Compare commits
No commits in common. "mine" and "access-api" have entirely different histories.
mine
...
access-api
155 changed files with 21138 additions and 25320 deletions
|
@ -1,3 +0,0 @@
|
||||||
dist
|
|
||||||
*/node_modules
|
|
||||||
Dockerfile*
|
|
|
@ -1,11 +0,0 @@
|
||||||
# https://docs.github.com/en/repositories/working-with-files/using-files/viewing-a-file#ignore-commits-in-the-blame-view
|
|
||||||
|
|
||||||
# Run prettier (https://github.com/binwiederhier/ntfy/pull/746)
|
|
||||||
6f6a2d1f693070bf72e89d86748080e4825c9164
|
|
||||||
c87549e71a10bc789eac8036078228f06e515a8e
|
|
||||||
ca5d736a7169eb6b4b0d849e061d5bf9565dcc53
|
|
||||||
2e27f58963feb9e4d1c573d4745d07770777fa7d
|
|
||||||
|
|
||||||
# Run eslint (https://github.com/binwiederhier/ntfy/pull/748)
|
|
||||||
f558b4dbe9bb5b9e0e87fada1215de2558353173
|
|
||||||
8319f1cf26113167fb29fe12edaff5db74caf35f
|
|
23
.github/workflows/build.yaml
vendored
23
.github/workflows/build.yaml
vendored
|
@ -4,21 +4,30 @@ jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
-
|
|
||||||
name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
-
|
-
|
||||||
name: Install Go
|
name: Install Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: '1.19.x'
|
go-version: '1.19.x'
|
||||||
-
|
-
|
||||||
name: Install node
|
name: Install node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '18'
|
||||||
cache: 'npm'
|
-
|
||||||
cache-dependency-path: './web/package-lock.json'
|
name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
-
|
||||||
|
name: Cache Go and npm modules
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/go/pkg/mod
|
||||||
|
~/go/bin
|
||||||
|
~/.npm
|
||||||
|
web/node_modules
|
||||||
|
key: ${{ runner.os }}-ntfy-${{ hashFiles('go.sum', 'web/package.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-ntfy-
|
||||||
-
|
-
|
||||||
name: Install dependencies
|
name: Install dependencies
|
||||||
run: make build-deps-ubuntu
|
run: make build-deps-ubuntu
|
||||||
|
|
2
.github/workflows/docs.yaml
vendored
2
.github/workflows/docs.yaml
vendored
|
@ -30,7 +30,7 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
cd build/ntfy-docs.github.io
|
cd build/ntfy-docs.github.io
|
||||||
git config user.name "GitHub Actions Bot"
|
git config user.name "GitHub Actions Bot"
|
||||||
git config user.email "<actions@github.com>"
|
git config user.email "<>"
|
||||||
git add docs/
|
git add docs/
|
||||||
git commit -m "Updated docs"
|
git commit -m "Updated docs"
|
||||||
git push origin main
|
git push origin main
|
||||||
|
|
23
.github/workflows/release.yaml
vendored
23
.github/workflows/release.yaml
vendored
|
@ -7,21 +7,30 @@ jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
-
|
|
||||||
name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
-
|
-
|
||||||
name: Install Go
|
name: Install Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: '1.19.x'
|
go-version: '1.19.x'
|
||||||
-
|
-
|
||||||
name: Install node
|
name: Install node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '18'
|
||||||
cache: 'npm'
|
-
|
||||||
cache-dependency-path: './web/package-lock.json'
|
name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
-
|
||||||
|
name: Cache Go and npm modules
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/go/pkg/mod
|
||||||
|
~/go/bin
|
||||||
|
~/.npm
|
||||||
|
web/node_modules
|
||||||
|
key: ${{ runner.os }}-ntfy-${{ hashFiles('go.sum', 'web/package.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-ntfy-
|
||||||
-
|
-
|
||||||
name: Docker login
|
name: Docker login
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
|
|
23
.github/workflows/test.yaml
vendored
23
.github/workflows/test.yaml
vendored
|
@ -4,21 +4,30 @@ jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
-
|
|
||||||
name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
-
|
-
|
||||||
name: Install Go
|
name: Install Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: '1.19.x'
|
go-version: '1.19.x'
|
||||||
-
|
-
|
||||||
name: Install node
|
name: Install node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '18'
|
||||||
cache: 'npm'
|
-
|
||||||
cache-dependency-path: './web/package-lock.json'
|
name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
-
|
||||||
|
name: Cache Go and npm modules
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/go/pkg/mod
|
||||||
|
~/go/bin
|
||||||
|
~/.npm
|
||||||
|
web/node_modules
|
||||||
|
key: ${{ runner.os }}-ntfy-${{ hashFiles('go.sum', 'web/package.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-ntfy-
|
||||||
-
|
-
|
||||||
name: Install dependencies
|
name: Install dependencies
|
||||||
run: make build-deps-ubuntu
|
run: make build-deps-ubuntu
|
||||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,5 +1,4 @@
|
||||||
dist/
|
dist/
|
||||||
dev-dist/
|
|
||||||
build/
|
build/
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
|
@ -97,7 +97,7 @@ nfpms:
|
||||||
- dst: /var/lib/ntfy
|
- dst: /var/lib/ntfy
|
||||||
type: dir
|
type: dir
|
||||||
- dst: /usr/share/ntfy/logo.png
|
- dst: /usr/share/ntfy/logo.png
|
||||||
src: web/public/static/images/ntfy.png
|
src: web/public/static/img/ntfy.png
|
||||||
scripts:
|
scripts:
|
||||||
preinstall: "scripts/preinst.sh"
|
preinstall: "scripts/preinst.sh"
|
||||||
postinstall: "scripts/postinst.sh"
|
postinstall: "scripts/postinst.sh"
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
FROM r.batts.cloud/debian:testing
|
FROM alpine
|
||||||
|
|
||||||
LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com"
|
LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com"
|
||||||
LABEL org.opencontainers.image.url="https://ntfy.sh/"
|
LABEL org.opencontainers.image.url="https://ntfy.sh/"
|
||||||
|
|
|
@ -1,54 +0,0 @@
|
||||||
FROM r.batts.cloud/golang:1.19 as builder
|
|
||||||
|
|
||||||
ARG VERSION=dev
|
|
||||||
ARG COMMIT=unknown
|
|
||||||
|
|
||||||
RUN apt-get update
|
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash
|
|
||||||
RUN apt-get install -y \
|
|
||||||
build-essential \
|
|
||||||
nodejs \
|
|
||||||
python3-pip \
|
|
||||||
python3-venv
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
ADD Makefile .
|
|
||||||
|
|
||||||
# docs
|
|
||||||
ADD ./requirements.txt .
|
|
||||||
RUN make docs-deps
|
|
||||||
ADD ./mkdocs.yml .
|
|
||||||
ADD ./docs ./docs
|
|
||||||
RUN make docs-build
|
|
||||||
|
|
||||||
# web
|
|
||||||
ADD ./web/package.json ./web/package-lock.json ./web/
|
|
||||||
RUN make web-deps
|
|
||||||
ADD ./web ./web
|
|
||||||
RUN make web-build
|
|
||||||
|
|
||||||
# cli & server
|
|
||||||
ADD go.mod go.sum main.go ./
|
|
||||||
ADD ./client ./client
|
|
||||||
ADD ./cmd ./cmd
|
|
||||||
ADD ./log ./log
|
|
||||||
ADD ./server ./server
|
|
||||||
ADD ./user ./user
|
|
||||||
ADD ./util ./util
|
|
||||||
RUN make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server
|
|
||||||
|
|
||||||
FROM r.batts.cloud/debian:testing
|
|
||||||
|
|
||||||
LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com"
|
|
||||||
LABEL org.opencontainers.image.url="https://ntfy.sh/"
|
|
||||||
LABEL org.opencontainers.image.documentation="https://docs.ntfy.sh/"
|
|
||||||
LABEL org.opencontainers.image.source="https://github.com/binwiederhier/ntfy"
|
|
||||||
LABEL org.opencontainers.image.vendor="Philipp C. Heckel"
|
|
||||||
LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0"
|
|
||||||
LABEL org.opencontainers.image.title="ntfy"
|
|
||||||
LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST"
|
|
||||||
|
|
||||||
COPY --from=builder /app/dist/ntfy_linux_server/ntfy /usr/bin/ntfy
|
|
||||||
|
|
||||||
EXPOSE 80/tcp
|
|
||||||
ENTRYPOINT ["ntfy"]
|
|
48
Makefile
48
Makefile
|
@ -31,16 +31,10 @@ help:
|
||||||
@echo " make cli-darwin-server - Build client & server (no GoReleaser, current arch, macOS)"
|
@echo " make cli-darwin-server - Build client & server (no GoReleaser, current arch, macOS)"
|
||||||
@echo " make cli-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)"
|
@echo " make cli-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)"
|
||||||
@echo
|
@echo
|
||||||
@echo "Build dev Docker:"
|
|
||||||
@echo " make docker-dev - Build client & server for current architecture using Docker only"
|
|
||||||
@echo
|
|
||||||
@echo "Build web app:"
|
@echo "Build web app:"
|
||||||
@echo " make web - Build the web app"
|
@echo " make web - Build the web app"
|
||||||
@echo " make web-deps - Install web app dependencies (npm install the universe)"
|
@echo " make web-deps - Install web app dependencies (npm install the universe)"
|
||||||
@echo " make web-build - Actually build the web app"
|
@echo " make web-build - Actually build the web app"
|
||||||
@echo " make web-lint - Run eslint on the web app"
|
|
||||||
@echo " make web-format - Run prettier on the web app"
|
|
||||||
@echo " make web-format-check - Run prettier on the web app, but don't change anything"
|
|
||||||
@echo
|
@echo
|
||||||
@echo "Build documentation:"
|
@echo "Build documentation:"
|
||||||
@echo " make docs - Build the documentation"
|
@echo " make docs - Build the documentation"
|
||||||
|
@ -86,33 +80,23 @@ build: web docs cli
|
||||||
update: web-deps-update cli-deps-update docs-deps-update
|
update: web-deps-update cli-deps-update docs-deps-update
|
||||||
docker pull alpine
|
docker pull alpine
|
||||||
|
|
||||||
docker-dev:
|
|
||||||
docker build \
|
|
||||||
--file ./Dockerfile-build \
|
|
||||||
--tag binwiederhier/ntfy:$(VERSION) \
|
|
||||||
--tag binwiederhier/ntfy:dev \
|
|
||||||
--build-arg VERSION=$(VERSION) \
|
|
||||||
--build-arg COMMIT=$(COMMIT) \
|
|
||||||
./
|
|
||||||
|
|
||||||
# Ubuntu-specific
|
# Ubuntu-specific
|
||||||
|
|
||||||
build-deps-ubuntu:
|
build-deps-ubuntu:
|
||||||
sudo apt-get update
|
sudo apt update
|
||||||
sudo apt-get install -y \
|
sudo apt install -y \
|
||||||
curl \
|
curl \
|
||||||
gcc-aarch64-linux-gnu \
|
gcc-aarch64-linux-gnu \
|
||||||
gcc-arm-linux-gnueabi \
|
gcc-arm-linux-gnueabi \
|
||||||
jq
|
jq
|
||||||
which pip3 || sudo apt-get install -y python3-pip
|
which pip3 || sudo apt install -y python3-pip
|
||||||
|
|
||||||
# Documentation
|
# Documentation
|
||||||
|
|
||||||
docs: docs-deps docs-build
|
docs: docs-deps docs-build
|
||||||
|
|
||||||
docs-build: venv .PHONY
|
docs-build: .PHONY
|
||||||
@. venv/bin/activate && \
|
@if ! /bin/echo -e "import sys\nif sys.version_info < (3,8):\n exit(1)" | python3; then \
|
||||||
if ! /bin/echo -e "import sys\nif sys.version_info < (3,8):\n exit(1)" | python3; then \
|
|
||||||
if which python3.8; then \
|
if which python3.8; then \
|
||||||
echo "python3.8 $(shell which mkdocs) build"; \
|
echo "python3.8 $(shell which mkdocs) build"; \
|
||||||
python3.8 $(shell which mkdocs) build; \
|
python3.8 $(shell which mkdocs) build; \
|
||||||
|
@ -125,15 +109,10 @@ docs-build: venv .PHONY
|
||||||
mkdocs build; \
|
mkdocs build; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
venv:
|
docs-deps: .PHONY
|
||||||
python3 -m venv ./venv
|
|
||||||
|
|
||||||
docs-deps: venv .PHONY
|
|
||||||
. venv/bin/activate && \
|
|
||||||
pip3 install -r requirements.txt
|
pip3 install -r requirements.txt
|
||||||
|
|
||||||
docs-deps-update: venv .PHONY
|
docs-deps-update: .PHONY
|
||||||
. venv/bin/activate && \
|
|
||||||
pip3 install -r requirements.txt --upgrade
|
pip3 install -r requirements.txt --upgrade
|
||||||
|
|
||||||
|
|
||||||
|
@ -148,7 +127,8 @@ web-build:
|
||||||
&& rm -rf ../server/site \
|
&& rm -rf ../server/site \
|
||||||
&& mv build ../server/site \
|
&& mv build ../server/site \
|
||||||
&& rm \
|
&& rm \
|
||||||
../server/site/config.js
|
../server/site/config.js \
|
||||||
|
../server/site/asset-manifest.json
|
||||||
|
|
||||||
web-deps:
|
web-deps:
|
||||||
cd web && npm install
|
cd web && npm install
|
||||||
|
@ -157,14 +137,6 @@ web-deps:
|
||||||
web-deps-update:
|
web-deps-update:
|
||||||
cd web && npm update
|
cd web && npm update
|
||||||
|
|
||||||
web-format:
|
|
||||||
cd web && npm run format
|
|
||||||
|
|
||||||
web-format-check:
|
|
||||||
cd web && npm run format:check
|
|
||||||
|
|
||||||
web-lint:
|
|
||||||
cd web && npm run lint
|
|
||||||
|
|
||||||
# Main server/client build
|
# Main server/client build
|
||||||
|
|
||||||
|
@ -254,7 +226,7 @@ cli-build-results:
|
||||||
|
|
||||||
# Test/check targets
|
# Test/check targets
|
||||||
|
|
||||||
check: test web-format-check fmt-check vet web-lint lint staticcheck
|
check: test fmt-check vet lint staticcheck
|
||||||
|
|
||||||
test: .PHONY
|
test: .PHONY
|
||||||
go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
||||||
|
|
|
@ -139,8 +139,6 @@ account costs. Even small donations are very much appreciated. A big fat **Thank
|
||||||
<a href="https://github.com/andrejarrell"><img src="https://github.com/andrejarrell.png" width="40px" /></a>
|
<a href="https://github.com/andrejarrell"><img src="https://github.com/andrejarrell.png" width="40px" /></a>
|
||||||
<a href="https://github.com/oaustegard"><img src="https://github.com/oaustegard.png" width="40px" /></a>
|
<a href="https://github.com/oaustegard"><img src="https://github.com/oaustegard.png" width="40px" /></a>
|
||||||
<a href="https://github.com/CreativeWarlock"><img src="https://github.com/CreativeWarlock.png" width="40px" /></a>
|
<a href="https://github.com/CreativeWarlock"><img src="https://github.com/CreativeWarlock.png" width="40px" /></a>
|
||||||
<a href="https://github.com/darkdragon-001"><img src="https://github.com/darkdragon-001.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/jonathan-kosgei"><img src="https://github.com/jonathan-kosgei.png" width="40px" /></a>
|
|
||||||
|
|
||||||
I'd also like to thank JetBrains for providing their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/) to me for free,
|
I'd also like to thank JetBrains for providing their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/) to me for free,
|
||||||
and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project:
|
and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project:
|
||||||
|
|
|
@ -11,25 +11,23 @@ import (
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Event type constants
|
||||||
const (
|
const (
|
||||||
// MessageEvent identifies a message event
|
MessageEvent = "message"
|
||||||
MessageEvent = "message"
|
KeepaliveEvent = "keepalive"
|
||||||
|
OpenEvent = "open"
|
||||||
|
PollRequestEvent = "poll_request"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxResponseBytes = 4096
|
maxResponseBytes = 4096
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // Same as in server/server.go
|
|
||||||
)
|
|
||||||
|
|
||||||
// Client is the ntfy client that can be used to publish and subscribe to ntfy topics
|
// Client is the ntfy client that can be used to publish and subscribe to ntfy topics
|
||||||
type Client struct {
|
type Client struct {
|
||||||
Messages chan *Message
|
Messages chan *Message
|
||||||
|
@ -98,14 +96,8 @@ func (c *Client) Publish(topic, message string, options ...PublishOption) (*Mess
|
||||||
// To pass title, priority and tags, check out WithTitle, WithPriority, WithTagsList, WithDelay, WithNoCache,
|
// To pass title, priority and tags, check out WithTitle, WithPriority, WithTagsList, WithDelay, WithNoCache,
|
||||||
// WithNoFirebase, and the generic WithHeader.
|
// WithNoFirebase, and the generic WithHeader.
|
||||||
func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishOption) (*Message, error) {
|
func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishOption) (*Message, error) {
|
||||||
topicURL, err := c.expandTopicURL(topic)
|
topicURL := c.expandTopicURL(topic)
|
||||||
if err != nil {
|
req, _ := http.NewRequest("POST", topicURL, body)
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest("POST", topicURL, body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, option := range options {
|
for _, option := range options {
|
||||||
if err := option(req); err != nil {
|
if err := option(req); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -141,14 +133,11 @@ func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishO
|
||||||
// By default, all messages will be returned, but you can change this behavior using a SubscribeOption.
|
// By default, all messages will be returned, but you can change this behavior using a SubscribeOption.
|
||||||
// See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam.
|
// See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam.
|
||||||
func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, error) {
|
func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, error) {
|
||||||
topicURL, err := c.expandTopicURL(topic)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
messages := make([]*Message, 0)
|
messages := make([]*Message, 0)
|
||||||
msgChan := make(chan *Message)
|
msgChan := make(chan *Message)
|
||||||
errChan := make(chan error)
|
errChan := make(chan error)
|
||||||
|
topicURL := c.expandTopicURL(topic)
|
||||||
log.Debug("%s Polling from topic", util.ShortTopicURL(topicURL))
|
log.Debug("%s Polling from topic", util.ShortTopicURL(topicURL))
|
||||||
options = append(options, WithPoll())
|
options = append(options, WithPoll())
|
||||||
go func() {
|
go func() {
|
||||||
|
@ -177,18 +166,15 @@ func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, err
|
||||||
// Example:
|
// Example:
|
||||||
//
|
//
|
||||||
// c := client.New(client.NewConfig())
|
// c := client.New(client.NewConfig())
|
||||||
// subscriptionID, _ := c.Subscribe("mytopic")
|
// subscriptionID := c.Subscribe("mytopic")
|
||||||
// for m := range c.Messages {
|
// for m := range c.Messages {
|
||||||
// fmt.Printf("New message: %s", m.Message)
|
// fmt.Printf("New message: %s", m.Message)
|
||||||
// }
|
// }
|
||||||
func (c *Client) Subscribe(topic string, options ...SubscribeOption) (string, error) {
|
func (c *Client) Subscribe(topic string, options ...SubscribeOption) string {
|
||||||
topicURL, err := c.expandTopicURL(topic)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
subscriptionID := util.RandomString(10)
|
subscriptionID := util.RandomString(10)
|
||||||
|
topicURL := c.expandTopicURL(topic)
|
||||||
log.Debug("%s Subscribing to topic", util.ShortTopicURL(topicURL))
|
log.Debug("%s Subscribing to topic", util.ShortTopicURL(topicURL))
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
c.subscriptions[subscriptionID] = &subscription{
|
c.subscriptions[subscriptionID] = &subscription{
|
||||||
|
@ -197,7 +183,7 @@ func (c *Client) Subscribe(topic string, options ...SubscribeOption) (string, er
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
}
|
}
|
||||||
go handleSubscribeConnLoop(ctx, c.Messages, topicURL, subscriptionID, options...)
|
go handleSubscribeConnLoop(ctx, c.Messages, topicURL, subscriptionID, options...)
|
||||||
return subscriptionID, nil
|
return subscriptionID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unsubscribe unsubscribes from a topic that has been previously subscribed to using the unique
|
// Unsubscribe unsubscribes from a topic that has been previously subscribed to using the unique
|
||||||
|
@ -213,16 +199,31 @@ func (c *Client) Unsubscribe(subscriptionID string) {
|
||||||
sub.cancel()
|
sub.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) expandTopicURL(topic string) (string, error) {
|
// UnsubscribeAll unsubscribes from a topic that has been previously subscribed with Subscribe.
|
||||||
|
// If there are multiple subscriptions matching the topic, all of them are unsubscribed from.
|
||||||
|
//
|
||||||
|
// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https://
|
||||||
|
// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the
|
||||||
|
// config (e.g. mytopic -> https://ntfy.sh/mytopic).
|
||||||
|
func (c *Client) UnsubscribeAll(topic string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
topicURL := c.expandTopicURL(topic)
|
||||||
|
for _, sub := range c.subscriptions {
|
||||||
|
if sub.topicURL == topicURL {
|
||||||
|
delete(c.subscriptions, sub.ID)
|
||||||
|
sub.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) expandTopicURL(topic string) string {
|
||||||
if strings.HasPrefix(topic, "http://") || strings.HasPrefix(topic, "https://") {
|
if strings.HasPrefix(topic, "http://") || strings.HasPrefix(topic, "https://") {
|
||||||
return topic, nil
|
return topic
|
||||||
} else if strings.Contains(topic, "/") {
|
} else if strings.Contains(topic, "/") {
|
||||||
return fmt.Sprintf("https://%s", topic), nil
|
return fmt.Sprintf("https://%s", topic)
|
||||||
}
|
}
|
||||||
if !topicRegex.MatchString(topic) {
|
return fmt.Sprintf("%s/%s", c.config.DefaultHost, topic)
|
||||||
return "", fmt.Errorf("invalid topic name: %s", topic)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s/%s", c.config.DefaultHost, topic), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicURL, subcriptionID string, options ...SubscribeOption) {
|
func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicURL, subcriptionID string, options ...SubscribeOption) {
|
||||||
|
|
|
@ -21,7 +21,7 @@ func TestClient_Publish_Subscribe(t *testing.T) {
|
||||||
defer test.StopServer(t, s, port)
|
defer test.StopServer(t, s, port)
|
||||||
c := client.New(newTestConfig(port))
|
c := client.New(newTestConfig(port))
|
||||||
|
|
||||||
subscriptionID, _ := c.Subscribe("mytopic")
|
subscriptionID := c.Subscribe("mytopic")
|
||||||
time.Sleep(time.Second)
|
time.Sleep(time.Second)
|
||||||
|
|
||||||
msg, err := c.Publish("mytopic", "some message")
|
msg, err := c.Publish("mytopic", "some message")
|
||||||
|
|
17
cmd/serve.go
17
cmd/serve.go
|
@ -64,7 +64,6 @@ var flagsServe = append(
|
||||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "allows users to log in via the web app, or API"}),
|
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "allows users to log in via the web app, or API"}),
|
||||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-reservations", Aliases: []string{"enable_reservations"}, EnvVars: []string{"NTFY_ENABLE_RESERVATIONS"}, Value: false, Usage: "allows users to reserve topics (if their tier allows it)"}),
|
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-reservations", Aliases: []string{"enable_reservations"}, EnvVars: []string{"NTFY_ENABLE_RESERVATIONS"}, Value: false, Usage: "allows users to reserve topics (if their tier allows it)"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-base-url", Aliases: []string{"upstream_base_url"}, EnvVars: []string{"NTFY_UPSTREAM_BASE_URL"}, Value: "", Usage: "forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-base-url", Aliases: []string{"upstream_base_url"}, EnvVars: []string{"NTFY_UPSTREAM_BASE_URL"}, Value: "", Usage: "forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-access-token", Aliases: []string{"upstream_access_token"}, EnvVars: []string{"NTFY_UPSTREAM_ACCESS_TOKEN"}, Value: "", Usage: "access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
|
||||||
|
@ -72,10 +71,6 @@ var flagsServe = append(
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", Aliases: []string{"smtp_server_listen"}, EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", Aliases: []string{"smtp_server_listen"}, EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-account", Aliases: []string{"twilio_account"}, EnvVars: []string{"NTFY_TWILIO_ACCOUNT"}, Usage: "Twilio account SID, used for phone calls, e.g. AC123..."}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-phone-number", Aliases: []string{"twilio_phone_number"}, EnvVars: []string{"NTFY_TWILIO_PHONE_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}),
|
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}),
|
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}),
|
||||||
|
@ -149,7 +144,6 @@ func execServe(c *cli.Context) error {
|
||||||
enableLogin := c.Bool("enable-login")
|
enableLogin := c.Bool("enable-login")
|
||||||
enableReservations := c.Bool("enable-reservations")
|
enableReservations := c.Bool("enable-reservations")
|
||||||
upstreamBaseURL := c.String("upstream-base-url")
|
upstreamBaseURL := c.String("upstream-base-url")
|
||||||
upstreamAccessToken := c.String("upstream-access-token")
|
|
||||||
smtpSenderAddr := c.String("smtp-sender-addr")
|
smtpSenderAddr := c.String("smtp-sender-addr")
|
||||||
smtpSenderUser := c.String("smtp-sender-user")
|
smtpSenderUser := c.String("smtp-sender-user")
|
||||||
smtpSenderPass := c.String("smtp-sender-pass")
|
smtpSenderPass := c.String("smtp-sender-pass")
|
||||||
|
@ -157,10 +151,6 @@ func execServe(c *cli.Context) error {
|
||||||
smtpServerListen := c.String("smtp-server-listen")
|
smtpServerListen := c.String("smtp-server-listen")
|
||||||
smtpServerDomain := c.String("smtp-server-domain")
|
smtpServerDomain := c.String("smtp-server-domain")
|
||||||
smtpServerAddrPrefix := c.String("smtp-server-addr-prefix")
|
smtpServerAddrPrefix := c.String("smtp-server-addr-prefix")
|
||||||
twilioAccount := c.String("twilio-account")
|
|
||||||
twilioAuthToken := c.String("twilio-auth-token")
|
|
||||||
twilioPhoneNumber := c.String("twilio-phone-number")
|
|
||||||
twilioVerifyService := c.String("twilio-verify-service")
|
|
||||||
totalTopicLimit := c.Int("global-topic-limit")
|
totalTopicLimit := c.Int("global-topic-limit")
|
||||||
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
|
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
|
||||||
visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting")
|
visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting")
|
||||||
|
@ -219,8 +209,6 @@ func execServe(c *cli.Context) error {
|
||||||
return errors.New("cannot set enable-signup without also setting enable-login")
|
return errors.New("cannot set enable-signup without also setting enable-login")
|
||||||
} else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") {
|
} else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") {
|
||||||
return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set")
|
return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set")
|
||||||
} else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") {
|
|
||||||
return errors.New("if twilio-account is set, twilio-auth-token, twilio-phone-number, twilio-verify-service, base-url, and auth-file must also be set")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backwards compatibility
|
// Backwards compatibility
|
||||||
|
@ -313,7 +301,6 @@ func execServe(c *cli.Context) error {
|
||||||
conf.DisallowedTopics = disallowedTopics
|
conf.DisallowedTopics = disallowedTopics
|
||||||
conf.WebRoot = webRoot
|
conf.WebRoot = webRoot
|
||||||
conf.UpstreamBaseURL = upstreamBaseURL
|
conf.UpstreamBaseURL = upstreamBaseURL
|
||||||
conf.UpstreamAccessToken = upstreamAccessToken
|
|
||||||
conf.SMTPSenderAddr = smtpSenderAddr
|
conf.SMTPSenderAddr = smtpSenderAddr
|
||||||
conf.SMTPSenderUser = smtpSenderUser
|
conf.SMTPSenderUser = smtpSenderUser
|
||||||
conf.SMTPSenderPass = smtpSenderPass
|
conf.SMTPSenderPass = smtpSenderPass
|
||||||
|
@ -321,10 +308,6 @@ func execServe(c *cli.Context) error {
|
||||||
conf.SMTPServerListen = smtpServerListen
|
conf.SMTPServerListen = smtpServerListen
|
||||||
conf.SMTPServerDomain = smtpServerDomain
|
conf.SMTPServerDomain = smtpServerDomain
|
||||||
conf.SMTPServerAddrPrefix = smtpServerAddrPrefix
|
conf.SMTPServerAddrPrefix = smtpServerAddrPrefix
|
||||||
conf.TwilioAccount = twilioAccount
|
|
||||||
conf.TwilioAuthToken = twilioAuthToken
|
|
||||||
conf.TwilioPhoneNumber = twilioPhoneNumber
|
|
||||||
conf.TwilioVerifyService = twilioVerifyService
|
|
||||||
conf.TotalTopicLimit = totalTopicLimit
|
conf.TotalTopicLimit = totalTopicLimit
|
||||||
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
||||||
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
|
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
|
||||||
|
|
|
@ -72,7 +72,7 @@ ntfy subscribe TOPIC COMMAND
|
||||||
$NTFY_TITLE $title, $t Message title
|
$NTFY_TITLE $title, $t Message title
|
||||||
$NTFY_PRIORITY $priority, $prio, $p Message priority (1=min, 5=max)
|
$NTFY_PRIORITY $priority, $prio, $p Message priority (1=min, 5=max)
|
||||||
$NTFY_TAGS $tags, $tag, $ta Message tags (comma separated list)
|
$NTFY_TAGS $tags, $tag, $ta Message tags (comma separated list)
|
||||||
$NTFY_RAW $raw Raw JSON message
|
$NTFY_RAW $raw Raw JSON message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages
|
ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages
|
||||||
|
@ -194,10 +194,7 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
|
||||||
topicOptions = append(topicOptions, auth)
|
topicOptions = append(topicOptions, auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
subscriptionID, err := cl.Subscribe(s.Topic, topicOptions...)
|
subscriptionID := cl.Subscribe(s.Topic, topicOptions...)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if s.Command != "" {
|
if s.Command != "" {
|
||||||
cmds[subscriptionID] = s.Command
|
cmds[subscriptionID] = s.Command
|
||||||
} else if conf.DefaultCommand != "" {
|
} else if conf.DefaultCommand != "" {
|
||||||
|
@ -207,10 +204,7 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if topic != "" {
|
if topic != "" {
|
||||||
subscriptionID, err := cl.Subscribe(topic, options...)
|
subscriptionID := cl.Subscribe(topic, options...)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
cmds[subscriptionID] = command
|
cmds[subscriptionID] = command
|
||||||
}
|
}
|
||||||
for m := range cl.Messages {
|
for m := range cl.Messages {
|
||||||
|
|
|
@ -18,7 +18,6 @@ const (
|
||||||
defaultMessageLimit = 5000
|
defaultMessageLimit = 5000
|
||||||
defaultMessageExpiryDuration = "12h"
|
defaultMessageExpiryDuration = "12h"
|
||||||
defaultEmailLimit = 20
|
defaultEmailLimit = 20
|
||||||
defaultCallLimit = 0
|
|
||||||
defaultReservationLimit = 3
|
defaultReservationLimit = 3
|
||||||
defaultAttachmentFileSizeLimit = "15M"
|
defaultAttachmentFileSizeLimit = "15M"
|
||||||
defaultAttachmentTotalSizeLimit = "100M"
|
defaultAttachmentTotalSizeLimit = "100M"
|
||||||
|
@ -49,7 +48,6 @@ var cmdTier = &cli.Command{
|
||||||
&cli.Int64Flag{Name: "message-limit", Value: defaultMessageLimit, Usage: "daily message limit"},
|
&cli.Int64Flag{Name: "message-limit", Value: defaultMessageLimit, Usage: "daily message limit"},
|
||||||
&cli.StringFlag{Name: "message-expiry-duration", Value: defaultMessageExpiryDuration, Usage: "duration after which messages are deleted"},
|
&cli.StringFlag{Name: "message-expiry-duration", Value: defaultMessageExpiryDuration, Usage: "duration after which messages are deleted"},
|
||||||
&cli.Int64Flag{Name: "email-limit", Value: defaultEmailLimit, Usage: "daily email limit"},
|
&cli.Int64Flag{Name: "email-limit", Value: defaultEmailLimit, Usage: "daily email limit"},
|
||||||
&cli.Int64Flag{Name: "call-limit", Value: defaultCallLimit, Usage: "daily phone call limit"},
|
|
||||||
&cli.Int64Flag{Name: "reservation-limit", Value: defaultReservationLimit, Usage: "topic reservation limit"},
|
&cli.Int64Flag{Name: "reservation-limit", Value: defaultReservationLimit, Usage: "topic reservation limit"},
|
||||||
&cli.StringFlag{Name: "attachment-file-size-limit", Value: defaultAttachmentFileSizeLimit, Usage: "per-attachment file size limit"},
|
&cli.StringFlag{Name: "attachment-file-size-limit", Value: defaultAttachmentFileSizeLimit, Usage: "per-attachment file size limit"},
|
||||||
&cli.StringFlag{Name: "attachment-total-size-limit", Value: defaultAttachmentTotalSizeLimit, Usage: "total size limit of attachments for the user"},
|
&cli.StringFlag{Name: "attachment-total-size-limit", Value: defaultAttachmentTotalSizeLimit, Usage: "total size limit of attachments for the user"},
|
||||||
|
@ -93,7 +91,6 @@ Examples:
|
||||||
&cli.Int64Flag{Name: "message-limit", Usage: "daily message limit"},
|
&cli.Int64Flag{Name: "message-limit", Usage: "daily message limit"},
|
||||||
&cli.StringFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"},
|
&cli.StringFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"},
|
||||||
&cli.Int64Flag{Name: "email-limit", Usage: "daily email limit"},
|
&cli.Int64Flag{Name: "email-limit", Usage: "daily email limit"},
|
||||||
&cli.Int64Flag{Name: "call-limit", Usage: "daily phone call limit"},
|
|
||||||
&cli.Int64Flag{Name: "reservation-limit", Usage: "topic reservation limit"},
|
&cli.Int64Flag{Name: "reservation-limit", Usage: "topic reservation limit"},
|
||||||
&cli.StringFlag{Name: "attachment-file-size-limit", Usage: "per-attachment file size limit"},
|
&cli.StringFlag{Name: "attachment-file-size-limit", Usage: "per-attachment file size limit"},
|
||||||
&cli.StringFlag{Name: "attachment-total-size-limit", Usage: "total size limit of attachments for the user"},
|
&cli.StringFlag{Name: "attachment-total-size-limit", Usage: "total size limit of attachments for the user"},
|
||||||
|
@ -218,7 +215,6 @@ func execTierAdd(c *cli.Context) error {
|
||||||
MessageLimit: c.Int64("message-limit"),
|
MessageLimit: c.Int64("message-limit"),
|
||||||
MessageExpiryDuration: messageExpiryDuration,
|
MessageExpiryDuration: messageExpiryDuration,
|
||||||
EmailLimit: c.Int64("email-limit"),
|
EmailLimit: c.Int64("email-limit"),
|
||||||
CallLimit: c.Int64("call-limit"),
|
|
||||||
ReservationLimit: c.Int64("reservation-limit"),
|
ReservationLimit: c.Int64("reservation-limit"),
|
||||||
AttachmentFileSizeLimit: attachmentFileSizeLimit,
|
AttachmentFileSizeLimit: attachmentFileSizeLimit,
|
||||||
AttachmentTotalSizeLimit: attachmentTotalSizeLimit,
|
AttachmentTotalSizeLimit: attachmentTotalSizeLimit,
|
||||||
|
@ -271,9 +267,6 @@ func execTierChange(c *cli.Context) error {
|
||||||
if c.IsSet("email-limit") {
|
if c.IsSet("email-limit") {
|
||||||
tier.EmailLimit = c.Int64("email-limit")
|
tier.EmailLimit = c.Int64("email-limit")
|
||||||
}
|
}
|
||||||
if c.IsSet("call-limit") {
|
|
||||||
tier.CallLimit = c.Int64("call-limit")
|
|
||||||
}
|
|
||||||
if c.IsSet("reservation-limit") {
|
if c.IsSet("reservation-limit") {
|
||||||
tier.ReservationLimit = c.Int64("reservation-limit")
|
tier.ReservationLimit = c.Int64("reservation-limit")
|
||||||
}
|
}
|
||||||
|
@ -364,7 +357,6 @@ func printTier(c *cli.Context, tier *user.Tier) {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Message limit: %d\n", tier.MessageLimit)
|
fmt.Fprintf(c.App.ErrWriter, "- Message limit: %d\n", tier.MessageLimit)
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds()))
|
fmt.Fprintf(c.App.ErrWriter, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds()))
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit)
|
fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit)
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit)
|
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit)
|
fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit)
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSize(tier.AttachmentFileSizeLimit))
|
fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSize(tier.AttachmentFileSizeLimit))
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSize(tier.AttachmentTotalSizeLimit))
|
fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSize(tier.AttachmentTotalSizeLimit))
|
||||||
|
|
|
@ -759,7 +759,6 @@ To configure it, simply set `upstream-base-url` like so:
|
||||||
|
|
||||||
``` yaml
|
``` yaml
|
||||||
upstream-base-url: "https://ntfy.sh"
|
upstream-base-url: "https://ntfy.sh"
|
||||||
upstream-access-token: "..." # optional, only if rate limits exceeded, or upstream server protected
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If set, all incoming messages will publish a poll request to the configured upstream server, containing
|
If set, all incoming messages will publish a poll request to the configured upstream server, containing
|
||||||
|
@ -815,7 +814,6 @@ ntfy tier add \
|
||||||
--message-limit=10000 \
|
--message-limit=10000 \
|
||||||
--message-expiry-duration=24h \
|
--message-expiry-duration=24h \
|
||||||
--email-limit=50 \
|
--email-limit=50 \
|
||||||
--call-limit=10 \
|
|
||||||
--reservation-limit=10 \
|
--reservation-limit=10 \
|
||||||
--attachment-file-size-limit=100M \
|
--attachment-file-size-limit=100M \
|
||||||
--attachment-total-size-limit=1G \
|
--attachment-total-size-limit=1G \
|
||||||
|
@ -856,22 +854,6 @@ stripe-webhook-key: "whsec_ZnNkZnNIRExBSFNES0hBRFNmaHNka2ZsaGR"
|
||||||
billing-contact: "phil@example.com"
|
billing-contact: "phil@example.com"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Phone calls
|
|
||||||
ntfy supports phone calls via [Twilio](https://www.twilio.com/) as a call provider. If phone calls are enabled,
|
|
||||||
users can verify and add a phone number, and then receive phone calls when publishing a message using the `X-Call` header.
|
|
||||||
See [publishing page](publish.md#phone-calls) for more details.
|
|
||||||
|
|
||||||
To enable Twilio integration, sign up with [Twilio](https://www.twilio.com/), purchase a phone number (Toll free numbers
|
|
||||||
are the easiest), and then configure the following options:
|
|
||||||
|
|
||||||
* `twilio-account` is the Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586
|
|
||||||
* `twilio-auth-token` is the Twilio auth token, e.g. affebeef258625862586258625862586
|
|
||||||
* `twilio-phone-number` is the outgoing phone number you purchased, e.g. +18775132586
|
|
||||||
* `twilio-verify-service` is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586
|
|
||||||
|
|
||||||
After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`),
|
|
||||||
and then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message.
|
|
||||||
|
|
||||||
## Rate limiting
|
## Rate limiting
|
||||||
!!! info
|
!!! info
|
||||||
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.
|
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.
|
||||||
|
@ -1259,15 +1241,10 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||||
| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` |
|
| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` |
|
||||||
| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
|
| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
|
||||||
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | *string* | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
|
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | *string* | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
|
||||||
| `twilio-account` | `NTFY_TWILIO_ACCOUNT` | *string* | - | Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586 |
|
|
||||||
| `twilio-auth-token` | `NTFY_TWILIO_AUTH_TOKEN` | *string* | - | Twilio auth token, e.g. affebeef258625862586258625862586 |
|
|
||||||
| `twilio-phone-number` | `NTFY_TWILIO_PHONE_NUMBER` | *string* | - | Twilio outgoing phone number, e.g. +18775132586 |
|
|
||||||
| `twilio-verify-service` | `NTFY_TWILIO_VERIFY_SERVICE` | *string* | - | Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 |
|
|
||||||
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
|
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
|
||||||
| `manager-interval` | `NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
|
| `manager-interval` | `NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
|
||||||
| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. |
|
| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. |
|
||||||
| `upstream-base-url` | `NTFY_UPSTREAM_BASE_URL` | *URL* | `https://ntfy.sh` | Forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers |
|
| `upstream-base-url` | `NTFY_UPSTREAM_BASE_URL` | *URL* | `https://ntfy.sh` | Forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers |
|
||||||
| `upstream-access-token` | `NTFY_UPSTREAM_ACCESS_TOKEN` | *string* | `tk_zyYLYj...` | Access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth |
|
|
||||||
| `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. |
|
| `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. |
|
||||||
| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. |
|
| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. |
|
||||||
| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor |
|
| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor |
|
||||||
|
|
|
@ -163,15 +163,6 @@ $ make release-snapshot
|
||||||
|
|
||||||
During development, you may want to be more picky and build only certain things. Here are a few examples.
|
During development, you may want to be more picky and build only certain things. Here are a few examples.
|
||||||
|
|
||||||
### Build a Docker image only for Linux
|
|
||||||
|
|
||||||
This is useful to test the final build with web app, docs, and server without any dependencies locally
|
|
||||||
|
|
||||||
``` shell
|
|
||||||
$ make docker-dev
|
|
||||||
$ docker run --rm -p 80:80 binwiederhier/ntfy:dev serve
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build the ntfy binary
|
### Build the ntfy binary
|
||||||
To build only the `ntfy` binary **without the web app or documentation**, use the `make cli-...` targets:
|
To build only the `ntfy` binary **without the web app or documentation**, use the `make cli-...` targets:
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ I started adding notifications pretty much all of my scripts. Typically, I just
|
||||||
directly to the command I'm running. The following example will either send <i>Laptop backup succeeded</i>
|
directly to the command I'm running. The following example will either send <i>Laptop backup succeeded</i>
|
||||||
or ⚠️ <i>Laptop backup failed</i> directly to my phone:
|
or ⚠️ <i>Laptop backup failed</i> directly to my phone:
|
||||||
|
|
||||||
``` bash
|
```
|
||||||
rsync -a root@laptop /backups/laptop \
|
rsync -a root@laptop /backups/laptop \
|
||||||
&& zfs snapshot ... \
|
&& zfs snapshot ... \
|
||||||
&& curl -H prio:low -d "Laptop backup succeeded" ntfy.sh/backups \
|
&& curl -H prio:low -d "Laptop backup succeeded" ntfy.sh/backups \
|
||||||
|
@ -26,7 +26,7 @@ rsync -a root@laptop /backups/laptop \
|
||||||
Here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with
|
Here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with
|
||||||
GitHub have been hopeless. In case it ever becomes available, I want to know immediately.
|
GitHub have been hopeless. In case it ever becomes available, I want to know immediately.
|
||||||
|
|
||||||
```
|
``` cron
|
||||||
# Check github/ntfy user
|
# Check github/ntfy user
|
||||||
*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi
|
*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi
|
||||||
```
|
```
|
||||||
|
@ -136,33 +136,27 @@ You can send a message during a workflow run with curl. Here is an example sendi
|
||||||
```
|
```
|
||||||
|
|
||||||
## Watchtower (shoutrrr)
|
## Watchtower (shoutrrr)
|
||||||
You can use [shoutrrr](https://containrrr.dev/shoutrrr/latest/services/ntfy/) to send
|
You can use [shoutrrr](https://github.com/containrrr/shoutrrr) generic webhook support to send
|
||||||
[Watchtower](https://github.com/containrrr/watchtower/) notifications to your ntfy topic.
|
[Watchtower](https://github.com/containrrr/watchtower/) notifications to your ntfy topic.
|
||||||
|
|
||||||
Example docker-compose.yml:
|
Example docker-compose.yml:
|
||||||
|
|
||||||
``` yaml
|
``` yaml
|
||||||
services:
|
services:
|
||||||
watchtower:
|
watchtower:
|
||||||
image: containrrr/watchtower
|
image: containrrr/watchtower
|
||||||
environment:
|
environment:
|
||||||
- WATCHTOWER_NOTIFICATIONS=shoutrrr
|
- WATCHTOWER_NOTIFICATIONS=shoutrrr
|
||||||
- WATCHTOWER_NOTIFICATION_URL=ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates
|
- WATCHTOWER_NOTIFICATION_URL=generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates
|
||||||
```
|
```
|
||||||
|
|
||||||
Or, if you only want to send notifications using shoutrrr:
|
Or, if you only want to send notifications using shoutrrr:
|
||||||
```
|
```
|
||||||
shoutrrr send -u "ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage"
|
shoutrrr send -u "generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd
|
## Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd
|
||||||
|
It's possible to use custom scripts for all the *arr services, plus SABnzbd. Notifications for downloads, warnings, grabs etc.
|
||||||
<!-- Sonarr v4 is in beta as of May 2023, should be updated to remove v3 reference when stable -->
|
Some simple bash scripts to achieve this are kindly provided in [nickexyz's repository](https://github.com/nickexyz/ntfy-shellscripts).
|
||||||
|
|
||||||
Radarr, Prowlarr, and Sonarr v4 support ntfy natively under Settings > Connect.
|
|
||||||
|
|
||||||
Sonarr v3, Readarr, and SABnzbd support custom scripts for downloads, warnings, grabs, etc.
|
|
||||||
Some simple bash scripts to achieve this are kindly provided in [nickexyz's ntfy-shellscripts repository](https://github.com/nickexyz/ntfy-shellscripts).
|
|
||||||
|
|
||||||
## Node-RED
|
## Node-RED
|
||||||
You can use the HTTP request node to send messages with [Node-RED](https://nodered.org), some examples:
|
You can use the HTTP request node to send messages with [Node-RED](https://nodered.org), some examples:
|
||||||
|
|
|
@ -29,37 +29,37 @@ deb/rpm packages.
|
||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_x86_64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_linux_x86_64.tar.gz
|
||||||
tar zxvf ntfy_2.5.0_linux_x86_64.tar.gz
|
tar zxvf ntfy_2.4.0_linux_x86_64.tar.gz
|
||||||
sudo cp -a ntfy_2.5.0_linux_x86_64/ntfy /usr/local/bin/ntfy
|
sudo cp -a ntfy_2.4.0_linux_x86_64/ntfy /usr/local/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.4.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv6.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_linux_armv6.tar.gz
|
||||||
tar zxvf ntfy_2.5.0_linux_armv6.tar.gz
|
tar zxvf ntfy_2.4.0_linux_armv6.tar.gz
|
||||||
sudo cp -a ntfy_2.5.0_linux_armv6/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.4.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.4.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv7.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_linux_armv7.tar.gz
|
||||||
tar zxvf ntfy_2.5.0_linux_armv7.tar.gz
|
tar zxvf ntfy_2.4.0_linux_armv7.tar.gz
|
||||||
sudo cp -a ntfy_2.5.0_linux_armv7/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.4.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.4.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_arm64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_linux_arm64.tar.gz
|
||||||
tar zxvf ntfy_2.5.0_linux_arm64.tar.gz
|
tar zxvf ntfy_2.4.0_linux_arm64.tar.gz
|
||||||
sudo cp -a ntfy_2.5.0_linux_arm64/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.4.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.4.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -109,7 +109,7 @@ Manually installing the .deb file:
|
||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_amd64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_linux_amd64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
|
@ -117,7 +117,7 @@ Manually installing the .deb file:
|
||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv6.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_linux_armv6.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
|
@ -125,7 +125,7 @@ Manually installing the .deb file:
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv7.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_linux_armv7.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
|
@ -133,7 +133,7 @@ Manually installing the .deb file:
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_arm64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_linux_arm64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
|
@ -143,28 +143,28 @@ Manually installing the .deb file:
|
||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_amd64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_linux_amd64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv6.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_linux_armv6.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv7.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_linux_armv7.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_arm64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_linux_arm64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
@ -192,18 +192,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
|
||||||
|
|
||||||
## macOS
|
## macOS
|
||||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
||||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_macOS_all.tar.gz),
|
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_macOS_all.tar.gz),
|
||||||
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
||||||
|
|
||||||
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
||||||
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_macOS_all.tar.gz > ntfy_2.5.0_macOS_all.tar.gz
|
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_macOS_all.tar.gz > ntfy_2.4.0_macOS_all.tar.gz
|
||||||
tar zxvf ntfy_2.5.0_macOS_all.tar.gz
|
tar zxvf ntfy_2.4.0_macOS_all.tar.gz
|
||||||
sudo cp -a ntfy_2.5.0_macOS_all/ntfy /usr/local/bin/ntfy
|
sudo cp -a ntfy_2.4.0_macOS_all/ntfy /usr/local/bin/ntfy
|
||||||
mkdir ~/Library/Application\ Support/ntfy
|
mkdir ~/Library/Application\ Support/ntfy
|
||||||
cp ntfy_2.5.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
cp ntfy_2.4.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||||
ntfy --help
|
ntfy --help
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -221,7 +221,7 @@ brew install ntfy
|
||||||
|
|
||||||
## Windows
|
## Windows
|
||||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
|
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
|
||||||
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_windows_x86_64.zip),
|
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_windows_x86_64.zip),
|
||||||
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
||||||
|
|
||||||
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
|
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
|
||||||
|
|
|
@ -104,7 +104,6 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||||
- [ntfy_on_a_chip](https://github.com/gergepalfi/ntfy_on_a_chip) - ESP8266 and ESP32 client code to communicate with ntfy
|
- [ntfy_on_a_chip](https://github.com/gergepalfi/ntfy_on_a_chip) - ESP8266 and ESP32 client code to communicate with ntfy
|
||||||
- [ntfy-sdk](https://github.com/yukibtc/ntfy-sdk) - ntfy client library to send notifications (Rust)
|
- [ntfy-sdk](https://github.com/yukibtc/ntfy-sdk) - ntfy client library to send notifications (Rust)
|
||||||
- [ntfy_ynh](https://github.com/YunoHost-Apps/ntfy_ynh) - ntfy app for YunoHost
|
- [ntfy_ynh](https://github.com/YunoHost-Apps/ntfy_ynh) - ntfy app for YunoHost
|
||||||
- [woodpecker-ntfy](https://codeberg.org/l-x/woodpecker-ntfy)- Woodpecker CI plugin for sending ntfy notfication from a pipeline (Go)
|
|
||||||
- [drone-ntfy](https://github.com/Clortox/drone-ntfy) - Drone.io plugin for sending ntfy notifications from a pipeline (Shell)
|
- [drone-ntfy](https://github.com/Clortox/drone-ntfy) - Drone.io plugin for sending ntfy notifications from a pipeline (Shell)
|
||||||
- [ignition-ntfy-module](https://github.com/Kyvis-Labs/ignition-ntfy-module) - Adds support for sending notifications via a ntfy server to Ignition (Java)
|
- [ignition-ntfy-module](https://github.com/Kyvis-Labs/ignition-ntfy-module) - Adds support for sending notifications via a ntfy server to Ignition (Java)
|
||||||
- [maubot-ntfy](https://gitlab.com/999eagle/maubot-ntfy) - Matrix bot to subscribe to ntfy topics and send messages to Matrix (Python)
|
- [maubot-ntfy](https://gitlab.com/999eagle/maubot-ntfy) - Matrix bot to subscribe to ntfy topics and send messages to Matrix (Python)
|
||||||
|
|
176
docs/publish.md
176
docs/publish.md
|
@ -393,8 +393,8 @@ you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).
|
||||||
|
|
||||||
!!! info
|
!!! info
|
||||||
ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/).
|
ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/).
|
||||||
If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode any header (including the title)
|
If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode the `X-Title` or `X-Message`
|
||||||
as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)),
|
header as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)),
|
||||||
or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)).
|
or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)).
|
||||||
|
|
||||||
## Message priority
|
## Message priority
|
||||||
|
@ -619,7 +619,7 @@ them with a comma, e.g. `tag1,tag2,tag3`.
|
||||||
|
|
||||||
!!! info
|
!!! info
|
||||||
ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/).
|
ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/).
|
||||||
If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode the tags header or individual tags
|
If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode the individual tags
|
||||||
as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `tag1,=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)),
|
as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `tag1,=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)),
|
||||||
or `=?UTF-8?Q?=C3=84pfel?=,tag2` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)).
|
or `=?UTF-8?Q?=C3=84pfel?=,tag2` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)).
|
||||||
|
|
||||||
|
@ -1004,11 +1004,9 @@ all the supported fields:
|
||||||
| `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications |
|
| `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications |
|
||||||
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) |
|
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) |
|
||||||
| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-url) |
|
| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-url) |
|
||||||
| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) |
|
|
||||||
| `filename` | - | *string* | `file.jpg` | File name of the attachment |
|
| `filename` | - | *string* | `file.jpg` | File name of the attachment |
|
||||||
| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
|
| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
|
||||||
| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
|
| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
|
||||||
| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) |
|
|
||||||
|
|
||||||
## Action buttons
|
## Action buttons
|
||||||
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||||
|
@ -1141,13 +1139,7 @@ As an example, here's how you can create the above notification using this forma
|
||||||
]
|
]
|
||||||
]));
|
]));
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! info
|
|
||||||
ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/).
|
|
||||||
If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode any header (including actions)
|
|
||||||
as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)),
|
|
||||||
or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)).
|
|
||||||
|
|
||||||
#### Using a JSON array
|
#### Using a JSON array
|
||||||
Alternatively, the same actions can be defined as **JSON array**, if the notification is defined as part of the JSON body
|
Alternatively, the same actions can be defined as **JSON array**, if the notification is defined as part of the JSON body
|
||||||
(see [publish as JSON](#publish-as-json)):
|
(see [publish as JSON](#publish-as-json)):
|
||||||
|
@ -2703,133 +2695,6 @@ title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://nt
|
||||||
<figcaption>Publishing a message via e-mail</figcaption>
|
<figcaption>Publishing a message via e-mail</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
## Phone calls
|
|
||||||
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
|
||||||
|
|
||||||
You can use ntfy to call a phone and **read the message out loud using text-to-speech**.
|
|
||||||
Similar to email notifications, this can be useful to blast-notify yourself on all possible channels, or to notify people that do not have
|
|
||||||
the ntfy app installed on their phone.
|
|
||||||
|
|
||||||
**Phone numbers have to be previously verified** (via the [web app](https://ntfy.sh/account)), so this feature is
|
|
||||||
**only available to authenticated users** (no anonymous phone calls). To forward a message as a voice call, pass a phone
|
|
||||||
number in the `X-Call` header (or its alias: `Call`), prefixed with a plus sign and the country code, e.g. `+12223334444`.
|
|
||||||
You may also simply pass `yes` as a value to pick the first of your verified phone numbers.
|
|
||||||
On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) plans.
|
|
||||||
|
|
||||||
<figure markdown>
|
|
||||||
![phone number verification](static/img/web-phone-verify.png)
|
|
||||||
<figcaption>Phone number verification in the <a href="https://ntfy.sh/account">web app</a></figcaption>
|
|
||||||
</figure>
|
|
||||||
|
|
||||||
As of today, the text-to-speed voice used will only support English. If there is demand for other languages, we'll
|
|
||||||
be happy to add support for that. Please [open an issue on GitHub](https://github.com/binwiederhier/ntfy/issues).
|
|
||||||
|
|
||||||
!!! info
|
|
||||||
You are responsible for the message content, and **you must abide by the [Twilio Acceptable Use Policy](https://www.twilio.com/en-us/legal/aup)**.
|
|
||||||
This particularly means that you must not use this feature to send unsolicited messages, or messages that are illegal or
|
|
||||||
violate the rights of others. Please read the policy for details. Failure to do so may result in your account being suspended or terminated.
|
|
||||||
|
|
||||||
Here's how you use it:
|
|
||||||
|
|
||||||
=== "Command line (curl)"
|
|
||||||
```
|
|
||||||
curl \
|
|
||||||
-u :tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \
|
|
||||||
-H "Call: +12223334444" \
|
|
||||||
-d "Your garage seems to be on fire. You should probably check that out." \
|
|
||||||
ntfy.sh/alerts
|
|
||||||
```
|
|
||||||
|
|
||||||
=== "ntfy CLI"
|
|
||||||
```
|
|
||||||
ntfy publish \
|
|
||||||
--token=tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \
|
|
||||||
--call=+12223334444 \
|
|
||||||
alerts "Your garage seems to be on fire. You should probably check that out."
|
|
||||||
```
|
|
||||||
|
|
||||||
=== "HTTP"
|
|
||||||
``` http
|
|
||||||
POST /alerts HTTP/1.1
|
|
||||||
Host: ntfy.sh
|
|
||||||
Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
|
||||||
Call: +12223334444
|
|
||||||
|
|
||||||
Your garage seems to be on fire. You should probably check that out.
|
|
||||||
```
|
|
||||||
|
|
||||||
=== "JavaScript"
|
|
||||||
``` javascript
|
|
||||||
fetch('https://ntfy.sh/alerts', {
|
|
||||||
method: 'POST',
|
|
||||||
body: "Your garage seems to be on fire. You should probably check that out.",
|
|
||||||
headers: {
|
|
||||||
'Authorization': 'Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2',
|
|
||||||
'Call': '+12223334444'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
=== "Go"
|
|
||||||
``` go
|
|
||||||
req, _ := http.NewRequest("POST", "https://ntfy.sh/alerts",
|
|
||||||
strings.NewReader("Your garage seems to be on fire. You should probably check that out."))
|
|
||||||
req.Header.Set("Call", "+12223334444")
|
|
||||||
req.Header.Set("Authorization", "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2")
|
|
||||||
http.DefaultClient.Do(req)
|
|
||||||
```
|
|
||||||
|
|
||||||
=== "PowerShell"
|
|
||||||
``` powershell
|
|
||||||
$Request = @{
|
|
||||||
Method = "POST"
|
|
||||||
URI = "https://ntfy.sh/alerts"
|
|
||||||
Headers = @{
|
|
||||||
Authorization = "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2"
|
|
||||||
Call = "+12223334444"
|
|
||||||
}
|
|
||||||
Body = "Your garage seems to be on fire. You should probably check that out."
|
|
||||||
}
|
|
||||||
Invoke-RestMethod @Request
|
|
||||||
```
|
|
||||||
|
|
||||||
=== "Python"
|
|
||||||
``` python
|
|
||||||
requests.post("https://ntfy.sh/alerts",
|
|
||||||
data="Your garage seems to be on fire. You should probably check that out.",
|
|
||||||
headers={
|
|
||||||
"Authorization": "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2",
|
|
||||||
"Call": "+12223334444"
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
=== "PHP"
|
|
||||||
``` php-inline
|
|
||||||
file_get_contents('https://ntfy.sh/alerts', false, stream_context_create([
|
|
||||||
'http' => [
|
|
||||||
'method' => 'POST',
|
|
||||||
'header' =>
|
|
||||||
"Content-Type: text/plain\r\n" .
|
|
||||||
"Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\r\n" .
|
|
||||||
"Call: +12223334444",
|
|
||||||
'content' => 'Your garage seems to be on fire. You should probably check that out.'
|
|
||||||
]
|
|
||||||
]));
|
|
||||||
```
|
|
||||||
|
|
||||||
Here's what a phone call from ntfy sounds like:
|
|
||||||
|
|
||||||
<audio controls>
|
|
||||||
<source src="../static/audio/ntfy-phone-call.mp3" type="audio/mpeg">
|
|
||||||
<source src="../static/audio/ntfy-phone-call.ogg" type="audio/ogg">
|
|
||||||
</audio>
|
|
||||||
|
|
||||||
Audio transcript:
|
|
||||||
|
|
||||||
> You have a notification from ntfy on topic alerts.
|
|
||||||
> Message: Your garage seems to be on fire. You should probably check that out. End message.
|
|
||||||
> This message was sent by user phil. It will be repeated up to three times.
|
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
Depending on whether the server is configured to support [access control](config.md#access-control), some topics
|
Depending on whether the server is configured to support [access control](config.md#access-control), some topics
|
||||||
may be read/write protected so that only users with the correct credentials can subscribe or publish to them.
|
may be read/write protected so that only users with the correct credentials can subscribe or publish to them.
|
||||||
|
@ -2911,7 +2776,6 @@ Here's an example with a user `testuser` and password `fakepassword`:
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "PowerShell 5 and earlier"
|
=== "PowerShell 5 and earlier"
|
||||||
``` powershell
|
|
||||||
# With PowerShell 5 or earlier, we need to create the base64 username:password string ourselves
|
# With PowerShell 5 or earlier, we need to create the base64 username:password string ourselves
|
||||||
$CredentialString = "$($Credential.Username):$($Credential.GetNetworkCredential().Password)"
|
$CredentialString = "$($Credential.Username):$($Credential.GetNetworkCredential().Password)"
|
||||||
$EncodedCredential = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($CredentialString))
|
$EncodedCredential = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($CredentialString))
|
||||||
|
@ -3238,12 +3102,6 @@ The following command will generate the appropriate value for you on *nix system
|
||||||
echo -n "Basic `echo -n 'testuser:fakepassword' | base64`" | base64 | tr -d '='
|
echo -n "Basic `echo -n 'testuser:fakepassword' | base64`" | base64 | tr -d '='
|
||||||
```
|
```
|
||||||
|
|
||||||
For access tokens, you can use this instead:
|
|
||||||
|
|
||||||
```
|
|
||||||
echo -n "Bearer faketoken" | base64 | tr -d '='
|
|
||||||
```
|
|
||||||
|
|
||||||
## Advanced features
|
## Advanced features
|
||||||
|
|
||||||
### Message caching
|
### Message caching
|
||||||
|
@ -3456,18 +3314,17 @@ There are a few limitations to the API to prevent abuse and to keep the server h
|
||||||
are configurable via the server side [rate limiting settings](config.md#rate-limiting). Most of these limits you won't run into,
|
are configurable via the server side [rate limiting settings](config.md#rate-limiting). Most of these limits you won't run into,
|
||||||
but just in case, let's list them all:
|
but just in case, let's list them all:
|
||||||
|
|
||||||
| Limit | Description |
|
| Limit | Description |
|
||||||
|----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|---------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). |
|
| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). |
|
||||||
| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 5 seconds. |
|
| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 5 seconds. |
|
||||||
| **Daily messages** | By default, the number of messages is governed by the request limits. This can be overridden. On ntfy.sh, the daily message limit is 250. |
|
| **Daily messages** | By default, the number of messages is governed by the request limits. This can be overridden. On ntfy.sh, the daily message limit is 250. |
|
||||||
| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. On ntfy.sh, the daily limit is 5. |
|
| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. On ntfy.sh, the daily limit is 5. |
|
||||||
| **Phone calls** | By default, the server does not allow any phone calls, except for users with a tier that has a call limit. |
|
| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. |
|
||||||
| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. |
|
| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. On ntfy.sh, the attachment size limit is 2 MB, and the per-visitor total is 20 MB. |
|
||||||
| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. On ntfy.sh, the attachment size limit is 2 MB, and the per-visitor total is 20 MB. |
|
| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. |
|
||||||
| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. |
|
| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. On ntfy.sh, the daily bandwidth limit is 200 MB. |
|
||||||
| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. On ntfy.sh, the daily bandwidth limit is 200 MB. |
|
| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. |
|
||||||
| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. |
|
|
||||||
|
|
||||||
These limits can be changed on a per-user basis using [tiers](config.md#tiers). If [payments](config.md#payments) are enabled, a user tier can be changed by purchasing
|
These limits can be changed on a per-user basis using [tiers](config.md#tiers). If [payments](config.md#payments) are enabled, a user tier can be changed by purchasing
|
||||||
a higher tier. ntfy.sh offers multiple paid tiers, which allows for much hier limits than the ones listed above.
|
a higher tier. ntfy.sh offers multiple paid tiers, which allows for much hier limits than the ones listed above.
|
||||||
|
@ -3479,7 +3336,7 @@ table in their canonical form.
|
||||||
|
|
||||||
!!! info
|
!!! info
|
||||||
ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/).
|
ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/).
|
||||||
If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode any
|
If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode the `X-Title` or `X-Message`
|
||||||
header as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)),
|
header as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)),
|
||||||
or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)).
|
or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)).
|
||||||
|
|
||||||
|
@ -3496,7 +3353,6 @@ table in their canonical form.
|
||||||
| `X-Icon` | `Icon` | URL to use as notification [icon](#icons) |
|
| `X-Icon` | `Icon` | URL to use as notification [icon](#icons) |
|
||||||
| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client |
|
| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client |
|
||||||
| `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) |
|
| `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) |
|
||||||
| `X-Call` | `Call` | Phone number for [phone calls](#phone-calls) |
|
|
||||||
| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
|
| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
|
||||||
| `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |
|
| `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |
|
||||||
| `X-UnifiedPush` | `UnifiedPush`, `up` | [UnifiedPush](#unifiedpush) publish option, only to be used by UnifiedPush apps |
|
| `X-UnifiedPush` | `UnifiedPush`, `up` | [UnifiedPush](#unifiedpush) publish option, only to be used by UnifiedPush apps |
|
||||||
|
|
|
@ -2,33 +2,7 @@
|
||||||
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
||||||
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
||||||
|
|
||||||
## ntfy server v2.5.0
|
### ntfy server v2.4.0
|
||||||
Released May 18, 2023
|
|
||||||
|
|
||||||
This release brings a number of new features, including support for text-to-speech style [phone calls](publish.md#phone-calls),
|
|
||||||
an admin API to manage users and ACL (currently in beta, and hence undocumented), and support for authorized access to
|
|
||||||
upstream servers via the `upstream-access-token` config option.
|
|
||||||
|
|
||||||
❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier)
|
|
||||||
and [Liberapay](https://en.liberapay.com/ntfy/), or by buying a [paid plan via the web app](https://ntfy.sh/app) (20% off
|
|
||||||
if you use promo code `MYTOPIC`). ntfy will always remain open source.
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
|
|
||||||
* Support for text-to-speech style [phone calls](publish.md#phone-calls) using the `X-Call` header (no ticket)
|
|
||||||
* Admin API to manage users and ACL, `v1/users` + `v1/users/access` (intentionally undocumented as of now, [#722](https://github.com/binwiederhier/ntfy/issues/722), thanks to [@CreativeWarlock](https://github.com/CreativeWarlock) for sponsoring this ticket)
|
|
||||||
* Added `upstream-access-token` config option to allow authorized access to upstream servers (no ticket)
|
|
||||||
|
|
||||||
**Bug fixes + maintenance:**
|
|
||||||
|
|
||||||
* Removed old ntfy website from ntfy entirely (no ticket)
|
|
||||||
* Make emoji lookup for emails more efficient ([#725](https://github.com/binwiederhier/ntfy/pull/725), thanks to [@adamantike](https://github.com/adamantike))
|
|
||||||
* Fix potential subscriber ID clash ([#712](https://github.com/binwiederhier/ntfy/issues/712), thanks to [@peterbourgon](https://github.com/peterbourgon) for reporting, and [@dropdevrahul](https://github.com/dropdevrahul) for fixing)
|
|
||||||
* Support for `quoted-printable` in incoming emails ([#719](https://github.com/binwiederhier/ntfy/pull/719), thanks to [@Aerion](https://github.com/Aerion))
|
|
||||||
* Attachments with filenames that are downloaded using a browser will now download with the proper filename ([#726](https://github.com/binwiederhier/ntfy/issues/726), thanks to [@un99known99](https://github.com/un99known99) for reporting, and [@wunter8](https://github.com/wunter8) for fixing)
|
|
||||||
* Fix web app i18n issue in account preferences ([#730](https://github.com/binwiederhier/ntfy/issues/730), thanks to [@codebude](https://github.com/codebude) for reporting)
|
|
||||||
|
|
||||||
## ntfy server v2.4.0
|
|
||||||
Released Apr 26, 2023
|
Released Apr 26, 2023
|
||||||
|
|
||||||
This release adds a tiny `v1/stats` endpoint to expose how many messages have been published, and adds suport to encode the `X-Title`,
|
This release adds a tiny `v1/stats` endpoint to expose how many messages have been published, and adds suport to encode the `X-Title`,
|
||||||
|
@ -57,7 +31,7 @@ will always remain open source.
|
||||||
|
|
||||||
* Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/Shjosan/))
|
* Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/Shjosan/))
|
||||||
|
|
||||||
## ntfy server v2.3.1
|
### ntfy server v2.3.1
|
||||||
Released March 30, 2023
|
Released March 30, 2023
|
||||||
|
|
||||||
This release disables server-initiated polling of iOS devices entirely, thereby eliminating the thundering herd problem
|
This release disables server-initiated polling of iOS devices entirely, thereby eliminating the thundering herd problem
|
||||||
|
@ -1204,6 +1178,16 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||||
|
|
||||||
## Not released yet
|
## Not released yet
|
||||||
|
|
||||||
|
### ntfy server v2.5.0 (UNRELEASED)
|
||||||
|
|
||||||
|
**Bug fixes + maintenance:**
|
||||||
|
|
||||||
|
* Removed old ntfy website from ntfy entirely (no ticket)
|
||||||
|
* Make emoji lookup for emails more efficient ([#725](https://github.com/binwiederhier/ntfy/pull/725), thanks to [@adamantike](https://github.com/adamantike))
|
||||||
|
* Fix potential subscriber ID clash ([#712](https://github.com/binwiederhier/ntfy/issues/712), thanks to [@peterbourgon](https://github.com/peterbourgon) for reporting, and [@dropdevrahul](https://github.com/dropdevrahul) for fixing)
|
||||||
|
* Support for `quoted-printable` in incoming emails ([#719](https://github.com/binwiederhier/ntfy/pull/719), thanks to [@Aerion](https://github.com/Aerion))
|
||||||
|
* Attachments with filenames that are downloaded using a browser will now download with the proper filename ([#726](https://github.com/binwiederhier/ntfy/issues/726), thanks to [un99known99](https://github.com/un99known99) for reporting, and [@wunter8](https://github.com/wunter8) for fixing)
|
||||||
|
|
||||||
### ntfy Android app v1.16.1 (UNRELEASED)
|
### ntfy Android app v1.16.1 (UNRELEASED)
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
@ -1214,23 +1198,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||||
|
|
||||||
* UnifiedPush subscriptions now include the `Rate-Topics` header to facilitate subscriber-based billing ([#652](https://github.com/binwiederhier/ntfy/issues/652), thanks to [@wunter8](https://github.com/wunter8))
|
* UnifiedPush subscriptions now include the `Rate-Topics` header to facilitate subscriber-based billing ([#652](https://github.com/binwiederhier/ntfy/issues/652), thanks to [@wunter8](https://github.com/wunter8))
|
||||||
* Subscriptions without icons no longer appear to use another subscription's icon ([#634](https://github.com/binwiederhier/ntfy/issues/634), thanks to [@topcaser](https://github.com/topcaser) for reporting and to [@wunter8](https://github.com/wunter8) for fixing)
|
* Subscriptions without icons no longer appear to use another subscription's icon ([#634](https://github.com/binwiederhier/ntfy/issues/634), thanks to [@topcaser](https://github.com/topcaser) for reporting and to [@wunter8](https://github.com/wunter8) for fixing)
|
||||||
* Bumped all dependencies to the latest versions (no ticket)
|
|
||||||
|
|
||||||
**Additional languages:**
|
**Additional languages:**
|
||||||
|
|
||||||
* Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/hellbown/))
|
* Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/hellbown/))
|
||||||
|
|
||||||
### ntfy server v2.6.0 (UNRELEASED)
|
|
||||||
|
|
||||||
**Bug fixes:**
|
|
||||||
|
|
||||||
* Support encoding any header as RFC 2047 ([#737](https://github.com/binwiederhier/ntfy/issues/737), thanks to [@cfouche3005](https://github.com/cfouche3005) for reporting)
|
|
||||||
* Do not forward poll requests for UnifiedPush messages (no ticket, thanks to NoName for reporting)
|
|
||||||
* Fix `ntfy pub %` segfaulting ([#760](https://github.com/binwiederhier/ntfy/issues/760), thanks to [@clesmian](https://github.com/clesmian) for reporting)
|
|
||||||
|
|
||||||
**Maintenance:**
|
|
||||||
|
|
||||||
* Improved GitHub Actions flow ([#745](https://github.com/binwiederhier/ntfy/pull/745), thanks to [@nimbleghost](https://github.com/nimbleghost))
|
|
||||||
* Web: Add JS formatter "prettier" ([#746](https://github.com/binwiederhier/ntfy/pull/746), thanks to [@nimbleghost](https://github.com/nimbleghost))
|
|
||||||
* Web: Add eslint with eslint-config-airbnb ([#748](https://github.com/binwiederhier/ntfy/pull/748), thanks to [@nimbleghost](https://github.com/nimbleghost))
|
|
||||||
* Web: Switch to Vite ([#749](https://github.com/binwiederhier/ntfy/pull/749), thanks to [@nimbleghost](https://github.com/nimbleghost))
|
|
||||||
|
|
BIN
docs/static/audio/ntfy-phone-call.mp3
vendored
BIN
docs/static/audio/ntfy-phone-call.mp3
vendored
Binary file not shown.
BIN
docs/static/audio/ntfy-phone-call.ogg
vendored
BIN
docs/static/audio/ntfy-phone-call.ogg
vendored
Binary file not shown.
BIN
docs/static/img/web-phone-verify.png
vendored
BIN
docs/static/img/web-phone-verify.png
vendored
Binary file not shown.
Before Width: | Height: | Size: 22 KiB |
18
go.mod
18
go.mod
|
@ -14,12 +14,12 @@ require (
|
||||||
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8
|
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8
|
||||||
github.com/stretchr/testify v1.8.1
|
github.com/stretchr/testify v1.8.1
|
||||||
github.com/urfave/cli/v2 v2.25.3
|
github.com/urfave/cli/v2 v2.25.3
|
||||||
golang.org/x/crypto v0.9.0
|
golang.org/x/crypto v0.8.0
|
||||||
golang.org/x/oauth2 v0.8.0 // indirect
|
golang.org/x/oauth2 v0.7.0 // indirect
|
||||||
golang.org/x/sync v0.2.0
|
golang.org/x/sync v0.2.0
|
||||||
golang.org/x/term v0.8.0
|
golang.org/x/term v0.8.0
|
||||||
golang.org/x/time v0.3.0
|
golang.org/x/time v0.3.0
|
||||||
google.golang.org/api v0.122.0
|
google.golang.org/api v0.121.0
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -28,15 +28,15 @@ require github.com/pkg/errors v0.9.1 // indirect
|
||||||
require (
|
require (
|
||||||
firebase.google.com/go/v4 v4.11.0
|
firebase.google.com/go/v4 v4.11.0
|
||||||
github.com/prometheus/client_golang v1.15.1
|
github.com/prometheus/client_golang v1.15.1
|
||||||
github.com/stripe/stripe-go/v74 v74.18.0
|
github.com/stripe/stripe-go/v74 v74.17.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go v0.110.2 // indirect
|
cloud.google.com/go v0.110.1 // indirect
|
||||||
cloud.google.com/go/compute v1.19.3 // indirect
|
cloud.google.com/go/compute v1.19.1 // indirect
|
||||||
cloud.google.com/go/compute/metadata v0.2.3 // indirect
|
cloud.google.com/go/compute/metadata v0.2.3 // indirect
|
||||||
cloud.google.com/go/iam v1.0.1 // indirect
|
cloud.google.com/go/iam v1.0.0 // indirect
|
||||||
cloud.google.com/go/longrunning v0.4.2 // indirect
|
cloud.google.com/go/longrunning v0.4.1 // indirect
|
||||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||||
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
@ -61,7 +61,7 @@ require (
|
||||||
github.com/stretchr/objx v0.5.0 // indirect
|
github.com/stretchr/objx v0.5.0 // indirect
|
||||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||||
go.opencensus.io v0.24.0 // indirect
|
go.opencensus.io v0.24.0 // indirect
|
||||||
golang.org/x/net v0.10.0 // indirect
|
golang.org/x/net v0.9.0 // indirect
|
||||||
golang.org/x/sys v0.8.0 // indirect
|
golang.org/x/sys v0.8.0 // indirect
|
||||||
golang.org/x/text v0.9.0 // indirect
|
golang.org/x/text v0.9.0 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
||||||
|
|
36
go.sum
36
go.sum
|
@ -1,17 +1,17 @@
|
||||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
cloud.google.com/go v0.110.2 h1:sdFPBr6xG9/wkBbfhmUz/JmZC7X6LavQgcrVINrKiVA=
|
cloud.google.com/go v0.110.1 h1:oDJ19Fu9TX9Xs06iyCw4yifSqZ7JQ8BeuVHcTmWQlOA=
|
||||||
cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw=
|
cloud.google.com/go v0.110.1/go.mod h1:uc+V/WjzxQ7vpkxfJhgW4Q4axWXyfAerpQOuSNDZyFw=
|
||||||
cloud.google.com/go/compute v1.19.3 h1:DcTwsFgGev/wV5+q8o2fzgcHOaac+DKGC91ZlvpsQds=
|
cloud.google.com/go/compute v1.19.1 h1:am86mquDUgjGNWxiGn+5PGLbmgiWXlE/yNWpIpNvuXY=
|
||||||
cloud.google.com/go/compute v1.19.3/go.mod h1:qxvISKp/gYnXkSAD1ppcSOveRAmzxicEv/JlizULFrI=
|
cloud.google.com/go/compute v1.19.1/go.mod h1:6ylj3a05WF8leseCdIf77NK0g1ey+nj5IKd5/kvShxE=
|
||||||
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
|
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
|
||||||
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
||||||
cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA=
|
cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA=
|
||||||
cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE=
|
cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE=
|
||||||
cloud.google.com/go/iam v1.0.1 h1:lyeCAU6jpnVNrE9zGQkTl3WgNgK/X+uWwaw0kynZJMU=
|
cloud.google.com/go/iam v1.0.0 h1:hlQJMovyJJwYjZcTohUH4o1L8Z8kYz+E+W/zktiLCBc=
|
||||||
cloud.google.com/go/iam v1.0.1/go.mod h1:yR3tmSL8BcZB4bxByRv2jkSIahVmCtfKZwLYGBalRE8=
|
cloud.google.com/go/iam v1.0.0/go.mod h1:ikbQ4f1r91wTmBmmOtBCOtuEOei6taatNXytzB7Cxew=
|
||||||
cloud.google.com/go/longrunning v0.4.2 h1:WDKiiNXFTaQ6qz/G8FCOkuY9kJmOJGY67wPUC1M2RbE=
|
cloud.google.com/go/longrunning v0.4.1 h1:v+yFJOfKC3yZdY6ZUI933pIYdhyhV8S3NpWrXWmg7jM=
|
||||||
cloud.google.com/go/longrunning v0.4.2/go.mod h1:OHrnaYyLUV6oqwh0xiS7e5sLQhP1m0QU9R+WhGDMgIQ=
|
cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo=
|
||||||
cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM=
|
cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM=
|
||||||
cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E=
|
cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E=
|
||||||
firebase.google.com/go/v4 v4.11.0 h1:szjBoiF33A2FavRLIDZjW1mw+OsW/XAtHoYNIqWOjRk=
|
firebase.google.com/go/v4 v4.11.0 h1:szjBoiF33A2FavRLIDZjW1mw+OsW/XAtHoYNIqWOjRk=
|
||||||
|
@ -139,8 +139,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stripe/stripe-go/v74 v74.18.0 h1:ImSIoaVkTUozHxa21AhwHYBjwc8fVSJJJB1Q7oaXzIw=
|
github.com/stripe/stripe-go/v74 v74.17.0 h1:qVWSzmADr6gudznuAcPjB9ewzgxfyIhBCkyTbkxJcCw=
|
||||||
github.com/stripe/stripe-go/v74 v74.18.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
github.com/stripe/stripe-go/v74 v74.17.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
||||||
github.com/urfave/cli/v2 v2.25.3 h1:VJkt6wvEBOoSjPFQvOkv6iWIrsJyCrKGtCtxXWwmGeY=
|
github.com/urfave/cli/v2 v2.25.3 h1:VJkt6wvEBOoSjPFQvOkv6iWIrsJyCrKGtCtxXWwmGeY=
|
||||||
github.com/urfave/cli/v2 v2.25.3/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
|
github.com/urfave/cli/v2 v2.25.3/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
|
||||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||||
|
@ -153,8 +153,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ=
|
||||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
|
@ -174,12 +174,12 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||||
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8=
|
golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g=
|
||||||
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
|
golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
@ -225,8 +225,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
|
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
|
||||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
||||||
google.golang.org/api v0.122.0 h1:zDobeejm3E7pEG1mNHvdxvjs5XJoCMzyNH+CmwL94Es=
|
google.golang.org/api v0.121.0 h1:8Oopoo8Vavxx6gt+sgs8s8/X60WBAtKQq6JqnkF+xow=
|
||||||
google.golang.org/api v0.122.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms=
|
google.golang.org/api v0.121.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||||
|
|
41
log/event.go
41
log/event.go
|
@ -41,34 +41,34 @@ func newEvent() *Event {
|
||||||
|
|
||||||
// Fatal logs the event as FATAL, and exits the program with exit code 1
|
// Fatal logs the event as FATAL, and exits the program with exit code 1
|
||||||
func (e *Event) Fatal(message string, v ...any) {
|
func (e *Event) Fatal(message string, v ...any) {
|
||||||
e.Field(fieldExitCode, 1).Log(FatalLevel, message, v...)
|
e.Field(fieldExitCode, 1).maybeLog(FatalLevel, message, v...)
|
||||||
fmt.Fprintf(os.Stderr, message+"\n", v...) // Always output error to stderr
|
fmt.Fprintf(os.Stderr, message+"\n", v...) // Always output error to stderr
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error logs the event with log level error
|
// Error logs the event with log level error
|
||||||
func (e *Event) Error(message string, v ...any) *Event {
|
func (e *Event) Error(message string, v ...any) {
|
||||||
return e.Log(ErrorLevel, message, v...)
|
e.maybeLog(ErrorLevel, message, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warn logs the event with log level warn
|
// Warn logs the event with log level warn
|
||||||
func (e *Event) Warn(message string, v ...any) *Event {
|
func (e *Event) Warn(message string, v ...any) {
|
||||||
return e.Log(WarnLevel, message, v...)
|
e.maybeLog(WarnLevel, message, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info logs the event with log level info
|
// Info logs the event with log level info
|
||||||
func (e *Event) Info(message string, v ...any) *Event {
|
func (e *Event) Info(message string, v ...any) {
|
||||||
return e.Log(InfoLevel, message, v...)
|
e.maybeLog(InfoLevel, message, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug logs the event with log level debug
|
// Debug logs the event with log level debug
|
||||||
func (e *Event) Debug(message string, v ...any) *Event {
|
func (e *Event) Debug(message string, v ...any) {
|
||||||
return e.Log(DebugLevel, message, v...)
|
e.maybeLog(DebugLevel, message, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trace logs the event with log level trace
|
// Trace logs the event with log level trace
|
||||||
func (e *Event) Trace(message string, v ...any) *Event {
|
func (e *Event) Trace(message string, v ...any) {
|
||||||
return e.Log(TraceLevel, message, v...)
|
e.maybeLog(TraceLevel, message, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tag adds a "tag" field to the log event
|
// Tag adds a "tag" field to the log event
|
||||||
|
@ -108,14 +108,6 @@ func (e *Event) Field(key string, value any) *Event {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
// FieldIf adds a custom field and value to the log event if the given level is loggable
|
|
||||||
func (e *Event) FieldIf(key string, value any, level Level) *Event {
|
|
||||||
if e.Loggable(level) {
|
|
||||||
return e.Field(key, value)
|
|
||||||
}
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fields adds a map of fields to the log event
|
// Fields adds a map of fields to the log event
|
||||||
func (e *Event) Fields(fields Context) *Event {
|
func (e *Event) Fields(fields Context) *Event {
|
||||||
if e.fields == nil {
|
if e.fields == nil {
|
||||||
|
@ -146,7 +138,7 @@ func (e *Event) With(contexters ...Contexter) *Event {
|
||||||
// to determine if they match. This is super complicated, but required for efficiency.
|
// to determine if they match. This is super complicated, but required for efficiency.
|
||||||
func (e *Event) Render(l Level, message string, v ...any) string {
|
func (e *Event) Render(l Level, message string, v ...any) string {
|
||||||
appliedContexters := e.maybeApplyContexters()
|
appliedContexters := e.maybeApplyContexters()
|
||||||
if !e.Loggable(l) {
|
if !e.shouldLog(l) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
e.Message = fmt.Sprintf(message, v...)
|
e.Message = fmt.Sprintf(message, v...)
|
||||||
|
@ -161,12 +153,11 @@ func (e *Event) Render(l Level, message string, v ...any) string {
|
||||||
return e.String()
|
return e.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log logs the event to the defined output, or does nothing if Render returns an empty string
|
// maybeLog logs the event to the defined output, or does nothing if Render returns an empty string
|
||||||
func (e *Event) Log(l Level, message string, v ...any) *Event {
|
func (e *Event) maybeLog(l Level, message string, v ...any) {
|
||||||
if m := e.Render(l, message, v...); m != "" {
|
if m := e.Render(l, message, v...); m != "" {
|
||||||
log.Println(m)
|
log.Println(m)
|
||||||
}
|
}
|
||||||
return e
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loggable returns true if the given log level is lower or equal to the current log level
|
// Loggable returns true if the given log level is lower or equal to the current log level
|
||||||
|
@ -208,6 +199,10 @@ func (e *Event) String() string {
|
||||||
return fmt.Sprintf("%s %s (%s)", e.Level.String(), e.Message, strings.Join(fields, ", "))
|
return fmt.Sprintf("%s %s (%s)", e.Level.String(), e.Message, strings.Join(fields, ", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *Event) shouldLog(l Level) bool {
|
||||||
|
return e.globalLevelWithOverride() <= l
|
||||||
|
}
|
||||||
|
|
||||||
func (e *Event) globalLevelWithOverride() Level {
|
func (e *Event) globalLevelWithOverride() Level {
|
||||||
mu.RLock()
|
mu.RLock()
|
||||||
l, ov := level, overrides
|
l, ov := level, overrides
|
||||||
|
|
|
@ -198,30 +198,6 @@ func TestLog_LevelOverride_ManyOnSameField(t *testing.T) {
|
||||||
require.Equal(t, "", File())
|
require.Equal(t, "", File())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLog_FieldIf(t *testing.T) {
|
|
||||||
t.Cleanup(resetState)
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
SetOutput(&out)
|
|
||||||
SetLevel(DebugLevel)
|
|
||||||
SetFormat(JSONFormat)
|
|
||||||
|
|
||||||
Time(time.Unix(11, 0).UTC()).
|
|
||||||
FieldIf("trace_field", "manager", TraceLevel). // This is not logged
|
|
||||||
Field("tag", "manager").
|
|
||||||
Debug("trace_field is not logged")
|
|
||||||
SetLevel(TraceLevel)
|
|
||||||
Time(time.Unix(12, 0).UTC()).
|
|
||||||
FieldIf("trace_field", "manager", TraceLevel). // Now it is logged
|
|
||||||
Field("tag", "manager").
|
|
||||||
Debug("trace_field is logged")
|
|
||||||
|
|
||||||
expected := `{"time":"1970-01-01T00:00:11Z","level":"DEBUG","message":"trace_field is not logged","tag":"manager"}
|
|
||||||
{"time":"1970-01-01T00:00:12Z","level":"DEBUG","message":"trace_field is logged","tag":"manager","trace_field":"manager"}
|
|
||||||
`
|
|
||||||
require.Equal(t, expected, out.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLog_UsingStdLogger_JSON(t *testing.T) {
|
func TestLog_UsingStdLogger_JSON(t *testing.T) {
|
||||||
t.Cleanup(resetState)
|
t.Cleanup(resetState)
|
||||||
|
|
||||||
|
|
|
@ -98,7 +98,6 @@ type Config struct {
|
||||||
FirebasePollInterval time.Duration
|
FirebasePollInterval time.Duration
|
||||||
FirebaseQuotaExceededPenaltyDuration time.Duration
|
FirebaseQuotaExceededPenaltyDuration time.Duration
|
||||||
UpstreamBaseURL string
|
UpstreamBaseURL string
|
||||||
UpstreamAccessToken string
|
|
||||||
SMTPSenderAddr string
|
SMTPSenderAddr string
|
||||||
SMTPSenderUser string
|
SMTPSenderUser string
|
||||||
SMTPSenderPass string
|
SMTPSenderPass string
|
||||||
|
@ -106,12 +105,6 @@ type Config struct {
|
||||||
SMTPServerListen string
|
SMTPServerListen string
|
||||||
SMTPServerDomain string
|
SMTPServerDomain string
|
||||||
SMTPServerAddrPrefix string
|
SMTPServerAddrPrefix string
|
||||||
TwilioAccount string
|
|
||||||
TwilioAuthToken string
|
|
||||||
TwilioPhoneNumber string
|
|
||||||
TwilioCallsBaseURL string
|
|
||||||
TwilioVerifyBaseURL string
|
|
||||||
TwilioVerifyService string
|
|
||||||
MetricsEnable bool
|
MetricsEnable bool
|
||||||
MetricsListenHTTP string
|
MetricsListenHTTP string
|
||||||
ProfileListenHTTP string
|
ProfileListenHTTP string
|
||||||
|
@ -183,7 +176,6 @@ func NewConfig() *Config {
|
||||||
FirebasePollInterval: DefaultFirebasePollInterval,
|
FirebasePollInterval: DefaultFirebasePollInterval,
|
||||||
FirebaseQuotaExceededPenaltyDuration: DefaultFirebaseQuotaExceededPenaltyDuration,
|
FirebaseQuotaExceededPenaltyDuration: DefaultFirebaseQuotaExceededPenaltyDuration,
|
||||||
UpstreamBaseURL: "",
|
UpstreamBaseURL: "",
|
||||||
UpstreamAccessToken: "",
|
|
||||||
SMTPSenderAddr: "",
|
SMTPSenderAddr: "",
|
||||||
SMTPSenderUser: "",
|
SMTPSenderUser: "",
|
||||||
SMTPSenderPass: "",
|
SMTPSenderPass: "",
|
||||||
|
@ -191,12 +183,6 @@ func NewConfig() *Config {
|
||||||
SMTPServerListen: "",
|
SMTPServerListen: "",
|
||||||
SMTPServerDomain: "",
|
SMTPServerDomain: "",
|
||||||
SMTPServerAddrPrefix: "",
|
SMTPServerAddrPrefix: "",
|
||||||
TwilioCallsBaseURL: "https://api.twilio.com", // Override for tests
|
|
||||||
TwilioAccount: "",
|
|
||||||
TwilioAuthToken: "",
|
|
||||||
TwilioPhoneNumber: "",
|
|
||||||
TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests
|
|
||||||
TwilioVerifyService: "",
|
|
||||||
MessageLimit: DefaultMessageLengthLimit,
|
MessageLimit: DefaultMessageLengthLimit,
|
||||||
MinDelay: DefaultMinDelay,
|
MinDelay: DefaultMinDelay,
|
||||||
MaxDelay: DefaultMaxDelay,
|
MaxDelay: DefaultMaxDelay,
|
||||||
|
|
|
@ -108,20 +108,12 @@ var (
|
||||||
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil}
|
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil}
|
||||||
errHTTPBadRequestTierInvalid = &errHTTP{40030, http.StatusBadRequest, "invalid request: tier does not exist", "", nil}
|
errHTTPBadRequestTierInvalid = &errHTTP{40030, http.StatusBadRequest, "invalid request: tier does not exist", "", nil}
|
||||||
errHTTPBadRequestUserNotFound = &errHTTP{40031, http.StatusBadRequest, "invalid request: user does not exist", "", nil}
|
errHTTPBadRequestUserNotFound = &errHTTP{40031, http.StatusBadRequest, "invalid request: user does not exist", "", nil}
|
||||||
errHTTPBadRequestPhoneCallsDisabled = &errHTTP{40032, http.StatusBadRequest, "invalid request: calling is disabled", "https://ntfy.sh/docs/config/#phone-calls", nil}
|
|
||||||
errHTTPBadRequestPhoneNumberInvalid = &errHTTP{40033, http.StatusBadRequest, "invalid request: phone number invalid", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
|
||||||
errHTTPBadRequestPhoneNumberNotVerified = &errHTTP{40034, http.StatusBadRequest, "invalid request: phone number not verified, or no matching verified numbers found", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
|
||||||
errHTTPBadRequestAnonymousCallsNotAllowed = &errHTTP{40035, http.StatusBadRequest, "invalid request: anonymous phone calls are not allowed", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
|
||||||
errHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, "invalid request: verification channel must be 'sms' or 'call'", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
|
||||||
errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "delayed call notifications are not supported", "", nil}
|
|
||||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
||||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
|
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||||
errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", "", nil}
|
errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", "", nil}
|
||||||
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil}
|
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil}
|
||||||
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil}
|
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil}
|
||||||
errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil}
|
|
||||||
errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil}
|
|
||||||
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||||
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
|
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
|
||||||
errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", "", nil}
|
errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", "", nil}
|
||||||
|
@ -134,7 +126,6 @@ var (
|
||||||
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil}
|
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil}
|
||||||
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||||
errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
||||||
errHTTPTooManyRequestsLimitCalls = &errHTTP{42910, http.StatusTooManyRequests, "limit reached: daily phone call quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
|
||||||
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil}
|
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil}
|
||||||
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil}
|
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil}
|
||||||
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil}
|
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil}
|
||||||
|
|
|
@ -20,7 +20,6 @@ const (
|
||||||
tagFirebase = "firebase"
|
tagFirebase = "firebase"
|
||||||
tagSMTP = "smtp" // Receive email
|
tagSMTP = "smtp" // Receive email
|
||||||
tagEmail = "email" // Send email
|
tagEmail = "email" // Send email
|
||||||
tagTwilio = "twilio"
|
|
||||||
tagFileCache = "file_cache"
|
tagFileCache = "file_cache"
|
||||||
tagMessageCache = "message_cache"
|
tagMessageCache = "message_cache"
|
||||||
tagStripe = "stripe"
|
tagStripe = "stripe"
|
||||||
|
|
|
@ -90,8 +90,6 @@ var (
|
||||||
apiAccountSettingsPath = "/v1/account/settings"
|
apiAccountSettingsPath = "/v1/account/settings"
|
||||||
apiAccountSubscriptionPath = "/v1/account/subscription"
|
apiAccountSubscriptionPath = "/v1/account/subscription"
|
||||||
apiAccountReservationPath = "/v1/account/reservation"
|
apiAccountReservationPath = "/v1/account/reservation"
|
||||||
apiAccountPhonePath = "/v1/account/phone"
|
|
||||||
apiAccountPhoneVerifyPath = "/v1/account/phone/verify"
|
|
||||||
apiAccountBillingPortalPath = "/v1/account/billing/portal"
|
apiAccountBillingPortalPath = "/v1/account/billing/portal"
|
||||||
apiAccountBillingWebhookPath = "/v1/account/billing/webhook"
|
apiAccountBillingWebhookPath = "/v1/account/billing/webhook"
|
||||||
apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription"
|
apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription"
|
||||||
|
@ -102,7 +100,6 @@ var (
|
||||||
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
||||||
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
|
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
|
||||||
urlRegex = regexp.MustCompile(`^https?://`)
|
urlRegex = regexp.MustCompile(`^https?://`)
|
||||||
phoneNumberRegex = regexp.MustCompile(`^\+\d{1,100}$`)
|
|
||||||
|
|
||||||
//go:embed site
|
//go:embed site
|
||||||
webFs embed.FS
|
webFs embed.FS
|
||||||
|
@ -464,12 +461,6 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||||
return s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingPortalSessionCreate))(w, r, v)
|
return s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingPortalSessionCreate))(w, r, v)
|
||||||
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingWebhookPath {
|
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingWebhookPath {
|
||||||
return s.ensurePaymentsEnabled(s.ensureUserManager(s.handleAccountBillingWebhook))(w, r, v) // This request comes from Stripe!
|
return s.ensurePaymentsEnabled(s.ensureUserManager(s.handleAccountBillingWebhook))(w, r, v) // This request comes from Stripe!
|
||||||
} else if r.Method == http.MethodPut && r.URL.Path == apiAccountPhoneVerifyPath {
|
|
||||||
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberVerify)))(w, r, v)
|
|
||||||
} else if r.Method == http.MethodPut && r.URL.Path == apiAccountPhonePath {
|
|
||||||
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberAdd)))(w, r, v)
|
|
||||||
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath {
|
|
||||||
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberDelete)))(w, r, v)
|
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == apiStatsPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == apiStatsPath {
|
||||||
return s.handleStats(w, r, v)
|
return s.handleStats(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath {
|
||||||
|
@ -549,8 +540,6 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
|
||||||
EnableLogin: s.config.EnableLogin,
|
EnableLogin: s.config.EnableLogin,
|
||||||
EnableSignup: s.config.EnableSignup,
|
EnableSignup: s.config.EnableSignup,
|
||||||
EnablePayments: s.config.StripeSecretKey != "",
|
EnablePayments: s.config.StripeSecretKey != "",
|
||||||
EnableCalls: s.config.TwilioAccount != "",
|
|
||||||
EnableEmails: s.config.SMTPSenderFrom != "",
|
|
||||||
EnableReservations: s.config.EnableReservations,
|
EnableReservations: s.config.EnableReservations,
|
||||||
BillingContact: s.config.BillingContact,
|
BillingContact: s.config.BillingContact,
|
||||||
DisallowedTopics: s.config.DisallowedTopics,
|
DisallowedTopics: s.config.DisallowedTopics,
|
||||||
|
@ -694,7 +683,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
m := newDefaultMessage(t.ID, "")
|
m := newDefaultMessage(t.ID, "")
|
||||||
cache, firebase, email, call, unifiedpush, e := s.parsePublishParams(r, m)
|
cache, firebase, email, unifiedpush, e := s.parsePublishParams(r, m)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return nil, e.With(t)
|
return nil, e.With(t)
|
||||||
}
|
}
|
||||||
|
@ -708,14 +697,6 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
||||||
return nil, errHTTPTooManyRequestsLimitMessages.With(t)
|
return nil, errHTTPTooManyRequestsLimitMessages.With(t)
|
||||||
} else if email != "" && !vrate.EmailAllowed() {
|
} else if email != "" && !vrate.EmailAllowed() {
|
||||||
return nil, errHTTPTooManyRequestsLimitEmails.With(t)
|
return nil, errHTTPTooManyRequestsLimitEmails.With(t)
|
||||||
} else if call != "" {
|
|
||||||
var httpErr *errHTTP
|
|
||||||
call, httpErr = s.convertPhoneNumber(v.User(), call)
|
|
||||||
if httpErr != nil {
|
|
||||||
return nil, httpErr.With(t)
|
|
||||||
} else if !vrate.CallAllowed() {
|
|
||||||
return nil, errHTTPTooManyRequestsLimitCalls.With(t)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if m.PollID != "" {
|
if m.PollID != "" {
|
||||||
m = newPollRequestMessage(t.ID, m.PollID)
|
m = newPollRequestMessage(t.ID, m.PollID)
|
||||||
|
@ -740,7 +721,6 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
||||||
"message_firebase": firebase,
|
"message_firebase": firebase,
|
||||||
"message_unifiedpush": unifiedpush,
|
"message_unifiedpush": unifiedpush,
|
||||||
"message_email": email,
|
"message_email": email,
|
||||||
"message_call": call,
|
|
||||||
})
|
})
|
||||||
if ev.IsTrace() {
|
if ev.IsTrace() {
|
||||||
ev.Field("message_body", util.MaybeMarshalJSON(m)).Trace("Received message")
|
ev.Field("message_body", util.MaybeMarshalJSON(m)).Trace("Received message")
|
||||||
|
@ -757,10 +737,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
||||||
if s.smtpSender != nil && email != "" {
|
if s.smtpSender != nil && email != "" {
|
||||||
go s.sendEmail(v, m, email)
|
go s.sendEmail(v, m, email)
|
||||||
}
|
}
|
||||||
if s.config.TwilioAccount != "" && call != "" {
|
if s.config.UpstreamBaseURL != "" {
|
||||||
go s.callPhone(v, r, m, call)
|
|
||||||
}
|
|
||||||
if s.config.UpstreamBaseURL != "" && !unifiedpush { // UP messages are not sent to upstream
|
|
||||||
go s.forwardPollRequest(v, m)
|
go s.forwardPollRequest(v, m)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -855,11 +832,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
|
||||||
logvm(v, m).Err(err).Warn("Unable to publish poll request")
|
logvm(v, m).Err(err).Warn("Unable to publish poll request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
|
|
||||||
req.Header.Set("X-Poll-ID", m.ID)
|
req.Header.Set("X-Poll-ID", m.ID)
|
||||||
if s.config.UpstreamAccessToken != "" {
|
|
||||||
req.Header.Set("Authorization", util.BearerAuth(s.config.UpstreamAccessToken))
|
|
||||||
}
|
|
||||||
var httpClient = &http.Client{
|
var httpClient = &http.Client{
|
||||||
Timeout: time.Second * 10,
|
Timeout: time.Second * 10,
|
||||||
}
|
}
|
||||||
|
@ -868,19 +841,15 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
|
||||||
logvm(v, m).Err(err).Warn("Unable to publish poll request")
|
logvm(v, m).Err(err).Warn("Unable to publish poll request")
|
||||||
return
|
return
|
||||||
} else if response.StatusCode != http.StatusOK {
|
} else if response.StatusCode != http.StatusOK {
|
||||||
if response.StatusCode == http.StatusTooManyRequests {
|
logvm(v, m).Err(err).Warn("Unable to publish poll request, unexpected HTTP status: %d", response.StatusCode)
|
||||||
logvm(v, m).Err(err).Warn("Unable to publish poll request, the upstream server %s responded with HTTP %s; you may solve this by sending fewer daily messages, or by configuring upstream-access-token (assuming you have an account with higher rate limits) ", s.config.UpstreamBaseURL, response.Status)
|
|
||||||
} else {
|
|
||||||
logvm(v, m).Err(err).Warn("Unable to publish poll request, the upstream server %s responded with HTTP %s", s.config.UpstreamBaseURL, response.Status)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, unifiedpush bool, err *errHTTP) {
|
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email string, unifiedpush bool, err *errHTTP) {
|
||||||
cache = readBoolParam(r, true, "x-cache", "cache")
|
cache = readBoolParam(r, true, "x-cache", "cache")
|
||||||
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
||||||
m.Title = readParam(r, "x-title", "title", "t")
|
m.Title = maybeDecodeHeader(readParam(r, "x-title", "title", "t"))
|
||||||
m.Click = readParam(r, "x-click", "click")
|
m.Click = readParam(r, "x-click", "click")
|
||||||
icon := readParam(r, "x-icon", "icon")
|
icon := readParam(r, "x-icon", "icon")
|
||||||
filename := readParam(r, "x-filename", "filename", "file", "f")
|
filename := readParam(r, "x-filename", "filename", "file", "f")
|
||||||
|
@ -893,7 +862,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||||
}
|
}
|
||||||
if attach != "" {
|
if attach != "" {
|
||||||
if !urlRegex.MatchString(attach) {
|
if !urlRegex.MatchString(attach) {
|
||||||
return false, false, "", "", false, errHTTPBadRequestAttachmentURLInvalid
|
return false, false, "", false, errHTTPBadRequestAttachmentURLInvalid
|
||||||
}
|
}
|
||||||
m.Attachment.URL = attach
|
m.Attachment.URL = attach
|
||||||
if m.Attachment.Name == "" {
|
if m.Attachment.Name == "" {
|
||||||
|
@ -911,48 +880,42 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||||
}
|
}
|
||||||
if icon != "" {
|
if icon != "" {
|
||||||
if !urlRegex.MatchString(icon) {
|
if !urlRegex.MatchString(icon) {
|
||||||
return false, false, "", "", false, errHTTPBadRequestIconURLInvalid
|
return false, false, "", false, errHTTPBadRequestIconURLInvalid
|
||||||
}
|
}
|
||||||
m.Icon = icon
|
m.Icon = icon
|
||||||
}
|
}
|
||||||
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
||||||
if s.smtpSender == nil && email != "" {
|
if s.smtpSender == nil && email != "" {
|
||||||
return false, false, "", "", false, errHTTPBadRequestEmailDisabled
|
return false, false, "", false, errHTTPBadRequestEmailDisabled
|
||||||
}
|
|
||||||
call = readParam(r, "x-call", "call")
|
|
||||||
if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) {
|
|
||||||
return false, false, "", "", false, errHTTPBadRequestPhoneCallsDisabled
|
|
||||||
} else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) {
|
|
||||||
return false, false, "", "", false, errHTTPBadRequestPhoneNumberInvalid
|
|
||||||
}
|
}
|
||||||
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
|
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
|
||||||
if messageStr != "" {
|
if messageStr != "" {
|
||||||
m.Message = messageStr
|
m.Message = maybeDecodeHeader(messageStr)
|
||||||
}
|
}
|
||||||
var e error
|
var e error
|
||||||
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return false, false, "", "", false, errHTTPBadRequestPriorityInvalid
|
return false, false, "", false, errHTTPBadRequestPriorityInvalid
|
||||||
}
|
}
|
||||||
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
|
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
|
||||||
|
for i, t := range m.Tags {
|
||||||
|
m.Tags[i] = maybeDecodeHeader(t)
|
||||||
|
}
|
||||||
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
||||||
if delayStr != "" {
|
if delayStr != "" {
|
||||||
if !cache {
|
if !cache {
|
||||||
return false, false, "", "", false, errHTTPBadRequestDelayNoCache
|
return false, false, "", false, errHTTPBadRequestDelayNoCache
|
||||||
}
|
}
|
||||||
if email != "" {
|
if email != "" {
|
||||||
return false, false, "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
|
return false, false, "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
|
||||||
}
|
|
||||||
if call != "" {
|
|
||||||
return false, false, "", "", false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
|
|
||||||
}
|
}
|
||||||
delay, err := util.ParseFutureTime(delayStr, time.Now())
|
delay, err := util.ParseFutureTime(delayStr, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, false, "", "", false, errHTTPBadRequestDelayCannotParse
|
return false, false, "", false, errHTTPBadRequestDelayCannotParse
|
||||||
} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
|
} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
|
||||||
return false, false, "", "", false, errHTTPBadRequestDelayTooSmall
|
return false, false, "", false, errHTTPBadRequestDelayTooSmall
|
||||||
} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
|
} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
|
||||||
return false, false, "", "", false, errHTTPBadRequestDelayTooLarge
|
return false, false, "", false, errHTTPBadRequestDelayTooLarge
|
||||||
}
|
}
|
||||||
m.Time = delay.Unix()
|
m.Time = delay.Unix()
|
||||||
}
|
}
|
||||||
|
@ -960,7 +923,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||||
if actionsStr != "" {
|
if actionsStr != "" {
|
||||||
m.Actions, e = parseActions(actionsStr)
|
m.Actions, e = parseActions(actionsStr)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return false, false, "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
|
return false, false, "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
||||||
|
@ -974,7 +937,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||||
cache = false
|
cache = false
|
||||||
email = ""
|
email = ""
|
||||||
}
|
}
|
||||||
return cache, firebase, email, call, unifiedpush, nil
|
return cache, firebase, email, unifiedpush, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
|
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
|
||||||
|
@ -1748,9 +1711,6 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
|
||||||
if m.Delay != "" {
|
if m.Delay != "" {
|
||||||
r.Header.Set("X-Delay", m.Delay)
|
r.Header.Set("X-Delay", m.Delay)
|
||||||
}
|
}
|
||||||
if m.Call != "" {
|
|
||||||
r.Header.Set("X-Call", m.Call)
|
|
||||||
}
|
|
||||||
return next(w, r, v)
|
return next(w, r, v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -144,18 +144,6 @@
|
||||||
# smtp-server-domain:
|
# smtp-server-domain:
|
||||||
# smtp-server-addr-prefix:
|
# smtp-server-addr-prefix:
|
||||||
|
|
||||||
# If enabled, ntfy can perform voice calls via Twilio via the "X-Call" header.
|
|
||||||
#
|
|
||||||
# - twilio-account is the Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586
|
|
||||||
# - twilio-auth-token is the Twilio auth token, e.g. affebeef258625862586258625862586
|
|
||||||
# - twilio-phone-number is the outgoing phone number you purchased, e.g. +18775132586
|
|
||||||
# - twilio-verify-service is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586
|
|
||||||
#
|
|
||||||
# twilio-account:
|
|
||||||
# twilio-auth-token:
|
|
||||||
# twilio-phone-number:
|
|
||||||
# twilio-verify-service:
|
|
||||||
|
|
||||||
# Interval in which keepalive messages are sent to the client. This is to prevent
|
# Interval in which keepalive messages are sent to the client. This is to prevent
|
||||||
# intermediaries closing the connection for inactivity.
|
# intermediaries closing the connection for inactivity.
|
||||||
#
|
#
|
||||||
|
@ -208,12 +196,7 @@
|
||||||
# the message ID of the original message, instructing the iOS app to poll this server for the actual message contents.
|
# the message ID of the original message, instructing the iOS app to poll this server for the actual message contents.
|
||||||
# This is to prevent the upstream server and Firebase/APNS from being able to read the message.
|
# This is to prevent the upstream server and Firebase/APNS from being able to read the message.
|
||||||
#
|
#
|
||||||
# - upstream-base-url is the base URL of the upstream server. Should be "https://ntfy.sh".
|
|
||||||
# - upstream-access-token is the token used to authenticate with the upstream server. This is only required
|
|
||||||
# if you exceed the upstream rate limits, or the uptream server requires authentication.
|
|
||||||
#
|
|
||||||
# upstream-base-url:
|
# upstream-base-url:
|
||||||
# upstream-access-token:
|
|
||||||
|
|
||||||
# Rate limiting: Total number of topics before the server rejects new topics.
|
# Rate limiting: Total number of topics before the server rejects new topics.
|
||||||
#
|
#
|
||||||
|
|
|
@ -56,7 +56,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||||
Messages: limits.MessageLimit,
|
Messages: limits.MessageLimit,
|
||||||
MessagesExpiryDuration: int64(limits.MessageExpiryDuration.Seconds()),
|
MessagesExpiryDuration: int64(limits.MessageExpiryDuration.Seconds()),
|
||||||
Emails: limits.EmailLimit,
|
Emails: limits.EmailLimit,
|
||||||
Calls: limits.CallLimit,
|
|
||||||
Reservations: limits.ReservationsLimit,
|
Reservations: limits.ReservationsLimit,
|
||||||
AttachmentTotalSize: limits.AttachmentTotalSizeLimit,
|
AttachmentTotalSize: limits.AttachmentTotalSizeLimit,
|
||||||
AttachmentFileSize: limits.AttachmentFileSizeLimit,
|
AttachmentFileSize: limits.AttachmentFileSizeLimit,
|
||||||
|
@ -68,8 +67,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||||
MessagesRemaining: stats.MessagesRemaining,
|
MessagesRemaining: stats.MessagesRemaining,
|
||||||
Emails: stats.Emails,
|
Emails: stats.Emails,
|
||||||
EmailsRemaining: stats.EmailsRemaining,
|
EmailsRemaining: stats.EmailsRemaining,
|
||||||
Calls: stats.Calls,
|
|
||||||
CallsRemaining: stats.CallsRemaining,
|
|
||||||
Reservations: stats.Reservations,
|
Reservations: stats.Reservations,
|
||||||
ReservationsRemaining: stats.ReservationsRemaining,
|
ReservationsRemaining: stats.ReservationsRemaining,
|
||||||
AttachmentTotalSize: stats.AttachmentTotalSize,
|
AttachmentTotalSize: stats.AttachmentTotalSize,
|
||||||
|
@ -108,19 +105,17 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||||
CancelAt: u.Billing.StripeSubscriptionCancelAt.Unix(),
|
CancelAt: u.Billing.StripeSubscriptionCancelAt.Unix(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s.config.EnableReservations {
|
reservations, err := s.userManager.Reservations(u.Name)
|
||||||
reservations, err := s.userManager.Reservations(u.Name)
|
if err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
}
|
if len(reservations) > 0 {
|
||||||
if len(reservations) > 0 {
|
response.Reservations = make([]*apiAccountReservation, 0)
|
||||||
response.Reservations = make([]*apiAccountReservation, 0)
|
for _, r := range reservations {
|
||||||
for _, r := range reservations {
|
response.Reservations = append(response.Reservations, &apiAccountReservation{
|
||||||
response.Reservations = append(response.Reservations, &apiAccountReservation{
|
Topic: r.Topic,
|
||||||
Topic: r.Topic,
|
Everyone: r.Everyone.String(),
|
||||||
Everyone: r.Everyone.String(),
|
})
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tokens, err := s.userManager.Tokens(u.ID)
|
tokens, err := s.userManager.Tokens(u.ID)
|
||||||
|
@ -143,15 +138,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s.config.TwilioAccount != "" {
|
|
||||||
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(phoneNumbers) > 0 {
|
|
||||||
response.PhoneNumbers = phoneNumbers
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
response.Username = user.Everyone
|
response.Username = user.Everyone
|
||||||
response.Role = string(user.RoleAnonymous)
|
response.Role = string(user.RoleAnonymous)
|
||||||
|
@ -525,72 +511,6 @@ func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *vi
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
u := v.User()
|
|
||||||
req, err := readJSONWithLimit[apiAccountPhoneNumberVerifyRequest](r.Body, jsonBodyBytesLimit, false)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
} else if !phoneNumberRegex.MatchString(req.Number) {
|
|
||||||
return errHTTPBadRequestPhoneNumberInvalid
|
|
||||||
} else if req.Channel != "sms" && req.Channel != "call" {
|
|
||||||
return errHTTPBadRequestPhoneNumberVerifyChannelInvalid
|
|
||||||
}
|
|
||||||
// Check user is allowed to add phone numbers
|
|
||||||
if u == nil || (u.IsUser() && u.Tier == nil) {
|
|
||||||
return errHTTPUnauthorized
|
|
||||||
} else if u.IsUser() && u.Tier.CallLimit == 0 {
|
|
||||||
return errHTTPUnauthorized
|
|
||||||
}
|
|
||||||
// Check if phone number exists
|
|
||||||
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
} else if util.Contains(phoneNumbers, req.Number) {
|
|
||||||
return errHTTPConflictPhoneNumberExists
|
|
||||||
}
|
|
||||||
// Actually add the unverified number, and send verification
|
|
||||||
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Sending phone number verification")
|
|
||||||
if err := s.verifyPhoneNumber(v, r, req.Number, req.Channel); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
u := v.User()
|
|
||||||
req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, jsonBodyBytesLimit, false)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !phoneNumberRegex.MatchString(req.Number) {
|
|
||||||
return errHTTPBadRequestPhoneNumberInvalid
|
|
||||||
}
|
|
||||||
if err := s.verifyPhoneNumberCheck(v, r, req.Number, req.Code); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Adding phone number as verified")
|
|
||||||
if err := s.userManager.AddPhoneNumber(u.ID, req.Number); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
u := v.User()
|
|
||||||
req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, jsonBodyBytesLimit, false)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !phoneNumberRegex.MatchString(req.Number) {
|
|
||||||
return errHTTPBadRequestPhoneNumberInvalid
|
|
||||||
}
|
|
||||||
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Deleting phone number")
|
|
||||||
if err := s.userManager.RemovePhoneNumber(u.ID, req.Number); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
|
||||||
}
|
|
||||||
|
|
||||||
// publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic
|
// publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic
|
||||||
func (s *Server) publishSyncEventAsync(v *visitor) {
|
func (s *Server) publishSyncEventAsync(v *visitor) {
|
||||||
go func() {
|
go func() {
|
||||||
|
|
|
@ -151,8 +151,6 @@ func TestAccount_Get_Anonymous(t *testing.T) {
|
||||||
require.Equal(t, int64(1004), account.Stats.MessagesRemaining)
|
require.Equal(t, int64(1004), account.Stats.MessagesRemaining)
|
||||||
require.Equal(t, int64(0), account.Stats.Emails)
|
require.Equal(t, int64(0), account.Stats.Emails)
|
||||||
require.Equal(t, int64(24), account.Stats.EmailsRemaining)
|
require.Equal(t, int64(24), account.Stats.EmailsRemaining)
|
||||||
require.Equal(t, int64(0), account.Stats.Calls)
|
|
||||||
require.Equal(t, int64(0), account.Stats.CallsRemaining)
|
|
||||||
|
|
||||||
rr = request(t, s, "POST", "/mytopic", "", nil)
|
rr = request(t, s, "POST", "/mytopic", "", nil)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
|
@ -500,8 +498,6 @@ func TestAccount_Reservation_AddAdminSuccess(t *testing.T) {
|
||||||
func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
|
func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
|
||||||
conf := newTestConfigWithAuthFile(t)
|
conf := newTestConfigWithAuthFile(t)
|
||||||
conf.EnableSignup = true
|
conf.EnableSignup = true
|
||||||
conf.EnableReservations = true
|
|
||||||
conf.TwilioAccount = "dummy"
|
|
||||||
s := newTestServer(t, conf)
|
s := newTestServer(t, conf)
|
||||||
|
|
||||||
// Create user
|
// Create user
|
||||||
|
@ -514,7 +510,6 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
|
||||||
MessageLimit: 123,
|
MessageLimit: 123,
|
||||||
MessageExpiryDuration: 86400 * time.Second,
|
MessageExpiryDuration: 86400 * time.Second,
|
||||||
EmailLimit: 32,
|
EmailLimit: 32,
|
||||||
CallLimit: 10,
|
|
||||||
ReservationLimit: 2,
|
ReservationLimit: 2,
|
||||||
AttachmentFileSizeLimit: 1231231,
|
AttachmentFileSizeLimit: 1231231,
|
||||||
AttachmentTotalSizeLimit: 123123,
|
AttachmentTotalSizeLimit: 123123,
|
||||||
|
@ -556,7 +551,6 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
|
||||||
require.Equal(t, int64(123), account.Limits.Messages)
|
require.Equal(t, int64(123), account.Limits.Messages)
|
||||||
require.Equal(t, int64(86400), account.Limits.MessagesExpiryDuration)
|
require.Equal(t, int64(86400), account.Limits.MessagesExpiryDuration)
|
||||||
require.Equal(t, int64(32), account.Limits.Emails)
|
require.Equal(t, int64(32), account.Limits.Emails)
|
||||||
require.Equal(t, int64(10), account.Limits.Calls)
|
|
||||||
require.Equal(t, int64(2), account.Limits.Reservations)
|
require.Equal(t, int64(2), account.Limits.Reservations)
|
||||||
require.Equal(t, int64(1231231), account.Limits.AttachmentFileSize)
|
require.Equal(t, int64(1231231), account.Limits.AttachmentFileSize)
|
||||||
require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize)
|
require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize)
|
||||||
|
|
|
@ -15,8 +15,6 @@ var (
|
||||||
metricEmailsPublishedFailure prometheus.Counter
|
metricEmailsPublishedFailure prometheus.Counter
|
||||||
metricEmailsReceivedSuccess prometheus.Counter
|
metricEmailsReceivedSuccess prometheus.Counter
|
||||||
metricEmailsReceivedFailure prometheus.Counter
|
metricEmailsReceivedFailure prometheus.Counter
|
||||||
metricCallsMadeSuccess prometheus.Counter
|
|
||||||
metricCallsMadeFailure prometheus.Counter
|
|
||||||
metricUnifiedPushPublishedSuccess prometheus.Counter
|
metricUnifiedPushPublishedSuccess prometheus.Counter
|
||||||
metricMatrixPublishedSuccess prometheus.Counter
|
metricMatrixPublishedSuccess prometheus.Counter
|
||||||
metricMatrixPublishedFailure prometheus.Counter
|
metricMatrixPublishedFailure prometheus.Counter
|
||||||
|
@ -59,12 +57,6 @@ func initMetrics() {
|
||||||
metricEmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
metricEmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "ntfy_emails_received_failure",
|
Name: "ntfy_emails_received_failure",
|
||||||
})
|
})
|
||||||
metricCallsMadeSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
|
||||||
Name: "ntfy_calls_made_success",
|
|
||||||
})
|
|
||||||
metricCallsMadeFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
|
||||||
Name: "ntfy_calls_made_failure",
|
|
||||||
})
|
|
||||||
metricUnifiedPushPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
metricUnifiedPushPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "ntfy_unifiedpush_published_success",
|
Name: "ntfy_unifiedpush_published_success",
|
||||||
})
|
})
|
||||||
|
@ -103,8 +95,6 @@ func initMetrics() {
|
||||||
metricEmailsPublishedFailure,
|
metricEmailsPublishedFailure,
|
||||||
metricEmailsReceivedSuccess,
|
metricEmailsReceivedSuccess,
|
||||||
metricEmailsReceivedFailure,
|
metricEmailsReceivedFailure,
|
||||||
metricCallsMadeSuccess,
|
|
||||||
metricCallsMadeFailure,
|
|
||||||
metricUnifiedPushPublishedSuccess,
|
metricUnifiedPushPublishedSuccess,
|
||||||
metricMatrixPublishedSuccess,
|
metricMatrixPublishedSuccess,
|
||||||
metricMatrixPublishedFailure,
|
metricMatrixPublishedFailure,
|
||||||
|
|
|
@ -85,15 +85,6 @@ func (s *Server) ensureAdmin(next handleFunc) handleFunc {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ensureCallsEnabled(next handleFunc) handleFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
if s.config.TwilioAccount == "" || s.userManager == nil {
|
|
||||||
return errHTTPNotFound
|
|
||||||
}
|
|
||||||
return next(w, r, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc {
|
func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
if s.config.StripeSecretKey == "" || s.stripe == nil {
|
if s.config.StripeSecretKey == "" || s.stripe == nil {
|
||||||
|
|
|
@ -68,7 +68,6 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _
|
||||||
Messages: freeTier.MessageLimit,
|
Messages: freeTier.MessageLimit,
|
||||||
MessagesExpiryDuration: int64(freeTier.MessageExpiryDuration.Seconds()),
|
MessagesExpiryDuration: int64(freeTier.MessageExpiryDuration.Seconds()),
|
||||||
Emails: freeTier.EmailLimit,
|
Emails: freeTier.EmailLimit,
|
||||||
Calls: freeTier.CallLimit,
|
|
||||||
Reservations: freeTier.ReservationsLimit,
|
Reservations: freeTier.ReservationsLimit,
|
||||||
AttachmentTotalSize: freeTier.AttachmentTotalSizeLimit,
|
AttachmentTotalSize: freeTier.AttachmentTotalSizeLimit,
|
||||||
AttachmentFileSize: freeTier.AttachmentFileSizeLimit,
|
AttachmentFileSize: freeTier.AttachmentFileSizeLimit,
|
||||||
|
@ -97,7 +96,6 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _
|
||||||
Messages: tier.MessageLimit,
|
Messages: tier.MessageLimit,
|
||||||
MessagesExpiryDuration: int64(tier.MessageExpiryDuration.Seconds()),
|
MessagesExpiryDuration: int64(tier.MessageExpiryDuration.Seconds()),
|
||||||
Emails: tier.EmailLimit,
|
Emails: tier.EmailLimit,
|
||||||
Calls: tier.CallLimit,
|
|
||||||
Reservations: tier.ReservationLimit,
|
Reservations: tier.ReservationLimit,
|
||||||
AttachmentTotalSize: tier.AttachmentTotalSizeLimit,
|
AttachmentTotalSize: tier.AttachmentTotalSizeLimit,
|
||||||
AttachmentFileSize: tier.AttachmentFileSizeLimit,
|
AttachmentFileSize: tier.AttachmentFileSizeLimit,
|
||||||
|
|
|
@ -18,7 +18,6 @@ import (
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -219,7 +218,7 @@ func TestServer_StaticSites(t *testing.T) {
|
||||||
|
|
||||||
rr = request(t, s, "GET", "/mytopic", "", nil)
|
rr = request(t, s, "GET", "/mytopic", "", nil)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
require.Contains(t, rr.Body.String(), `<meta name="robots" content="noindex, nofollow" />`)
|
require.Contains(t, rr.Body.String(), `<meta name="robots" content="noindex, nofollow"/>`)
|
||||||
|
|
||||||
rr = request(t, s, "GET", "/docs", "", nil)
|
rr = request(t, s, "GET", "/docs", "", nil)
|
||||||
require.Equal(t, 301, rr.Code)
|
require.Equal(t, 301, rr.Code)
|
||||||
|
@ -1191,20 +1190,7 @@ func TestServer_PublishDelayedEmail_Fail(t *testing.T) {
|
||||||
"E-Mail": "test@example.com",
|
"E-Mail": "test@example.com",
|
||||||
"Delay": "20 min",
|
"Delay": "20 min",
|
||||||
})
|
})
|
||||||
require.Equal(t, 40003, toHTTPError(t, response.Body.String()).Code)
|
require.Equal(t, 400, response.Code)
|
||||||
}
|
|
||||||
|
|
||||||
func TestServer_PublishDelayedCall_Fail(t *testing.T) {
|
|
||||||
c := newTestConfigWithAuthFile(t)
|
|
||||||
c.TwilioAccount = "AC1234567890"
|
|
||||||
c.TwilioAuthToken = "AAEAA1234567890"
|
|
||||||
c.TwilioPhoneNumber = "+1234567890"
|
|
||||||
s := newTestServer(t, c)
|
|
||||||
response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{
|
|
||||||
"Call": "yes",
|
|
||||||
"Delay": "20 min",
|
|
||||||
})
|
|
||||||
require.Equal(t, 40037, toHTTPError(t, response.Body.String()).Code)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishEmailNoMailer_Fail(t *testing.T) {
|
func TestServer_PublishEmailNoMailer_Fail(t *testing.T) {
|
||||||
|
@ -2478,108 +2464,18 @@ func TestServer_PublishWithUTF8MimeHeader(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
response := request(t, s, "POST", "/mytopic", "some attachment", map[string]string{
|
response := request(t, s, "POST", "/mytopic", "some attachment", map[string]string{
|
||||||
"X-Filename": "some =?UTF-8?q?=C3=A4?=ttachment.txt",
|
"X-Filename": "some attachment.txt",
|
||||||
"X-Message": "=?UTF-8?B?8J+HqfCfh6o=?=",
|
"X-Message": "=?UTF-8?B?8J+HqfCfh6o=?=",
|
||||||
"X-Title": "=?UTF-8?B?bnRmeSDlvojmo5I=?=, no really I mean it! =?UTF-8?Q?This is q=C3=BC=C3=B6ted-print=C3=A4ble.?=",
|
"X-Title": "=?UTF-8?B?bnRmeSDlvojmo5I=?=, no really I mean it! =?UTF-8?Q?This is q=C3=BC=C3=B6ted-print=C3=A4ble.?=",
|
||||||
"X-Tags": "=?UTF-8?B?8J+HqfCfh6o=?=, =?UTF-8?B?bnRmeSDlvojmo5I=?=",
|
"X-Tags": "=?UTF-8?B?8J+HqfCfh6o=?=, =?UTF-8?B?bnRmeSDlvojmo5I=?=",
|
||||||
"X-Click": "=?uTf-8?b?aHR0cHM6Ly/wn5KpLmxh?=",
|
|
||||||
"X-Actions": "http, \"=?utf-8?q?Mettre =C3=A0 jour?=\", \"https://my.tld/webhook/netbird-update\"; =?utf-8?b?aHR0cCwg6L+Z5piv5LiA5Liq5qCH562+LCBodHRwczovL/CfkqkubGE=?=",
|
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
m := toMessage(t, response.Body.String())
|
m := toMessage(t, response.Body.String())
|
||||||
require.Equal(t, "🇩🇪", m.Message)
|
require.Equal(t, "🇩🇪", m.Message)
|
||||||
require.Equal(t, "ntfy 很棒, no really I mean it! This is qüöted-printäble.", m.Title)
|
require.Equal(t, "ntfy 很棒, no really I mean it! This is qüöted-printäble.", m.Title)
|
||||||
require.Equal(t, "some ättachment.txt", m.Attachment.Name)
|
require.Equal(t, "some attachment.txt", m.Attachment.Name)
|
||||||
require.Equal(t, "🇩🇪", m.Tags[0])
|
require.Equal(t, "🇩🇪", m.Tags[0])
|
||||||
require.Equal(t, "ntfy 很棒", m.Tags[1])
|
require.Equal(t, "ntfy 很棒", m.Tags[1])
|
||||||
require.Equal(t, "https://💩.la", m.Click)
|
|
||||||
require.Equal(t, "Mettre à jour", m.Actions[0].Label)
|
|
||||||
require.Equal(t, "http", m.Actions[1].Action)
|
|
||||||
require.Equal(t, "这是一个标签", m.Actions[1].Label)
|
|
||||||
require.Equal(t, "https://💩.la", m.Actions[1].URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServer_UpstreamBaseURL_Success(t *testing.T) {
|
|
||||||
var pollID atomic.Pointer[string]
|
|
||||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
body, err := io.ReadAll(r.Body)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, "/87c9cddf7b0105f5fe849bf084c6e600be0fde99be3223335199b4965bd7b735", r.URL.Path)
|
|
||||||
require.Equal(t, "", string(body))
|
|
||||||
require.NotEmpty(t, r.Header.Get("X-Poll-ID"))
|
|
||||||
pollID.Store(util.String(r.Header.Get("X-Poll-ID")))
|
|
||||||
}))
|
|
||||||
defer upstreamServer.Close()
|
|
||||||
|
|
||||||
c := newTestConfigWithAuthFile(t)
|
|
||||||
c.BaseURL = "http://myserver.internal"
|
|
||||||
c.UpstreamBaseURL = upstreamServer.URL
|
|
||||||
s := newTestServer(t, c)
|
|
||||||
|
|
||||||
// Send message, and wait for upstream server to receive it
|
|
||||||
response := request(t, s, "PUT", "/mytopic", `hi there`, nil)
|
|
||||||
require.Equal(t, 200, response.Code)
|
|
||||||
m := toMessage(t, response.Body.String())
|
|
||||||
require.NotEmpty(t, m.ID)
|
|
||||||
require.Equal(t, "hi there", m.Message)
|
|
||||||
waitFor(t, func() bool {
|
|
||||||
pID := pollID.Load()
|
|
||||||
return pID != nil && *pID == m.ID
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServer_UpstreamBaseURL_With_Access_Token_Success(t *testing.T) {
|
|
||||||
var pollID atomic.Pointer[string]
|
|
||||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
body, err := io.ReadAll(r.Body)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, "/a1c72bcb4daf5af54d13ef86aea8f76c11e8b88320d55f1811d5d7b173bcc1df", r.URL.Path)
|
|
||||||
require.Equal(t, "Bearer tk_1234567890", r.Header.Get("Authorization"))
|
|
||||||
require.Equal(t, "", string(body))
|
|
||||||
require.NotEmpty(t, r.Header.Get("X-Poll-ID"))
|
|
||||||
pollID.Store(util.String(r.Header.Get("X-Poll-ID")))
|
|
||||||
}))
|
|
||||||
defer upstreamServer.Close()
|
|
||||||
|
|
||||||
c := newTestConfigWithAuthFile(t)
|
|
||||||
c.BaseURL = "http://myserver.internal"
|
|
||||||
c.UpstreamBaseURL = upstreamServer.URL
|
|
||||||
c.UpstreamAccessToken = "tk_1234567890"
|
|
||||||
s := newTestServer(t, c)
|
|
||||||
|
|
||||||
// Send message, and wait for upstream server to receive it
|
|
||||||
response := request(t, s, "PUT", "/mytopic1", `hi there`, nil)
|
|
||||||
require.Equal(t, 200, response.Code)
|
|
||||||
m := toMessage(t, response.Body.String())
|
|
||||||
require.NotEmpty(t, m.ID)
|
|
||||||
require.Equal(t, "hi there", m.Message)
|
|
||||||
waitFor(t, func() bool {
|
|
||||||
pID := pollID.Load()
|
|
||||||
return pID != nil && *pID == m.ID
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServer_UpstreamBaseURL_DoNotForwardUnifiedPush(t *testing.T) {
|
|
||||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
t.Fatal("UnifiedPush messages should not be forwarded")
|
|
||||||
}))
|
|
||||||
defer upstreamServer.Close()
|
|
||||||
|
|
||||||
c := newTestConfigWithAuthFile(t)
|
|
||||||
c.BaseURL = "http://myserver.internal"
|
|
||||||
c.UpstreamBaseURL = upstreamServer.URL
|
|
||||||
s := newTestServer(t, c)
|
|
||||||
|
|
||||||
// Send UP message, this should not forward to upstream server
|
|
||||||
response := request(t, s, "PUT", "/mytopic?up=1", `hi there`, nil)
|
|
||||||
require.Equal(t, 200, response.Code)
|
|
||||||
m := toMessage(t, response.Body.String())
|
|
||||||
require.NotEmpty(t, m.ID)
|
|
||||||
require.Equal(t, "hi there", m.Message)
|
|
||||||
|
|
||||||
// Forwarding is done asynchronously, so wait a bit.
|
|
||||||
// This ensures that the t.Fatal above is actually not triggered.
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestConfig(t *testing.T) *Config {
|
func newTestConfig(t *testing.T) *Config {
|
||||||
|
@ -2683,7 +2579,7 @@ func waitForWithMaxWait(t *testing.T, maxWait time.Duration, f func() bool) {
|
||||||
if f() {
|
if f() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
time.Sleep(50 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
}
|
}
|
||||||
t.Fatalf("Function f did not succeed after %v: %v", maxWait, string(debug.Stack()))
|
t.Fatalf("Function f did not succeed after %v: %v", maxWait, string(debug.Stack()))
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,176 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/xml"
|
|
||||||
"fmt"
|
|
||||||
"heckel.io/ntfy/log"
|
|
||||||
"heckel.io/ntfy/user"
|
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
twilioCallFormat = `
|
|
||||||
<Response>
|
|
||||||
<Pause length="1"/>
|
|
||||||
<Say loop="3">
|
|
||||||
You have a message from notify on topic %s. Message:
|
|
||||||
<break time="1s"/>
|
|
||||||
%s
|
|
||||||
<break time="1s"/>
|
|
||||||
End of message.
|
|
||||||
<break time="1s"/>
|
|
||||||
This message was sent by user %s. It will be repeated three times.
|
|
||||||
To unsubscribe from calls like this, remove your phone number in the notify web app.
|
|
||||||
<break time="3s"/>
|
|
||||||
</Say>
|
|
||||||
<Say>Goodbye.</Say>
|
|
||||||
</Response>`
|
|
||||||
)
|
|
||||||
|
|
||||||
// convertPhoneNumber checks if the given phone number is verified for the given user, and if so, returns the verified
|
|
||||||
// phone number. It also converts a boolean string ("yes", "1", "true") to the first verified phone number.
|
|
||||||
// If the user is anonymous, it will return an error.
|
|
||||||
func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, *errHTTP) {
|
|
||||||
if u == nil {
|
|
||||||
return "", errHTTPBadRequestAnonymousCallsNotAllowed
|
|
||||||
}
|
|
||||||
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
|
|
||||||
if err != nil {
|
|
||||||
return "", errHTTPInternalError
|
|
||||||
} else if len(phoneNumbers) == 0 {
|
|
||||||
return "", errHTTPBadRequestPhoneNumberNotVerified
|
|
||||||
}
|
|
||||||
if toBool(phoneNumber) {
|
|
||||||
return phoneNumbers[0], nil
|
|
||||||
} else if util.Contains(phoneNumbers, phoneNumber) {
|
|
||||||
return phoneNumber, nil
|
|
||||||
}
|
|
||||||
for _, p := range phoneNumbers {
|
|
||||||
if p == phoneNumber {
|
|
||||||
return phoneNumber, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", errHTTPBadRequestPhoneNumberNotVerified
|
|
||||||
}
|
|
||||||
|
|
||||||
// callPhone calls the Twilio API to make a phone call to the given phone number, using the given message.
|
|
||||||
// Failures will be logged, but not returned to the caller.
|
|
||||||
func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) {
|
|
||||||
u, sender := v.User(), m.Sender.String()
|
|
||||||
if u != nil {
|
|
||||||
sender = u.Name
|
|
||||||
}
|
|
||||||
body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(sender))
|
|
||||||
data := url.Values{}
|
|
||||||
data.Set("From", s.config.TwilioPhoneNumber)
|
|
||||||
data.Set("To", to)
|
|
||||||
data.Set("Twiml", body)
|
|
||||||
ev := logvrm(v, r, m).Tag(tagTwilio).Field("twilio_to", to).FieldIf("twilio_body", body, log.TraceLevel).Debug("Sending Twilio request")
|
|
||||||
response, err := s.callPhoneInternal(data)
|
|
||||||
if err != nil {
|
|
||||||
ev.Field("twilio_response", response).Err(err).Warn("Error sending Twilio request")
|
|
||||||
minc(metricCallsMadeFailure)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ev.FieldIf("twilio_response", response, log.TraceLevel).Debug("Received successful Twilio response")
|
|
||||||
minc(metricCallsMadeSuccess)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) callPhoneInternal(data url.Values) (string, error) {
|
|
||||||
requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/Calls.json", s.config.TwilioCallsBaseURL, s.config.TwilioAccount)
|
|
||||||
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
|
|
||||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
response, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(response), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, channel string) error {
|
|
||||||
ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Field("twilio_channel", channel).Debug("Sending phone verification")
|
|
||||||
data := url.Values{}
|
|
||||||
data.Set("To", phoneNumber)
|
|
||||||
data.Set("Channel", channel)
|
|
||||||
requestURL := fmt.Sprintf("%s/v2/Services/%s/Verifications", s.config.TwilioVerifyBaseURL, s.config.TwilioVerifyService)
|
|
||||||
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
|
|
||||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
response, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
ev.Err(err).Warn("Error sending Twilio phone verification request")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
ev.FieldIf("twilio_response", string(response), log.TraceLevel).Debug("Received Twilio phone verification response")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber, code string) error {
|
|
||||||
ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Checking phone verification")
|
|
||||||
data := url.Values{}
|
|
||||||
data.Set("To", phoneNumber)
|
|
||||||
data.Set("Code", code)
|
|
||||||
requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", s.config.TwilioVerifyBaseURL, s.config.TwilioVerifyService)
|
|
||||||
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
|
|
||||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
} else if resp.StatusCode != http.StatusOK {
|
|
||||||
if ev.IsTrace() {
|
|
||||||
response, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
ev.Field("twilio_response", string(response))
|
|
||||||
}
|
|
||||||
ev.Warn("Twilio phone verification failed with status code %d", resp.StatusCode)
|
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
|
||||||
return errHTTPGonePhoneVerificationExpired
|
|
||||||
}
|
|
||||||
return errHTTPInternalError
|
|
||||||
}
|
|
||||||
response, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if ev.IsTrace() {
|
|
||||||
ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response")
|
|
||||||
} else if ev.IsDebug() {
|
|
||||||
ev.Debug("Received successful Twilio phone verification response")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func xmlEscapeText(text string) string {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
_ = xml.EscapeText(&buf, []byte(text))
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
|
@ -1,264 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"heckel.io/ntfy/user"
|
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) {
|
|
||||||
var called, verified atomic.Bool
|
|
||||||
var code atomic.Pointer[string]
|
|
||||||
twilioVerifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
body, err := io.ReadAll(r.Body)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
|
||||||
if r.URL.Path == "/v2/Services/VA1234567890/Verifications" {
|
|
||||||
if code.Load() != nil {
|
|
||||||
t.Fatal("Should be only called once")
|
|
||||||
}
|
|
||||||
require.Equal(t, "Channel=sms&To=%2B12223334444", string(body))
|
|
||||||
code.Store(util.String("123456"))
|
|
||||||
} else if r.URL.Path == "/v2/Services/VA1234567890/VerificationCheck" {
|
|
||||||
if verified.Load() {
|
|
||||||
t.Fatal("Should be only called once")
|
|
||||||
}
|
|
||||||
require.Equal(t, "Code=123456&To=%2B12223334444", string(body))
|
|
||||||
verified.Store(true)
|
|
||||||
} else {
|
|
||||||
t.Fatal("Unexpected path:", r.URL.Path)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer twilioVerifyServer.Close()
|
|
||||||
twilioCallsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if called.Load() {
|
|
||||||
t.Fatal("Should be only called once")
|
|
||||||
}
|
|
||||||
body, err := io.ReadAll(r.Body)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
|
|
||||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
|
||||||
require.Equal(t, "From=%2B1234567890&To=%2B12223334444&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
|
|
||||||
called.Store(true)
|
|
||||||
}))
|
|
||||||
defer twilioCallsServer.Close()
|
|
||||||
|
|
||||||
c := newTestConfigWithAuthFile(t)
|
|
||||||
c.TwilioVerifyBaseURL = twilioVerifyServer.URL
|
|
||||||
c.TwilioCallsBaseURL = twilioCallsServer.URL
|
|
||||||
c.TwilioAccount = "AC1234567890"
|
|
||||||
c.TwilioAuthToken = "AAEAA1234567890"
|
|
||||||
c.TwilioPhoneNumber = "+1234567890"
|
|
||||||
c.TwilioVerifyService = "VA1234567890"
|
|
||||||
s := newTestServer(t, c)
|
|
||||||
|
|
||||||
// Add tier and user
|
|
||||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
|
||||||
Code: "pro",
|
|
||||||
MessageLimit: 10,
|
|
||||||
CallLimit: 1,
|
|
||||||
}))
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
|
||||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
|
||||||
u, err := s.userManager.User("phil")
|
|
||||||
require.Nil(t, err)
|
|
||||||
|
|
||||||
// Send verification code for phone number
|
|
||||||
response := request(t, s, "PUT", "/v1/account/phone/verify", `{"number":"+12223334444","channel":"sms"}`, map[string]string{
|
|
||||||
"authorization": util.BasicAuth("phil", "phil"),
|
|
||||||
})
|
|
||||||
require.Equal(t, 200, response.Code)
|
|
||||||
waitFor(t, func() bool {
|
|
||||||
return *code.Load() == "123456"
|
|
||||||
})
|
|
||||||
|
|
||||||
// Add phone number with code
|
|
||||||
response = request(t, s, "PUT", "/v1/account/phone", `{"number":"+12223334444","code":"123456"}`, map[string]string{
|
|
||||||
"authorization": util.BasicAuth("phil", "phil"),
|
|
||||||
})
|
|
||||||
require.Equal(t, 200, response.Code)
|
|
||||||
waitFor(t, func() bool {
|
|
||||||
return verified.Load()
|
|
||||||
})
|
|
||||||
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 1, len(phoneNumbers))
|
|
||||||
require.Equal(t, "+12223334444", phoneNumbers[0])
|
|
||||||
|
|
||||||
// Do the thing
|
|
||||||
response = request(t, s, "POST", "/mytopic", "hi there", map[string]string{
|
|
||||||
"authorization": util.BasicAuth("phil", "phil"),
|
|
||||||
"x-call": "yes",
|
|
||||||
})
|
|
||||||
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
|
|
||||||
waitFor(t, func() bool {
|
|
||||||
return called.Load()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Remove the phone number
|
|
||||||
response = request(t, s, "DELETE", "/v1/account/phone", `{"number":"+12223334444"}`, map[string]string{
|
|
||||||
"authorization": util.BasicAuth("phil", "phil"),
|
|
||||||
})
|
|
||||||
require.Equal(t, 200, response.Code)
|
|
||||||
|
|
||||||
// Verify the phone number is gone from the DB
|
|
||||||
phoneNumbers, err = s.userManager.PhoneNumbers(u.ID)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 0, len(phoneNumbers))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServer_Twilio_Call_Success(t *testing.T) {
|
|
||||||
var called atomic.Bool
|
|
||||||
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if called.Load() {
|
|
||||||
t.Fatal("Should be only called once")
|
|
||||||
}
|
|
||||||
body, err := io.ReadAll(r.Body)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
|
|
||||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
|
||||||
require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
|
|
||||||
called.Store(true)
|
|
||||||
}))
|
|
||||||
defer twilioServer.Close()
|
|
||||||
|
|
||||||
c := newTestConfigWithAuthFile(t)
|
|
||||||
c.TwilioCallsBaseURL = twilioServer.URL
|
|
||||||
c.TwilioAccount = "AC1234567890"
|
|
||||||
c.TwilioAuthToken = "AAEAA1234567890"
|
|
||||||
c.TwilioPhoneNumber = "+1234567890"
|
|
||||||
s := newTestServer(t, c)
|
|
||||||
|
|
||||||
// Add tier and user
|
|
||||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
|
||||||
Code: "pro",
|
|
||||||
MessageLimit: 10,
|
|
||||||
CallLimit: 1,
|
|
||||||
}))
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
|
||||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
|
||||||
u, err := s.userManager.User("phil")
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
|
|
||||||
|
|
||||||
// Do the thing
|
|
||||||
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
|
|
||||||
"authorization": util.BasicAuth("phil", "phil"),
|
|
||||||
"x-call": "+11122233344",
|
|
||||||
})
|
|
||||||
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
|
|
||||||
waitFor(t, func() bool {
|
|
||||||
return called.Load()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServer_Twilio_Call_Success_With_Yes(t *testing.T) {
|
|
||||||
var called atomic.Bool
|
|
||||||
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if called.Load() {
|
|
||||||
t.Fatal("Should be only called once")
|
|
||||||
}
|
|
||||||
body, err := io.ReadAll(r.Body)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
|
|
||||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
|
||||||
require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
|
|
||||||
called.Store(true)
|
|
||||||
}))
|
|
||||||
defer twilioServer.Close()
|
|
||||||
|
|
||||||
c := newTestConfigWithAuthFile(t)
|
|
||||||
c.TwilioCallsBaseURL = twilioServer.URL
|
|
||||||
c.TwilioAccount = "AC1234567890"
|
|
||||||
c.TwilioAuthToken = "AAEAA1234567890"
|
|
||||||
c.TwilioPhoneNumber = "+1234567890"
|
|
||||||
s := newTestServer(t, c)
|
|
||||||
|
|
||||||
// Add tier and user
|
|
||||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
|
||||||
Code: "pro",
|
|
||||||
MessageLimit: 10,
|
|
||||||
CallLimit: 1,
|
|
||||||
}))
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
|
||||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
|
||||||
u, err := s.userManager.User("phil")
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
|
|
||||||
|
|
||||||
// Do the thing
|
|
||||||
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
|
|
||||||
"authorization": util.BasicAuth("phil", "phil"),
|
|
||||||
"x-call": "yes", // <<<------
|
|
||||||
})
|
|
||||||
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
|
|
||||||
waitFor(t, func() bool {
|
|
||||||
return called.Load()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) {
|
|
||||||
c := newTestConfigWithAuthFile(t)
|
|
||||||
c.TwilioCallsBaseURL = "http://dummy.invalid"
|
|
||||||
c.TwilioAccount = "AC1234567890"
|
|
||||||
c.TwilioAuthToken = "AAEAA1234567890"
|
|
||||||
c.TwilioPhoneNumber = "+1234567890"
|
|
||||||
s := newTestServer(t, c)
|
|
||||||
|
|
||||||
// Add tier and user
|
|
||||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
|
||||||
Code: "pro",
|
|
||||||
MessageLimit: 10,
|
|
||||||
CallLimit: 1,
|
|
||||||
}))
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
|
||||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
|
||||||
|
|
||||||
// Do the thing
|
|
||||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
|
||||||
"authorization": util.BasicAuth("phil", "phil"),
|
|
||||||
"x-call": "+11122233344",
|
|
||||||
})
|
|
||||||
require.Equal(t, 40034, toHTTPError(t, response.Body.String()).Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServer_Twilio_Call_InvalidNumber(t *testing.T) {
|
|
||||||
c := newTestConfigWithAuthFile(t)
|
|
||||||
c.TwilioCallsBaseURL = "https://127.0.0.1"
|
|
||||||
c.TwilioAccount = "AC1234567890"
|
|
||||||
c.TwilioAuthToken = "AAEAA1234567890"
|
|
||||||
c.TwilioPhoneNumber = "+1234567890"
|
|
||||||
s := newTestServer(t, c)
|
|
||||||
|
|
||||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
|
||||||
"x-call": "+invalid",
|
|
||||||
})
|
|
||||||
require.Equal(t, 40033, toHTTPError(t, response.Body.String()).Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServer_Twilio_Call_Anonymous(t *testing.T) {
|
|
||||||
c := newTestConfigWithAuthFile(t)
|
|
||||||
c.TwilioCallsBaseURL = "https://127.0.0.1"
|
|
||||||
c.TwilioAccount = "AC1234567890"
|
|
||||||
c.TwilioAuthToken = "AAEAA1234567890"
|
|
||||||
c.TwilioPhoneNumber = "+1234567890"
|
|
||||||
s := newTestServer(t, c)
|
|
||||||
|
|
||||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
|
||||||
"x-call": "+123123",
|
|
||||||
})
|
|
||||||
require.Equal(t, 40035, toHTTPError(t, response.Body.String()).Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServer_Twilio_Call_Unconfigured(t *testing.T) {
|
|
||||||
s := newTestServer(t, newTestConfig(t))
|
|
||||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
|
||||||
"x-call": "+1234",
|
|
||||||
})
|
|
||||||
require.Equal(t, 40032, toHTTPError(t, response.Body.String()).Code)
|
|
||||||
}
|
|
|
@ -101,7 +101,6 @@ type publishMessage struct {
|
||||||
Attach string `json:"attach"`
|
Attach string `json:"attach"`
|
||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Call string `json:"call"`
|
|
||||||
Delay string `json:"delay"`
|
Delay string `json:"delay"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -312,16 +311,6 @@ type apiAccountTokenResponse struct {
|
||||||
Expires int64 `json:"expires,omitempty"` // Unix timestamp
|
Expires int64 `json:"expires,omitempty"` // Unix timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiAccountPhoneNumberVerifyRequest struct {
|
|
||||||
Number string `json:"number"`
|
|
||||||
Channel string `json:"channel"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type apiAccountPhoneNumberAddRequest struct {
|
|
||||||
Number string `json:"number"`
|
|
||||||
Code string `json:"code"` // Only set when adding a phone number
|
|
||||||
}
|
|
||||||
|
|
||||||
type apiAccountTier struct {
|
type apiAccountTier struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
@ -332,7 +321,6 @@ type apiAccountLimits struct {
|
||||||
Messages int64 `json:"messages"`
|
Messages int64 `json:"messages"`
|
||||||
MessagesExpiryDuration int64 `json:"messages_expiry_duration"`
|
MessagesExpiryDuration int64 `json:"messages_expiry_duration"`
|
||||||
Emails int64 `json:"emails"`
|
Emails int64 `json:"emails"`
|
||||||
Calls int64 `json:"calls"`
|
|
||||||
Reservations int64 `json:"reservations"`
|
Reservations int64 `json:"reservations"`
|
||||||
AttachmentTotalSize int64 `json:"attachment_total_size"`
|
AttachmentTotalSize int64 `json:"attachment_total_size"`
|
||||||
AttachmentFileSize int64 `json:"attachment_file_size"`
|
AttachmentFileSize int64 `json:"attachment_file_size"`
|
||||||
|
@ -345,8 +333,6 @@ type apiAccountStats struct {
|
||||||
MessagesRemaining int64 `json:"messages_remaining"`
|
MessagesRemaining int64 `json:"messages_remaining"`
|
||||||
Emails int64 `json:"emails"`
|
Emails int64 `json:"emails"`
|
||||||
EmailsRemaining int64 `json:"emails_remaining"`
|
EmailsRemaining int64 `json:"emails_remaining"`
|
||||||
Calls int64 `json:"calls"`
|
|
||||||
CallsRemaining int64 `json:"calls_remaining"`
|
|
||||||
Reservations int64 `json:"reservations"`
|
Reservations int64 `json:"reservations"`
|
||||||
ReservationsRemaining int64 `json:"reservations_remaining"`
|
ReservationsRemaining int64 `json:"reservations_remaining"`
|
||||||
AttachmentTotalSize int64 `json:"attachment_total_size"`
|
AttachmentTotalSize int64 `json:"attachment_total_size"`
|
||||||
|
@ -376,7 +362,6 @@ type apiAccountResponse struct {
|
||||||
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
|
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
|
||||||
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
|
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
|
||||||
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
|
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
|
||||||
PhoneNumbers []string `json:"phone_numbers,omitempty"`
|
|
||||||
Tier *apiAccountTier `json:"tier,omitempty"`
|
Tier *apiAccountTier `json:"tier,omitempty"`
|
||||||
Limits *apiAccountLimits `json:"limits,omitempty"`
|
Limits *apiAccountLimits `json:"limits,omitempty"`
|
||||||
Stats *apiAccountStats `json:"stats,omitempty"`
|
Stats *apiAccountStats `json:"stats,omitempty"`
|
||||||
|
@ -394,8 +379,6 @@ type apiConfigResponse struct {
|
||||||
EnableLogin bool `json:"enable_login"`
|
EnableLogin bool `json:"enable_login"`
|
||||||
EnableSignup bool `json:"enable_signup"`
|
EnableSignup bool `json:"enable_signup"`
|
||||||
EnablePayments bool `json:"enable_payments"`
|
EnablePayments bool `json:"enable_payments"`
|
||||||
EnableCalls bool `json:"enable_calls"`
|
|
||||||
EnableEmails bool `json:"enable_emails"`
|
|
||||||
EnableReservations bool `json:"enable_reservations"`
|
EnableReservations bool `json:"enable_reservations"`
|
||||||
BillingContact string `json:"billing_contact"`
|
BillingContact string `json:"billing_contact"`
|
||||||
DisallowedTopics []string `json:"disallowed_topics"`
|
DisallowedTopics []string `json:"disallowed_topics"`
|
||||||
|
|
|
@ -18,14 +18,6 @@ func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return defaultValue
|
return defaultValue
|
||||||
}
|
}
|
||||||
return toBool(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isBoolValue(value string) bool {
|
|
||||||
return value == "1" || value == "yes" || value == "true" || value == "0" || value == "no" || value == "false"
|
|
||||||
}
|
|
||||||
|
|
||||||
func toBool(value string) bool {
|
|
||||||
return value == "1" || value == "yes" || value == "true"
|
return value == "1" || value == "yes" || value == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -50,7 +42,7 @@ func readParam(r *http.Request, names ...string) string {
|
||||||
|
|
||||||
func readHeaderParam(r *http.Request, names ...string) string {
|
func readHeaderParam(r *http.Request, names ...string) string {
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
value := maybeDecodeHeader(r.Header.Get(name))
|
value := r.Header.Get(name)
|
||||||
if value != "" {
|
if value != "" {
|
||||||
return strings.TrimSpace(value)
|
return strings.TrimSpace(value)
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,10 +24,6 @@ const (
|
||||||
// visitorDefaultReservationsLimit is the amount of topic names a user without a tier is allowed to reserve.
|
// visitorDefaultReservationsLimit is the amount of topic names a user without a tier is allowed to reserve.
|
||||||
// This number is zero, and changing it may have unintended consequences in the web app, or otherwise
|
// This number is zero, and changing it may have unintended consequences in the web app, or otherwise
|
||||||
visitorDefaultReservationsLimit = int64(0)
|
visitorDefaultReservationsLimit = int64(0)
|
||||||
|
|
||||||
// visitorDefaultCallsLimit is the amount of calls a user without a tier is allowed to make.
|
|
||||||
// This number is zero, because phone numbers have to be verified first.
|
|
||||||
visitorDefaultCallsLimit = int64(0)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Constants used to convert a tier-user's MessageLimit (see user.Tier) into adequate request limiter
|
// Constants used to convert a tier-user's MessageLimit (see user.Tier) into adequate request limiter
|
||||||
|
@ -60,7 +56,6 @@ type visitor struct {
|
||||||
requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages)
|
requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages)
|
||||||
messagesLimiter *util.FixedLimiter // Rate limiter for messages
|
messagesLimiter *util.FixedLimiter // Rate limiter for messages
|
||||||
emailsLimiter *util.RateLimiter // Rate limiter for emails
|
emailsLimiter *util.RateLimiter // Rate limiter for emails
|
||||||
callsLimiter *util.FixedLimiter // Rate limiter for calls
|
|
||||||
subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections)
|
subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections)
|
||||||
bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads
|
bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads
|
||||||
accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil
|
accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil
|
||||||
|
@ -84,7 +79,6 @@ type visitorLimits struct {
|
||||||
EmailLimit int64
|
EmailLimit int64
|
||||||
EmailLimitBurst int
|
EmailLimitBurst int
|
||||||
EmailLimitReplenish rate.Limit
|
EmailLimitReplenish rate.Limit
|
||||||
CallLimit int64
|
|
||||||
ReservationsLimit int64
|
ReservationsLimit int64
|
||||||
AttachmentTotalSizeLimit int64
|
AttachmentTotalSizeLimit int64
|
||||||
AttachmentFileSizeLimit int64
|
AttachmentFileSizeLimit int64
|
||||||
|
@ -97,8 +91,6 @@ type visitorStats struct {
|
||||||
MessagesRemaining int64
|
MessagesRemaining int64
|
||||||
Emails int64
|
Emails int64
|
||||||
EmailsRemaining int64
|
EmailsRemaining int64
|
||||||
Calls int64
|
|
||||||
CallsRemaining int64
|
|
||||||
Reservations int64
|
Reservations int64
|
||||||
ReservationsRemaining int64
|
ReservationsRemaining int64
|
||||||
AttachmentTotalSize int64
|
AttachmentTotalSize int64
|
||||||
|
@ -115,11 +107,10 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor {
|
func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor {
|
||||||
var messages, emails, calls int64
|
var messages, emails int64
|
||||||
if user != nil {
|
if user != nil {
|
||||||
messages = user.Stats.Messages
|
messages = user.Stats.Messages
|
||||||
emails = user.Stats.Emails
|
emails = user.Stats.Emails
|
||||||
calls = user.Stats.Calls
|
|
||||||
}
|
}
|
||||||
v := &visitor{
|
v := &visitor{
|
||||||
config: conf,
|
config: conf,
|
||||||
|
@ -133,12 +124,11 @@ func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Mana
|
||||||
requestLimiter: nil, // Set in resetLimiters
|
requestLimiter: nil, // Set in resetLimiters
|
||||||
messagesLimiter: nil, // Set in resetLimiters, may be nil
|
messagesLimiter: nil, // Set in resetLimiters, may be nil
|
||||||
emailsLimiter: nil, // Set in resetLimiters
|
emailsLimiter: nil, // Set in resetLimiters
|
||||||
callsLimiter: nil, // Set in resetLimiters, may be nil
|
|
||||||
bandwidthLimiter: nil, // Set in resetLimiters
|
bandwidthLimiter: nil, // Set in resetLimiters
|
||||||
accountLimiter: nil, // Set in resetLimiters, may be nil
|
accountLimiter: nil, // Set in resetLimiters, may be nil
|
||||||
authLimiter: nil, // Set in resetLimiters, may be nil
|
authLimiter: nil, // Set in resetLimiters, may be nil
|
||||||
}
|
}
|
||||||
v.resetLimitersNoLock(messages, emails, calls, false)
|
v.resetLimitersNoLock(messages, emails, false)
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -157,19 +147,12 @@ func (v *visitor) contextNoLock() log.Context {
|
||||||
"visitor_messages": info.Stats.Messages,
|
"visitor_messages": info.Stats.Messages,
|
||||||
"visitor_messages_limit": info.Limits.MessageLimit,
|
"visitor_messages_limit": info.Limits.MessageLimit,
|
||||||
"visitor_messages_remaining": info.Stats.MessagesRemaining,
|
"visitor_messages_remaining": info.Stats.MessagesRemaining,
|
||||||
|
"visitor_emails": info.Stats.Emails,
|
||||||
|
"visitor_emails_limit": info.Limits.EmailLimit,
|
||||||
|
"visitor_emails_remaining": info.Stats.EmailsRemaining,
|
||||||
"visitor_request_limiter_limit": v.requestLimiter.Limit(),
|
"visitor_request_limiter_limit": v.requestLimiter.Limit(),
|
||||||
"visitor_request_limiter_tokens": v.requestLimiter.Tokens(),
|
"visitor_request_limiter_tokens": v.requestLimiter.Tokens(),
|
||||||
}
|
}
|
||||||
if v.config.SMTPSenderFrom != "" {
|
|
||||||
fields["visitor_emails"] = info.Stats.Emails
|
|
||||||
fields["visitor_emails_limit"] = info.Limits.EmailLimit
|
|
||||||
fields["visitor_emails_remaining"] = info.Stats.EmailsRemaining
|
|
||||||
}
|
|
||||||
if v.config.TwilioAccount != "" {
|
|
||||||
fields["visitor_calls"] = info.Stats.Calls
|
|
||||||
fields["visitor_calls_limit"] = info.Limits.CallLimit
|
|
||||||
fields["visitor_calls_remaining"] = info.Stats.CallsRemaining
|
|
||||||
}
|
|
||||||
if v.authLimiter != nil {
|
if v.authLimiter != nil {
|
||||||
fields["visitor_auth_limiter_limit"] = v.authLimiter.Limit()
|
fields["visitor_auth_limiter_limit"] = v.authLimiter.Limit()
|
||||||
fields["visitor_auth_limiter_tokens"] = v.authLimiter.Tokens()
|
fields["visitor_auth_limiter_tokens"] = v.authLimiter.Tokens()
|
||||||
|
@ -233,12 +216,6 @@ func (v *visitor) EmailAllowed() bool {
|
||||||
return v.emailsLimiter.Allow()
|
return v.emailsLimiter.Allow()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) CallAllowed() bool {
|
|
||||||
v.mu.RLock() // limiters could be replaced!
|
|
||||||
defer v.mu.RUnlock()
|
|
||||||
return v.callsLimiter.Allow()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *visitor) SubscriptionAllowed() bool {
|
func (v *visitor) SubscriptionAllowed() bool {
|
||||||
v.mu.RLock() // limiters could be replaced!
|
v.mu.RLock() // limiters could be replaced!
|
||||||
defer v.mu.RUnlock()
|
defer v.mu.RUnlock()
|
||||||
|
@ -319,7 +296,6 @@ func (v *visitor) Stats() *user.Stats {
|
||||||
return &user.Stats{
|
return &user.Stats{
|
||||||
Messages: v.messagesLimiter.Value(),
|
Messages: v.messagesLimiter.Value(),
|
||||||
Emails: v.emailsLimiter.Value(),
|
Emails: v.emailsLimiter.Value(),
|
||||||
Calls: v.callsLimiter.Value(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -328,7 +304,6 @@ func (v *visitor) ResetStats() {
|
||||||
defer v.mu.RUnlock()
|
defer v.mu.RUnlock()
|
||||||
v.emailsLimiter.Reset()
|
v.emailsLimiter.Reset()
|
||||||
v.messagesLimiter.Reset()
|
v.messagesLimiter.Reset()
|
||||||
v.callsLimiter.Reset()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// User returns the visitor user, or nil if there is none
|
// User returns the visitor user, or nil if there is none
|
||||||
|
@ -359,11 +334,11 @@ func (v *visitor) SetUser(u *user.User) {
|
||||||
shouldResetLimiters := v.user.TierID() != u.TierID() // TierID works with nil receiver
|
shouldResetLimiters := v.user.TierID() != u.TierID() // TierID works with nil receiver
|
||||||
v.user = u // u may be nil!
|
v.user = u // u may be nil!
|
||||||
if shouldResetLimiters {
|
if shouldResetLimiters {
|
||||||
var messages, emails, calls int64
|
var messages, emails int64
|
||||||
if u != nil {
|
if u != nil {
|
||||||
messages, emails, calls = u.Stats.Messages, u.Stats.Emails, u.Stats.Calls
|
messages, emails = u.Stats.Messages, u.Stats.Emails
|
||||||
}
|
}
|
||||||
v.resetLimitersNoLock(messages, emails, calls, true)
|
v.resetLimitersNoLock(messages, emails, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -378,12 +353,11 @@ func (v *visitor) MaybeUserID() string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) resetLimitersNoLock(messages, emails, calls int64, enqueueUpdate bool) {
|
func (v *visitor) resetLimitersNoLock(messages, emails int64, enqueueUpdate bool) {
|
||||||
limits := v.limitsNoLock()
|
limits := v.limitsNoLock()
|
||||||
v.requestLimiter = rate.NewLimiter(limits.RequestLimitReplenish, limits.RequestLimitBurst)
|
v.requestLimiter = rate.NewLimiter(limits.RequestLimitReplenish, limits.RequestLimitBurst)
|
||||||
v.messagesLimiter = util.NewFixedLimiterWithValue(limits.MessageLimit, messages)
|
v.messagesLimiter = util.NewFixedLimiterWithValue(limits.MessageLimit, messages)
|
||||||
v.emailsLimiter = util.NewRateLimiterWithValue(limits.EmailLimitReplenish, limits.EmailLimitBurst, emails)
|
v.emailsLimiter = util.NewRateLimiterWithValue(limits.EmailLimitReplenish, limits.EmailLimitBurst, emails)
|
||||||
v.callsLimiter = util.NewFixedLimiterWithValue(limits.CallLimit, calls)
|
|
||||||
v.bandwidthLimiter = util.NewBytesLimiter(int(limits.AttachmentBandwidthLimit), oneDay)
|
v.bandwidthLimiter = util.NewBytesLimiter(int(limits.AttachmentBandwidthLimit), oneDay)
|
||||||
if v.user == nil {
|
if v.user == nil {
|
||||||
v.accountLimiter = rate.NewLimiter(rate.Every(v.config.VisitorAccountCreationLimitReplenish), v.config.VisitorAccountCreationLimitBurst)
|
v.accountLimiter = rate.NewLimiter(rate.Every(v.config.VisitorAccountCreationLimitReplenish), v.config.VisitorAccountCreationLimitBurst)
|
||||||
|
@ -396,7 +370,6 @@ func (v *visitor) resetLimitersNoLock(messages, emails, calls int64, enqueueUpda
|
||||||
go v.userManager.EnqueueUserStats(v.user.ID, &user.Stats{
|
go v.userManager.EnqueueUserStats(v.user.ID, &user.Stats{
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
Emails: emails,
|
Emails: emails,
|
||||||
Calls: calls,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
log.Fields(v.contextNoLock()).Debug("Rate limiters reset for visitor") // Must be after function, because contextNoLock() describes rate limiters
|
log.Fields(v.contextNoLock()).Debug("Rate limiters reset for visitor") // Must be after function, because contextNoLock() describes rate limiters
|
||||||
|
@ -425,7 +398,6 @@ func tierBasedVisitorLimits(conf *Config, tier *user.Tier) *visitorLimits {
|
||||||
EmailLimit: tier.EmailLimit,
|
EmailLimit: tier.EmailLimit,
|
||||||
EmailLimitBurst: util.MinMax(int(float64(tier.EmailLimit)*visitorEmailLimitBurstRate), conf.VisitorEmailLimitBurst, visitorEmailLimitBurstMax),
|
EmailLimitBurst: util.MinMax(int(float64(tier.EmailLimit)*visitorEmailLimitBurstRate), conf.VisitorEmailLimitBurst, visitorEmailLimitBurstMax),
|
||||||
EmailLimitReplenish: dailyLimitToRate(tier.EmailLimit),
|
EmailLimitReplenish: dailyLimitToRate(tier.EmailLimit),
|
||||||
CallLimit: tier.CallLimit,
|
|
||||||
ReservationsLimit: tier.ReservationLimit,
|
ReservationsLimit: tier.ReservationLimit,
|
||||||
AttachmentTotalSizeLimit: tier.AttachmentTotalSizeLimit,
|
AttachmentTotalSizeLimit: tier.AttachmentTotalSizeLimit,
|
||||||
AttachmentFileSizeLimit: tier.AttachmentFileSizeLimit,
|
AttachmentFileSizeLimit: tier.AttachmentFileSizeLimit,
|
||||||
|
@ -448,7 +420,6 @@ func configBasedVisitorLimits(conf *Config) *visitorLimits {
|
||||||
EmailLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), // Approximation!
|
EmailLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), // Approximation!
|
||||||
EmailLimitBurst: conf.VisitorEmailLimitBurst,
|
EmailLimitBurst: conf.VisitorEmailLimitBurst,
|
||||||
EmailLimitReplenish: rate.Every(conf.VisitorEmailLimitReplenish),
|
EmailLimitReplenish: rate.Every(conf.VisitorEmailLimitReplenish),
|
||||||
CallLimit: visitorDefaultCallsLimit,
|
|
||||||
ReservationsLimit: visitorDefaultReservationsLimit,
|
ReservationsLimit: visitorDefaultReservationsLimit,
|
||||||
AttachmentTotalSizeLimit: conf.VisitorAttachmentTotalSizeLimit,
|
AttachmentTotalSizeLimit: conf.VisitorAttachmentTotalSizeLimit,
|
||||||
AttachmentFileSizeLimit: conf.AttachmentFileSizeLimit,
|
AttachmentFileSizeLimit: conf.AttachmentFileSizeLimit,
|
||||||
|
@ -494,15 +465,12 @@ func (v *visitor) Info() (*visitorInfo, error) {
|
||||||
func (v *visitor) infoLightNoLock() *visitorInfo {
|
func (v *visitor) infoLightNoLock() *visitorInfo {
|
||||||
messages := v.messagesLimiter.Value()
|
messages := v.messagesLimiter.Value()
|
||||||
emails := v.emailsLimiter.Value()
|
emails := v.emailsLimiter.Value()
|
||||||
calls := v.callsLimiter.Value()
|
|
||||||
limits := v.limitsNoLock()
|
limits := v.limitsNoLock()
|
||||||
stats := &visitorStats{
|
stats := &visitorStats{
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
MessagesRemaining: zeroIfNegative(limits.MessageLimit - messages),
|
MessagesRemaining: zeroIfNegative(limits.MessageLimit - messages),
|
||||||
Emails: emails,
|
Emails: emails,
|
||||||
EmailsRemaining: zeroIfNegative(limits.EmailLimit - emails),
|
EmailsRemaining: zeroIfNegative(limits.EmailLimit - emails),
|
||||||
Calls: calls,
|
|
||||||
CallsRemaining: zeroIfNegative(limits.CallLimit - calls),
|
|
||||||
}
|
}
|
||||||
return &visitorInfo{
|
return &visitorInfo{
|
||||||
Limits: limits,
|
Limits: limits,
|
||||||
|
|
142
user/manager.go
142
user/manager.go
|
@ -6,7 +6,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||||
"github.com/stripe/stripe-go/v74"
|
"github.com/stripe/stripe-go/v74"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"heckel.io/ntfy/log"
|
"heckel.io/ntfy/log"
|
||||||
|
@ -55,7 +55,6 @@ const (
|
||||||
messages_limit INT NOT NULL,
|
messages_limit INT NOT NULL,
|
||||||
messages_expiry_duration INT NOT NULL,
|
messages_expiry_duration INT NOT NULL,
|
||||||
emails_limit INT NOT NULL,
|
emails_limit INT NOT NULL,
|
||||||
calls_limit INT NOT NULL,
|
|
||||||
reservations_limit INT NOT NULL,
|
reservations_limit INT NOT NULL,
|
||||||
attachment_file_size_limit INT NOT NULL,
|
attachment_file_size_limit INT NOT NULL,
|
||||||
attachment_total_size_limit INT NOT NULL,
|
attachment_total_size_limit INT NOT NULL,
|
||||||
|
@ -77,7 +76,6 @@ const (
|
||||||
sync_topic TEXT NOT NULL,
|
sync_topic TEXT NOT NULL,
|
||||||
stats_messages INT NOT NULL DEFAULT (0),
|
stats_messages INT NOT NULL DEFAULT (0),
|
||||||
stats_emails INT NOT NULL DEFAULT (0),
|
stats_emails INT NOT NULL DEFAULT (0),
|
||||||
stats_calls INT NOT NULL DEFAULT (0),
|
|
||||||
stripe_customer_id TEXT,
|
stripe_customer_id TEXT,
|
||||||
stripe_subscription_id TEXT,
|
stripe_subscription_id TEXT,
|
||||||
stripe_subscription_status TEXT,
|
stripe_subscription_status TEXT,
|
||||||
|
@ -111,12 +109,6 @@ const (
|
||||||
PRIMARY KEY (user_id, token),
|
PRIMARY KEY (user_id, token),
|
||||||
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS user_phone (
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
phone_number TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (user_id, phone_number),
|
|
||||||
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||||
id INT PRIMARY KEY,
|
id INT PRIMARY KEY,
|
||||||
version INT NOT NULL
|
version INT NOT NULL
|
||||||
|
@ -131,26 +123,26 @@ const (
|
||||||
`
|
`
|
||||||
|
|
||||||
selectUserByIDQuery = `
|
selectUserByIDQuery = `
|
||||||
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
||||||
FROM user u
|
FROM user u
|
||||||
LEFT JOIN tier t on t.id = u.tier_id
|
LEFT JOIN tier t on t.id = u.tier_id
|
||||||
WHERE u.id = ?
|
WHERE u.id = ?
|
||||||
`
|
`
|
||||||
selectUserByNameQuery = `
|
selectUserByNameQuery = `
|
||||||
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
||||||
FROM user u
|
FROM user u
|
||||||
LEFT JOIN tier t on t.id = u.tier_id
|
LEFT JOIN tier t on t.id = u.tier_id
|
||||||
WHERE user = ?
|
WHERE user = ?
|
||||||
`
|
`
|
||||||
selectUserByTokenQuery = `
|
selectUserByTokenQuery = `
|
||||||
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
||||||
FROM user u
|
FROM user u
|
||||||
JOIN user_token tk on u.id = tk.user_id
|
JOIN user_token tk on u.id = tk.user_id
|
||||||
LEFT JOIN tier t on t.id = u.tier_id
|
LEFT JOIN tier t on t.id = u.tier_id
|
||||||
WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?)
|
WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?)
|
||||||
`
|
`
|
||||||
selectUserByStripeCustomerIDQuery = `
|
selectUserByStripeCustomerIDQuery = `
|
||||||
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
||||||
FROM user u
|
FROM user u
|
||||||
LEFT JOIN tier t on t.id = u.tier_id
|
LEFT JOIN tier t on t.id = u.tier_id
|
||||||
WHERE u.stripe_customer_id = ?
|
WHERE u.stripe_customer_id = ?
|
||||||
|
@ -181,8 +173,8 @@ const (
|
||||||
updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
|
updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
|
||||||
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
|
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
|
||||||
updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?`
|
updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?`
|
||||||
updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?`
|
updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ? WHERE id = ?`
|
||||||
updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0`
|
updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0`
|
||||||
updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?`
|
updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?`
|
||||||
deleteUsersMarkedQuery = `DELETE FROM user WHERE deleted < ?`
|
deleteUsersMarkedQuery = `DELETE FROM user WHERE deleted < ?`
|
||||||
deleteUserQuery = `DELETE FROM user WHERE user = ?`
|
deleteUserQuery = `DELETE FROM user WHERE user = ?`
|
||||||
|
@ -270,30 +262,26 @@ const (
|
||||||
)
|
)
|
||||||
`
|
`
|
||||||
|
|
||||||
selectPhoneNumbersQuery = `SELECT phone_number FROM user_phone WHERE user_id = ?`
|
|
||||||
insertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number) VALUES (?, ?)`
|
|
||||||
deletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?`
|
|
||||||
|
|
||||||
insertTierQuery = `
|
insertTierQuery = `
|
||||||
INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id)
|
INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
updateTierQuery = `
|
updateTierQuery = `
|
||||||
UPDATE tier
|
UPDATE tier
|
||||||
SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, calls_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_monthly_price_id = ?, stripe_yearly_price_id = ?
|
SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_monthly_price_id = ?, stripe_yearly_price_id = ?
|
||||||
WHERE code = ?
|
WHERE code = ?
|
||||||
`
|
`
|
||||||
selectTiersQuery = `
|
selectTiersQuery = `
|
||||||
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
|
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
|
||||||
FROM tier
|
FROM tier
|
||||||
`
|
`
|
||||||
selectTierByCodeQuery = `
|
selectTierByCodeQuery = `
|
||||||
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
|
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
|
||||||
FROM tier
|
FROM tier
|
||||||
WHERE code = ?
|
WHERE code = ?
|
||||||
`
|
`
|
||||||
selectTierByPriceIDQuery = `
|
selectTierByPriceIDQuery = `
|
||||||
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
|
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
|
||||||
FROM tier
|
FROM tier
|
||||||
WHERE (stripe_monthly_price_id = ? OR stripe_yearly_price_id = ?)
|
WHERE (stripe_monthly_price_id = ? OR stripe_yearly_price_id = ?)
|
||||||
`
|
`
|
||||||
|
@ -310,7 +298,7 @@ const (
|
||||||
|
|
||||||
// Schema management queries
|
// Schema management queries
|
||||||
const (
|
const (
|
||||||
currentSchemaVersion = 4
|
currentSchemaVersion = 3
|
||||||
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
|
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
|
||||||
updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1`
|
updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1`
|
||||||
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
||||||
|
@ -408,25 +396,12 @@ const (
|
||||||
CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id);
|
CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id);
|
||||||
CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id);
|
CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id);
|
||||||
`
|
`
|
||||||
|
|
||||||
// 3 -> 4
|
|
||||||
migrate3To4UpdateQueries = `
|
|
||||||
ALTER TABLE tier ADD COLUMN calls_limit INT NOT NULL DEFAULT (0);
|
|
||||||
ALTER TABLE user ADD COLUMN stats_calls INT NOT NULL DEFAULT (0);
|
|
||||||
CREATE TABLE IF NOT EXISTS user_phone (
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
phone_number TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (user_id, phone_number),
|
|
||||||
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
migrations = map[int]func(db *sql.DB) error{
|
migrations = map[int]func(db *sql.DB) error{
|
||||||
1: migrateFrom1,
|
1: migrateFrom1,
|
||||||
2: migrateFrom2,
|
2: migrateFrom2,
|
||||||
3: migrateFrom3,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -648,56 +623,6 @@ func (a *Manager) RemoveExpiredTokens() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PhoneNumbers returns all phone numbers for the user with the given user ID
|
|
||||||
func (a *Manager) PhoneNumbers(userID string) ([]string, error) {
|
|
||||||
rows, err := a.db.Query(selectPhoneNumbersQuery, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
phoneNumbers := make([]string, 0)
|
|
||||||
for {
|
|
||||||
phoneNumber, err := a.readPhoneNumber(rows)
|
|
||||||
if err == ErrPhoneNumberNotFound {
|
|
||||||
break
|
|
||||||
} else if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
phoneNumbers = append(phoneNumbers, phoneNumber)
|
|
||||||
}
|
|
||||||
return phoneNumbers, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Manager) readPhoneNumber(rows *sql.Rows) (string, error) {
|
|
||||||
var phoneNumber string
|
|
||||||
if !rows.Next() {
|
|
||||||
return "", ErrPhoneNumberNotFound
|
|
||||||
}
|
|
||||||
if err := rows.Scan(&phoneNumber); err != nil {
|
|
||||||
return "", err
|
|
||||||
} else if err := rows.Err(); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return phoneNumber, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddPhoneNumber adds a phone number to the user with the given user ID
|
|
||||||
func (a *Manager) AddPhoneNumber(userID string, phoneNumber string) error {
|
|
||||||
if _, err := a.db.Exec(insertPhoneNumberQuery, userID, phoneNumber); err != nil {
|
|
||||||
if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
|
|
||||||
return ErrPhoneNumberExists
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemovePhoneNumber deletes a phone number from the user with the given user ID
|
|
||||||
func (a *Manager) RemovePhoneNumber(userID string, phoneNumber string) error {
|
|
||||||
_, err := a.db.Exec(deletePhoneNumberQuery, userID, phoneNumber)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveDeletedUsers deletes all users that have been marked deleted for
|
// RemoveDeletedUsers deletes all users that have been marked deleted for
|
||||||
func (a *Manager) RemoveDeletedUsers() error {
|
func (a *Manager) RemoveDeletedUsers() error {
|
||||||
if _, err := a.db.Exec(deleteUsersMarkedQuery, time.Now().Unix()); err != nil {
|
if _, err := a.db.Exec(deleteUsersMarkedQuery, time.Now().Unix()); err != nil {
|
||||||
|
@ -780,10 +705,9 @@ func (a *Manager) writeUserStatsQueue() error {
|
||||||
"user_id": userID,
|
"user_id": userID,
|
||||||
"messages_count": update.Messages,
|
"messages_count": update.Messages,
|
||||||
"emails_count": update.Emails,
|
"emails_count": update.Emails,
|
||||||
"calls_count": update.Calls,
|
|
||||||
}).
|
}).
|
||||||
Trace("Updating stats for user %s", userID)
|
Trace("Updating stats for user %s", userID)
|
||||||
if _, err := tx.Exec(updateUserStatsQuery, update.Messages, update.Emails, update.Calls, userID); err != nil {
|
if _, err := tx.Exec(updateUserStatsQuery, update.Messages, update.Emails, userID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -865,9 +789,6 @@ func (a *Manager) AddUser(username, password string, role Role) error {
|
||||||
userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
|
userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
|
||||||
syncTopic, now := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength), time.Now().Unix()
|
syncTopic, now := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength), time.Now().Unix()
|
||||||
if _, err = a.db.Exec(insertUserQuery, userID, username, hash, role, syncTopic, now); err != nil {
|
if _, err = a.db.Exec(insertUserQuery, userID, username, hash, role, syncTopic, now); err != nil {
|
||||||
if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
|
|
||||||
return ErrUserExists
|
|
||||||
}
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
@ -995,12 +916,12 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var id, username, hash, role, prefs, syncTopic string
|
var id, username, hash, role, prefs, syncTopic string
|
||||||
var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString
|
var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString
|
||||||
var messages, emails, calls int64
|
var messages, emails int64
|
||||||
var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64
|
var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64
|
||||||
if !rows.Next() {
|
if !rows.Next() {
|
||||||
return nil, ErrUserNotFound
|
return nil, ErrUserNotFound
|
||||||
}
|
}
|
||||||
if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
|
if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if err := rows.Err(); err != nil {
|
} else if err := rows.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -1015,7 +936,6 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
|
||||||
Stats: &Stats{
|
Stats: &Stats{
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
Emails: emails,
|
Emails: emails,
|
||||||
Calls: calls,
|
|
||||||
},
|
},
|
||||||
Billing: &Billing{
|
Billing: &Billing{
|
||||||
StripeCustomerID: stripeCustomerID.String, // May be empty
|
StripeCustomerID: stripeCustomerID.String, // May be empty
|
||||||
|
@ -1039,7 +959,6 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
|
||||||
MessageLimit: messagesLimit.Int64,
|
MessageLimit: messagesLimit.Int64,
|
||||||
MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second,
|
MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second,
|
||||||
EmailLimit: emailsLimit.Int64,
|
EmailLimit: emailsLimit.Int64,
|
||||||
CallLimit: callsLimit.Int64,
|
|
||||||
ReservationLimit: reservationsLimit.Int64,
|
ReservationLimit: reservationsLimit.Int64,
|
||||||
AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64,
|
AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64,
|
||||||
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
|
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
|
||||||
|
@ -1372,7 +1291,7 @@ func (a *Manager) AddTier(tier *Tier) error {
|
||||||
if tier.ID == "" {
|
if tier.ID == "" {
|
||||||
tier.ID = util.RandomStringPrefix(tierIDPrefix, tierIDLength)
|
tier.ID = util.RandomStringPrefix(tierIDPrefix, tierIDLength)
|
||||||
}
|
}
|
||||||
if _, err := a.db.Exec(insertTierQuery, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil {
|
if _, err := a.db.Exec(insertTierQuery, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
@ -1380,7 +1299,7 @@ func (a *Manager) AddTier(tier *Tier) error {
|
||||||
|
|
||||||
// UpdateTier updates a tier's properties in the database
|
// UpdateTier updates a tier's properties in the database
|
||||||
func (a *Manager) UpdateTier(tier *Tier) error {
|
func (a *Manager) UpdateTier(tier *Tier) error {
|
||||||
if _, err := a.db.Exec(updateTierQuery, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil {
|
if _, err := a.db.Exec(updateTierQuery, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
@ -1449,11 +1368,11 @@ func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) {
|
||||||
func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
|
func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
|
||||||
var id, code, name string
|
var id, code, name string
|
||||||
var stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString
|
var stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString
|
||||||
var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64
|
var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64
|
||||||
if !rows.Next() {
|
if !rows.Next() {
|
||||||
return nil, ErrTierNotFound
|
return nil, ErrTierNotFound
|
||||||
}
|
}
|
||||||
if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
|
if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if err := rows.Err(); err != nil {
|
} else if err := rows.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -1466,7 +1385,6 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
|
||||||
MessageLimit: messagesLimit.Int64,
|
MessageLimit: messagesLimit.Int64,
|
||||||
MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second,
|
MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second,
|
||||||
EmailLimit: emailsLimit.Int64,
|
EmailLimit: emailsLimit.Int64,
|
||||||
CallLimit: callsLimit.Int64,
|
|
||||||
ReservationLimit: reservationsLimit.Int64,
|
ReservationLimit: reservationsLimit.Int64,
|
||||||
AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64,
|
AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64,
|
||||||
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
|
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
|
||||||
|
@ -1609,22 +1527,6 @@ func migrateFrom2(db *sql.DB) error {
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom3(db *sql.DB) error {
|
|
||||||
log.Tag(tag).Info("Migrating user database schema: from 3 to 4")
|
|
||||||
tx, err := db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
if _, err := tx.Exec(migrate3To4UpdateQueries); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := tx.Exec(updateSchemaVersion, 4); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func nullString(s string) sql.NullString {
|
func nullString(s string) sql.NullString {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return sql.NullString{}
|
return sql.NullString{}
|
||||||
|
|
|
@ -893,44 +893,6 @@ func TestManager_Tier_Change_And_Reset(t *testing.T) {
|
||||||
require.Nil(t, a.ResetTier("phil"))
|
require.Nil(t, a.ResetTier("phil"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUser_PhoneNumberAddListRemove(t *testing.T) {
|
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
|
||||||
|
|
||||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
|
|
||||||
phil, err := a.User("phil")
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Nil(t, a.AddPhoneNumber(phil.ID, "+1234567890"))
|
|
||||||
|
|
||||||
phoneNumbers, err := a.PhoneNumbers(phil.ID)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 1, len(phoneNumbers))
|
|
||||||
require.Equal(t, "+1234567890", phoneNumbers[0])
|
|
||||||
|
|
||||||
require.Nil(t, a.RemovePhoneNumber(phil.ID, "+1234567890"))
|
|
||||||
phoneNumbers, err = a.PhoneNumbers(phil.ID)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, 0, len(phoneNumbers))
|
|
||||||
|
|
||||||
// Paranoia check: We do NOT want to keep phone numbers in there
|
|
||||||
rows, err := a.db.Query(`SELECT * FROM user_phone`)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.False(t, rows.Next())
|
|
||||||
require.Nil(t, rows.Close())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUser_PhoneNumberAdd_Multiple_Users_Same_Number(t *testing.T) {
|
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
|
||||||
|
|
||||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
|
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
|
||||||
phil, err := a.User("phil")
|
|
||||||
require.Nil(t, err)
|
|
||||||
ben, err := a.User("ben")
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Nil(t, a.AddPhoneNumber(phil.ID, "+1234567890"))
|
|
||||||
require.Nil(t, a.AddPhoneNumber(ben.ID, "+1234567890"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSqliteCache_Migration_From1(t *testing.T) {
|
func TestSqliteCache_Migration_From1(t *testing.T) {
|
||||||
filename := filepath.Join(t.TempDir(), "user.db")
|
filename := filepath.Join(t.TempDir(), "user.db")
|
||||||
db, err := sql.Open("sqlite3", filename)
|
db, err := sql.Open("sqlite3", filename)
|
||||||
|
|
|
@ -86,7 +86,6 @@ type Tier struct {
|
||||||
MessageLimit int64 // Daily message limit
|
MessageLimit int64 // Daily message limit
|
||||||
MessageExpiryDuration time.Duration // Cache duration for messages
|
MessageExpiryDuration time.Duration // Cache duration for messages
|
||||||
EmailLimit int64 // Daily email limit
|
EmailLimit int64 // Daily email limit
|
||||||
CallLimit int64 // Daily phone call limit
|
|
||||||
ReservationLimit int64 // Number of topic reservations allowed by user
|
ReservationLimit int64 // Number of topic reservations allowed by user
|
||||||
AttachmentFileSizeLimit int64 // Max file size per file (bytes)
|
AttachmentFileSizeLimit int64 // Max file size per file (bytes)
|
||||||
AttachmentTotalSizeLimit int64 // Total file size for all files of this user (bytes)
|
AttachmentTotalSizeLimit int64 // Total file size for all files of this user (bytes)
|
||||||
|
@ -132,7 +131,6 @@ type NotificationPrefs struct {
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
Messages int64
|
Messages int64
|
||||||
Emails int64
|
Emails int64
|
||||||
Calls int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Billing is a struct holding a user's billing information
|
// Billing is a struct holding a user's billing information
|
||||||
|
@ -278,10 +276,7 @@ var (
|
||||||
ErrUnauthorized = errors.New("unauthorized")
|
ErrUnauthorized = errors.New("unauthorized")
|
||||||
ErrInvalidArgument = errors.New("invalid argument")
|
ErrInvalidArgument = errors.New("invalid argument")
|
||||||
ErrUserNotFound = errors.New("user not found")
|
ErrUserNotFound = errors.New("user not found")
|
||||||
ErrUserExists = errors.New("user already exists")
|
|
||||||
ErrTierNotFound = errors.New("tier not found")
|
ErrTierNotFound = errors.New("tier not found")
|
||||||
ErrTokenNotFound = errors.New("token not found")
|
ErrTokenNotFound = errors.New("token not found")
|
||||||
ErrPhoneNumberNotFound = errors.New("phone number not found")
|
|
||||||
ErrTooManyReservations = errors.New("new tier has lower reservation limit")
|
ErrTooManyReservations = errors.New("new tier has lower reservation limit")
|
||||||
ErrPhoneNumberExists = errors.New("phone number already exists")
|
|
||||||
)
|
)
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
src/app/emojis.js
|
|
|
@ -1,37 +0,0 @@
|
||||||
{
|
|
||||||
"extends": ["airbnb", "prettier"],
|
|
||||||
"env": {
|
|
||||||
"browser": true
|
|
||||||
},
|
|
||||||
"globals": {
|
|
||||||
"config": "readonly"
|
|
||||||
},
|
|
||||||
"parserOptions": {
|
|
||||||
"ecmaVersion": 2023
|
|
||||||
},
|
|
||||||
"rules": {
|
|
||||||
"no-console": "off",
|
|
||||||
"class-methods-use-this": "off",
|
|
||||||
"func-style": ["error", "expression"],
|
|
||||||
"no-restricted-syntax": ["error", "ForInStatement", "LabeledStatement", "WithStatement"],
|
|
||||||
"no-await-in-loop": "error",
|
|
||||||
"import/no-cycle": "warn",
|
|
||||||
"react/prop-types": "off",
|
|
||||||
"react/destructuring-assignment": "off",
|
|
||||||
"react/jsx-no-useless-fragment": "off",
|
|
||||||
"react/jsx-props-no-spreading": "off",
|
|
||||||
"react/jsx-no-duplicate-props": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"ignoreCase": false // For <TextField>'s [iI]nputProps
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"react/function-component-definition": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"namedComponents": "arrow-function",
|
|
||||||
"unnamedComponents": "arrow-function"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,4 +0,0 @@
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
public/static/langs/
|
|
||||||
src/app/emojis.js
|
|
|
@ -1,49 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<title>ntfy web</title>
|
|
||||||
|
|
||||||
<!-- Mobile view -->
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" />
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
|
||||||
<meta name="HandheldFriendly" content="true" />
|
|
||||||
|
|
||||||
<!-- Mobile browsers, background color -->
|
|
||||||
<meta name="theme-color" content="#317f6f" />
|
|
||||||
<meta name="msapplication-navbutton-color" content="#317f6f" />
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" />
|
|
||||||
|
|
||||||
<!-- Favicon, see favicon.io -->
|
|
||||||
<link rel="icon" type="image/png" href="/static/images/favicon.ico" />
|
|
||||||
|
|
||||||
<!-- Previews in Google, Slack, WhatsApp, etc. -->
|
|
||||||
<meta property="og:type" content="website" />
|
|
||||||
<meta property="og:locale" content="en_US" />
|
|
||||||
<meta property="og:site_name" content="ntfy web" />
|
|
||||||
<meta property="og:title" content="ntfy web" />
|
|
||||||
<meta
|
|
||||||
property="og:description"
|
|
||||||
content="ntfy lets you send push notifications via scripts from any computer or phone. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy."
|
|
||||||
/>
|
|
||||||
<meta property="og:image" content="/static/images/ntfy.png" />
|
|
||||||
<meta property="og:url" content="https://ntfy.sh" />
|
|
||||||
|
|
||||||
<!-- Never index -->
|
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
|
||||||
|
|
||||||
<!-- Style overrides & fonts -->
|
|
||||||
<link rel="stylesheet" href="/static/css/app.css" type="text/css" />
|
|
||||||
<link rel="stylesheet" href="/static/css/fonts.css" type="text/css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<noscript>
|
|
||||||
ntfy web requires JavaScript, but you can also use the
|
|
||||||
<a href="https://ntfy.sh/docs/subscribe/cli/">CLI</a> or <a href="https://ntfy.sh/docs/subscribe/phone/">Android/iOS app</a> to
|
|
||||||
subscribe.
|
|
||||||
</noscript>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script src="/config.js"></script>
|
|
||||||
<script type="module" src="/src/index.jsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
14541
web/package-lock.json
generated
14541
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -3,16 +3,14 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "NODE_OPTIONS=\"--enable-source-maps\" vite",
|
"start": "react-scripts start",
|
||||||
"build": "vite build",
|
"build": "react-scripts build",
|
||||||
"serve": "vite preview",
|
"test": "react-scripts test",
|
||||||
"format": "prettier . --write",
|
"eject": "react-scripts eject"
|
||||||
"format:check": "prettier . --check",
|
|
||||||
"lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.11.0",
|
"@emotion/react": "^11.8.2",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.8.1",
|
||||||
"@mui/icons-material": "^5.4.2",
|
"@mui/icons-material": "^5.4.2",
|
||||||
"@mui/material": "latest",
|
"@mui/material": "latest",
|
||||||
"dexie": "^3.2.1",
|
"dexie": "^3.2.1",
|
||||||
|
@ -27,21 +25,10 @@
|
||||||
"react-i18next": "^11.16.2",
|
"react-i18next": "^11.16.2",
|
||||||
"react-infinite-scroll-component": "^6.1.0",
|
"react-infinite-scroll-component": "^6.1.0",
|
||||||
"react-router-dom": "^6.2.2",
|
"react-router-dom": "^6.2.2",
|
||||||
|
"react-scripts": "^5.0.0",
|
||||||
"stacktrace-gps": "^3.0.4",
|
"stacktrace-gps": "^3.0.4",
|
||||||
"stacktrace-js": "^2.0.2"
|
"stacktrace-js": "^2.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
|
||||||
"@vitejs/plugin-react": "^4.0.0",
|
|
||||||
"eslint": "^8.41.0",
|
|
||||||
"eslint-config-airbnb": "^19.0.4",
|
|
||||||
"eslint-config-prettier": "^8.8.0",
|
|
||||||
"eslint-plugin-import": "^2.27.5",
|
|
||||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
|
||||||
"eslint-plugin-react": "^7.32.2",
|
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
|
||||||
"prettier": "^2.8.8",
|
|
||||||
"vite": "^4.3.8"
|
|
||||||
},
|
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
">0.2%",
|
">0.2%",
|
||||||
|
@ -53,8 +40,5 @@
|
||||||
"last 1 firefox version",
|
"last 1 firefox version",
|
||||||
"last 1 safari version"
|
"last 1 safari version"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"prettier": {
|
|
||||||
"printWidth": 140
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,14 +6,12 @@
|
||||||
// During web development, you may change values here for rapid testing.
|
// During web development, you may change values here for rapid testing.
|
||||||
|
|
||||||
var config = {
|
var config = {
|
||||||
base_url: window.location.origin, // Change to test against a different server
|
base_url: window.location.origin, // Change to test against a different server
|
||||||
app_root: "/app",
|
app_root: "/app",
|
||||||
enable_login: true,
|
enable_login: true,
|
||||||
enable_signup: true,
|
enable_signup: true,
|
||||||
enable_payments: false,
|
enable_payments: true,
|
||||||
enable_reservations: true,
|
enable_reservations: true,
|
||||||
enable_emails: true,
|
billing_contact: "",
|
||||||
enable_calls: true,
|
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"]
|
||||||
billing_contact: "",
|
|
||||||
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"],
|
|
||||||
};
|
};
|
||||||
|
|
44
web/public/index.html
Normal file
44
web/public/index.html
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>ntfy web</title>
|
||||||
|
|
||||||
|
<!-- Mobile view -->
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||||
|
<meta name="HandheldFriendly" content="true">
|
||||||
|
|
||||||
|
<!-- Mobile browsers, background color -->
|
||||||
|
<meta name="theme-color" content="#317f6f">
|
||||||
|
<meta name="msapplication-navbutton-color" content="#317f6f">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f">
|
||||||
|
|
||||||
|
<!-- Favicon, see favicon.io -->
|
||||||
|
<link rel="icon" type="image/png" href="%PUBLIC_URL%/static/images/favicon.ico">
|
||||||
|
|
||||||
|
<!-- Previews in Google, Slack, WhatsApp, etc. -->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:locale" content="en_US" />
|
||||||
|
<meta property="og:site_name" content="ntfy web" />
|
||||||
|
<meta property="og:title" content="ntfy web" />
|
||||||
|
<meta property="og:description" content="ntfy lets you send push notifications via scripts from any computer or phone. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy." />
|
||||||
|
<meta property="og:image" content="%PUBLIC_URL%/static/images/ntfy.png" />
|
||||||
|
<meta property="og:url" content="https://ntfy.sh" />
|
||||||
|
|
||||||
|
<!-- Never index -->
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
|
||||||
|
<!-- Style overrides & fonts -->
|
||||||
|
<link rel="stylesheet" href="%PUBLIC_URL%/static/css/app.css" type="text/css">
|
||||||
|
<link rel="stylesheet" href="%PUBLIC_URL%/static/css/fonts.css" type="text/css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
ntfy web requires JavaScript, but you can also use the <a href="https://ntfy.sh/docs/subscribe/cli/">CLI</a>
|
||||||
|
or <a href="https://ntfy.sh/docs/subscribe/phone/">Android/iOS app</a> to subscribe.
|
||||||
|
</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="%PUBLIC_URL%/config.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -1,11 +1,10 @@
|
||||||
/* web app styling overrides */
|
/* web app styling overrides */
|
||||||
|
|
||||||
a,
|
a, a:visited {
|
||||||
a:visited {
|
color: #338574;
|
||||||
color: #338574;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
a:hover {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #317f6f;
|
color: #317f6f;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,32 +2,36 @@
|
||||||
|
|
||||||
/* roboto-300 - latin */
|
/* roboto-300 - latin */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Roboto";
|
font-family: 'Roboto';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
src: local(""), url("../fonts/roboto-v29-latin-300.woff2") format("woff2");
|
src: local(''),
|
||||||
|
url('../fonts/roboto-v29-latin-300.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* roboto-regular - latin */
|
/* roboto-regular - latin */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Roboto";
|
font-family: 'Roboto';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
src: local(""), url("../fonts/roboto-v29-latin-regular.woff2") format("woff2");
|
src: local(''),
|
||||||
|
url('../fonts/roboto-v29-latin-regular.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* roboto-500 - latin */
|
/* roboto-500 - latin */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Roboto";
|
font-family: 'Roboto';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
src: local(""), url("../fonts/roboto-v29-latin-500.woff2") format("woff2");
|
src: local(''),
|
||||||
|
url('../fonts/roboto-v29-latin-500.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* roboto-700 - latin */
|
/* roboto-700 - latin */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Roboto";
|
font-family: 'Roboto';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
src: local(""), url("../fonts/roboto-v29-latin-700.woff2") format("woff2");
|
src: local(''),
|
||||||
|
url('../fonts/roboto-v29-latin-700.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
|
|
@ -152,7 +152,7 @@
|
||||||
"publish_dialog_chip_delay_label": "تأخير التسليم",
|
"publish_dialog_chip_delay_label": "تأخير التسليم",
|
||||||
"subscribe_dialog_login_description": "هذا الموضوع محمي بكلمة مرور. الرجاء إدخال اسم المستخدم وكلمة المرور للاشتراك.",
|
"subscribe_dialog_login_description": "هذا الموضوع محمي بكلمة مرور. الرجاء إدخال اسم المستخدم وكلمة المرور للاشتراك.",
|
||||||
"subscribe_dialog_subscribe_button_cancel": "إلغاء",
|
"subscribe_dialog_subscribe_button_cancel": "إلغاء",
|
||||||
"common_back": "العودة",
|
"subscribe_dialog_login_button_back": "العودة",
|
||||||
"prefs_notifications_sound_play": "تشغيل الصوت المحدد",
|
"prefs_notifications_sound_play": "تشغيل الصوت المحدد",
|
||||||
"prefs_notifications_min_priority_title": "أولوية دنيا",
|
"prefs_notifications_min_priority_title": "أولوية دنيا",
|
||||||
"prefs_notifications_min_priority_max_only": "الأولوية القصوى فقط",
|
"prefs_notifications_min_priority_max_only": "الأولوية القصوى فقط",
|
||||||
|
@ -225,7 +225,7 @@
|
||||||
"account_tokens_table_expires_header": "تنتهي مدة صلاحيته في",
|
"account_tokens_table_expires_header": "تنتهي مدة صلاحيته في",
|
||||||
"account_tokens_table_never_expires": "لا تنتهي صلاحيتها أبدا",
|
"account_tokens_table_never_expires": "لا تنتهي صلاحيتها أبدا",
|
||||||
"account_tokens_table_current_session": "جلسة المتصفح الحالية",
|
"account_tokens_table_current_session": "جلسة المتصفح الحالية",
|
||||||
"common_copy_to_clipboard": "انسخ إلى الحافظة",
|
"account_tokens_table_copy_to_clipboard": "انسخ إلى الحافظة",
|
||||||
"account_tokens_table_cannot_delete_or_edit": "لا يمكن تحرير أو حذف الرمز المميز للجلسة الحالية",
|
"account_tokens_table_cannot_delete_or_edit": "لا يمكن تحرير أو حذف الرمز المميز للجلسة الحالية",
|
||||||
"account_tokens_table_create_token_button": "إنشاء رمز مميز للوصول",
|
"account_tokens_table_create_token_button": "إنشاء رمز مميز للوصول",
|
||||||
"account_tokens_table_last_origin_tooltip": "من عنوان IP {{ip}}، انقر للبحث",
|
"account_tokens_table_last_origin_tooltip": "من عنوان IP {{ip}}، انقر للبحث",
|
||||||
|
|
|
@ -104,7 +104,7 @@
|
||||||
"subscribe_dialog_subscribe_topic_placeholder": "Име на темата, напр. phils_alerts",
|
"subscribe_dialog_subscribe_topic_placeholder": "Име на темата, напр. phils_alerts",
|
||||||
"subscribe_dialog_subscribe_use_another_label": "Използване на друг сървър",
|
"subscribe_dialog_subscribe_use_another_label": "Използване на друг сървър",
|
||||||
"subscribe_dialog_login_username_label": "Потребител, напр. phil",
|
"subscribe_dialog_login_username_label": "Потребител, напр. phil",
|
||||||
"common_back": "Назад",
|
"subscribe_dialog_login_button_back": "Назад",
|
||||||
"subscribe_dialog_subscribe_button_cancel": "Отказ",
|
"subscribe_dialog_subscribe_button_cancel": "Отказ",
|
||||||
"subscribe_dialog_login_description": "Темата е защитена. За да се абонирате въведете потребител и парола.",
|
"subscribe_dialog_login_description": "Темата е защитена. За да се абонирате въведете потребител и парола.",
|
||||||
"subscribe_dialog_subscribe_button_subscribe": "Абониране",
|
"subscribe_dialog_subscribe_button_subscribe": "Абониране",
|
||||||
|
|
|
@ -91,7 +91,7 @@
|
||||||
"subscribe_dialog_subscribe_button_subscribe": "Přihlásit odběr",
|
"subscribe_dialog_subscribe_button_subscribe": "Přihlásit odběr",
|
||||||
"subscribe_dialog_login_username_label": "Uživatelské jméno, např. phil",
|
"subscribe_dialog_login_username_label": "Uživatelské jméno, např. phil",
|
||||||
"subscribe_dialog_login_password_label": "Heslo",
|
"subscribe_dialog_login_password_label": "Heslo",
|
||||||
"common_back": "Zpět",
|
"subscribe_dialog_login_button_back": "Zpět",
|
||||||
"subscribe_dialog_login_button_login": "Přihlásit se",
|
"subscribe_dialog_login_button_login": "Přihlásit se",
|
||||||
"subscribe_dialog_error_user_not_authorized": "Uživatel {{username}} není autorizován",
|
"subscribe_dialog_error_user_not_authorized": "Uživatel {{username}} není autorizován",
|
||||||
"subscribe_dialog_error_user_anonymous": "anonymně",
|
"subscribe_dialog_error_user_anonymous": "anonymně",
|
||||||
|
@ -305,7 +305,7 @@
|
||||||
"account_tokens_table_expires_header": "Vyprší",
|
"account_tokens_table_expires_header": "Vyprší",
|
||||||
"account_tokens_table_never_expires": "Nikdy nevyprší",
|
"account_tokens_table_never_expires": "Nikdy nevyprší",
|
||||||
"account_tokens_table_current_session": "Současná relace prohlížeče",
|
"account_tokens_table_current_session": "Současná relace prohlížeče",
|
||||||
"common_copy_to_clipboard": "Kopírování do schránky",
|
"account_tokens_table_copy_to_clipboard": "Kopírování do schránky",
|
||||||
"account_tokens_table_label_header": "Popisek",
|
"account_tokens_table_label_header": "Popisek",
|
||||||
"account_tokens_table_cannot_delete_or_edit": "Nelze upravit nebo odstranit aktuální token relace",
|
"account_tokens_table_cannot_delete_or_edit": "Nelze upravit nebo odstranit aktuální token relace",
|
||||||
"account_tokens_table_create_token_button": "Vytvořit přístupový token",
|
"account_tokens_table_create_token_button": "Vytvořit přístupový token",
|
||||||
|
@ -355,15 +355,5 @@
|
||||||
"account_upgrade_dialog_billing_contact_website": "Otázky týkající se fakturace naleznete na našich <Link>webových stránkách</Link>.",
|
"account_upgrade_dialog_billing_contact_website": "Otázky týkající se fakturace naleznete na našich <Link>webových stránkách</Link>.",
|
||||||
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} rezervované téma",
|
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} rezervované téma",
|
||||||
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} denní zpráva",
|
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} denní zpráva",
|
||||||
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} denní e-mail",
|
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} denní e-mail"
|
||||||
"publish_dialog_call_label": "Telefonát",
|
|
||||||
"publish_dialog_call_reset": "Odstranit telefonát",
|
|
||||||
"publish_dialog_chip_call_label": "Telefonát",
|
|
||||||
"account_basics_phone_numbers_title": "Telefonní čísla",
|
|
||||||
"account_basics_phone_numbers_dialog_description": "Pro oznámení prostřednictvím tel. hovoru, musíte přidat a ověřit alespoň jedno telefonní číslo. Ověření lze provést pomocí SMS nebo telefonátu.",
|
|
||||||
"account_basics_phone_numbers_description": "K oznámení telefonátem",
|
|
||||||
"account_basics_phone_numbers_no_phone_numbers_yet": "Zatím žádná telefonní čísla",
|
|
||||||
"account_basics_phone_numbers_copied_to_clipboard": "Telefonní číslo zkopírováno do schránky",
|
|
||||||
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Žádná ověřená telefonní čísla",
|
|
||||||
"publish_dialog_call_item": "Vytočit číslo {{number}}"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -91,7 +91,7 @@
|
||||||
"publish_dialog_delay_label": "Forsinkelse",
|
"publish_dialog_delay_label": "Forsinkelse",
|
||||||
"publish_dialog_button_send": "Send",
|
"publish_dialog_button_send": "Send",
|
||||||
"subscribe_dialog_subscribe_button_subscribe": "Tilmeld",
|
"subscribe_dialog_subscribe_button_subscribe": "Tilmeld",
|
||||||
"common_back": "Tilbage",
|
"subscribe_dialog_login_button_back": "Tilbage",
|
||||||
"subscribe_dialog_login_username_label": "Brugernavn, f.eks. phil",
|
"subscribe_dialog_login_username_label": "Brugernavn, f.eks. phil",
|
||||||
"account_basics_title": "Konto",
|
"account_basics_title": "Konto",
|
||||||
"subscribe_dialog_error_topic_already_reserved": "Emnet er allerede reserveret",
|
"subscribe_dialog_error_topic_already_reserved": "Emnet er allerede reserveret",
|
||||||
|
@ -209,7 +209,7 @@
|
||||||
"subscribe_dialog_subscribe_use_another_label": "Brug en anden server",
|
"subscribe_dialog_subscribe_use_another_label": "Brug en anden server",
|
||||||
"account_basics_tier_upgrade_button": "Opgrader til Pro",
|
"account_basics_tier_upgrade_button": "Opgrader til Pro",
|
||||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} daglige beskeder",
|
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} daglige beskeder",
|
||||||
"common_copy_to_clipboard": "Kopier til udklipsholder",
|
"account_tokens_table_copy_to_clipboard": "Kopier til udklipsholder",
|
||||||
"prefs_reservations_edit_button": "Rediger emneadgang",
|
"prefs_reservations_edit_button": "Rediger emneadgang",
|
||||||
"account_upgrade_dialog_title": "Skift kontoniveau",
|
"account_upgrade_dialog_title": "Skift kontoniveau",
|
||||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} reserverede emner",
|
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} reserverede emner",
|
||||||
|
|
|
@ -94,7 +94,7 @@
|
||||||
"publish_dialog_delay_placeholder": "Auslieferung verzögern, z.B. {{unixTimestamp}}, {{relativeTime}}, oder \"{{naturalLanguage}}\" (nur Englisch)",
|
"publish_dialog_delay_placeholder": "Auslieferung verzögern, z.B. {{unixTimestamp}}, {{relativeTime}}, oder \"{{naturalLanguage}}\" (nur Englisch)",
|
||||||
"prefs_appearance_title": "Darstellung",
|
"prefs_appearance_title": "Darstellung",
|
||||||
"subscribe_dialog_login_password_label": "Kennwort",
|
"subscribe_dialog_login_password_label": "Kennwort",
|
||||||
"common_back": "Zurück",
|
"subscribe_dialog_login_button_back": "Zurück",
|
||||||
"publish_dialog_chip_attach_url_label": "Datei von URL anhängen",
|
"publish_dialog_chip_attach_url_label": "Datei von URL anhängen",
|
||||||
"publish_dialog_chip_delay_label": "Auslieferung verzögern",
|
"publish_dialog_chip_delay_label": "Auslieferung verzögern",
|
||||||
"publish_dialog_chip_topic_label": "Thema ändern",
|
"publish_dialog_chip_topic_label": "Thema ändern",
|
||||||
|
@ -284,7 +284,7 @@
|
||||||
"account_tokens_table_expires_header": "Verfällt",
|
"account_tokens_table_expires_header": "Verfällt",
|
||||||
"account_tokens_table_never_expires": "Verfällt nie",
|
"account_tokens_table_never_expires": "Verfällt nie",
|
||||||
"account_tokens_table_current_session": "Aktuelle Browser-Sitzung",
|
"account_tokens_table_current_session": "Aktuelle Browser-Sitzung",
|
||||||
"common_copy_to_clipboard": "In die Zwischenablage kopieren",
|
"account_tokens_table_copy_to_clipboard": "In die Zwischenablage kopieren",
|
||||||
"account_tokens_table_copied_to_clipboard": "Access-Token kopiert",
|
"account_tokens_table_copied_to_clipboard": "Access-Token kopiert",
|
||||||
"account_tokens_table_cannot_delete_or_edit": "Aktuelles Token kann nicht bearbeitet oder gelöscht werden",
|
"account_tokens_table_cannot_delete_or_edit": "Aktuelles Token kann nicht bearbeitet oder gelöscht werden",
|
||||||
"account_tokens_table_create_token_button": "Access-Token erzeugen",
|
"account_tokens_table_create_token_button": "Access-Token erzeugen",
|
||||||
|
@ -355,30 +355,5 @@
|
||||||
"account_upgrade_dialog_interval_yearly": "Jährlich",
|
"account_upgrade_dialog_interval_yearly": "Jährlich",
|
||||||
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} tägliche Nachricht",
|
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} tägliche Nachricht",
|
||||||
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} reserviertes Thema",
|
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} reserviertes Thema",
|
||||||
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} tägliche E-Mail",
|
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} tägliche E-Mail"
|
||||||
"publish_dialog_call_label": "Telefonanruf",
|
|
||||||
"publish_dialog_call_item": "Telefonnummer {{number}} anrufen",
|
|
||||||
"publish_dialog_chip_call_label": "Telefonanruf",
|
|
||||||
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Keine verifizierten Telefonnummern",
|
|
||||||
"account_basics_phone_numbers_title": "Telefonnummern",
|
|
||||||
"account_basics_phone_numbers_copied_to_clipboard": "Telefonnummer wurde in die Zwischenablage kopiert",
|
|
||||||
"account_basics_phone_numbers_dialog_title": "Telefonnummer hinzufügen",
|
|
||||||
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} Telefonanrufe pro Tag",
|
|
||||||
"account_upgrade_dialog_tier_features_no_calls": "Keine Telefonanrufe",
|
|
||||||
"publish_dialog_call_reset": "Telefonanruf entfernen",
|
|
||||||
"account_basics_phone_numbers_dialog_description": "Um die Benachrichtigung per Telefonanruf zu nutzen musst Du mindestens eine Telefonnummer hinzufügen und verifizieren. Die Verifizierung kann per SMS oder über einen Anruf erfolgen.",
|
|
||||||
"account_basics_phone_numbers_description": "Für Telefon-Benachrichtigungen",
|
|
||||||
"account_basics_phone_numbers_no_phone_numbers_yet": "Noch keine Telefonnummern",
|
|
||||||
"account_basics_phone_numbers_dialog_number_label": "Telefonnummer",
|
|
||||||
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
|
||||||
"account_basics_phone_numbers_dialog_channel_call": "Anruf",
|
|
||||||
"account_basics_phone_numbers_dialog_number_placeholder": "z.B. +49123456789",
|
|
||||||
"account_basics_phone_numbers_dialog_verify_button_call": "Ruf mich an",
|
|
||||||
"account_basics_phone_numbers_dialog_verify_button_sms": "SMS senden",
|
|
||||||
"account_basics_phone_numbers_dialog_code_label": "Verifizierungs-Code",
|
|
||||||
"account_basics_phone_numbers_dialog_code_placeholder": "z.B. 123456",
|
|
||||||
"account_basics_phone_numbers_dialog_check_verification_button": "Code bestätigen",
|
|
||||||
"account_usage_calls_title": "Getätigte Anrufe",
|
|
||||||
"account_usage_calls_none": "Noch keine Anrufe mit diesem Account getätigt",
|
|
||||||
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} Telefonanrufe pro Tag"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,8 +2,6 @@
|
||||||
"common_cancel": "Cancel",
|
"common_cancel": "Cancel",
|
||||||
"common_save": "Save",
|
"common_save": "Save",
|
||||||
"common_add": "Add",
|
"common_add": "Add",
|
||||||
"common_back": "Back",
|
|
||||||
"common_copy_to_clipboard": "Copy to clipboard",
|
|
||||||
"signup_title": "Create a ntfy account",
|
"signup_title": "Create a ntfy account",
|
||||||
"signup_form_username": "Username",
|
"signup_form_username": "Username",
|
||||||
"signup_form_password": "Password",
|
"signup_form_password": "Password",
|
||||||
|
@ -129,9 +127,6 @@
|
||||||
"publish_dialog_email_label": "Email",
|
"publish_dialog_email_label": "Email",
|
||||||
"publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com",
|
"publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com",
|
||||||
"publish_dialog_email_reset": "Remove email forward",
|
"publish_dialog_email_reset": "Remove email forward",
|
||||||
"publish_dialog_call_label": "Phone call",
|
|
||||||
"publish_dialog_call_item": "Call phone number {{number}}",
|
|
||||||
"publish_dialog_call_reset": "Remove phone call",
|
|
||||||
"publish_dialog_attach_label": "Attachment URL",
|
"publish_dialog_attach_label": "Attachment URL",
|
||||||
"publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk",
|
"publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk",
|
||||||
"publish_dialog_attach_reset": "Remove attachment URL",
|
"publish_dialog_attach_reset": "Remove attachment URL",
|
||||||
|
@ -143,8 +138,6 @@
|
||||||
"publish_dialog_other_features": "Other features:",
|
"publish_dialog_other_features": "Other features:",
|
||||||
"publish_dialog_chip_click_label": "Click URL",
|
"publish_dialog_chip_click_label": "Click URL",
|
||||||
"publish_dialog_chip_email_label": "Forward to email",
|
"publish_dialog_chip_email_label": "Forward to email",
|
||||||
"publish_dialog_chip_call_label": "Phone call",
|
|
||||||
"publish_dialog_chip_call_no_verified_numbers_tooltip": "No verified phone numbers",
|
|
||||||
"publish_dialog_chip_attach_url_label": "Attach file by URL",
|
"publish_dialog_chip_attach_url_label": "Attach file by URL",
|
||||||
"publish_dialog_chip_attach_file_label": "Attach local file",
|
"publish_dialog_chip_attach_file_label": "Attach local file",
|
||||||
"publish_dialog_chip_delay_label": "Delay delivery",
|
"publish_dialog_chip_delay_label": "Delay delivery",
|
||||||
|
@ -172,6 +165,7 @@
|
||||||
"subscribe_dialog_login_description": "This topic is password-protected. Please enter username and password to subscribe.",
|
"subscribe_dialog_login_description": "This topic is password-protected. Please enter username and password to subscribe.",
|
||||||
"subscribe_dialog_login_username_label": "Username, e.g. phil",
|
"subscribe_dialog_login_username_label": "Username, e.g. phil",
|
||||||
"subscribe_dialog_login_password_label": "Password",
|
"subscribe_dialog_login_password_label": "Password",
|
||||||
|
"subscribe_dialog_login_button_back": "Back",
|
||||||
"subscribe_dialog_login_button_login": "Login",
|
"subscribe_dialog_login_button_login": "Login",
|
||||||
"subscribe_dialog_error_user_not_authorized": "User {{username}} not authorized",
|
"subscribe_dialog_error_user_not_authorized": "User {{username}} not authorized",
|
||||||
"subscribe_dialog_error_topic_already_reserved": "Topic already reserved",
|
"subscribe_dialog_error_topic_already_reserved": "Topic already reserved",
|
||||||
|
@ -188,21 +182,6 @@
|
||||||
"account_basics_password_dialog_confirm_password_label": "Confirm password",
|
"account_basics_password_dialog_confirm_password_label": "Confirm password",
|
||||||
"account_basics_password_dialog_button_submit": "Change password",
|
"account_basics_password_dialog_button_submit": "Change password",
|
||||||
"account_basics_password_dialog_current_password_incorrect": "Password incorrect",
|
"account_basics_password_dialog_current_password_incorrect": "Password incorrect",
|
||||||
"account_basics_phone_numbers_title": "Phone numbers",
|
|
||||||
"account_basics_phone_numbers_dialog_description": "To use the call notification feature, you need to add and verify at least one phone number. Verification can be done via SMS or a phone call.",
|
|
||||||
"account_basics_phone_numbers_description": "For phone call notifications",
|
|
||||||
"account_basics_phone_numbers_no_phone_numbers_yet": "No phone numbers yet",
|
|
||||||
"account_basics_phone_numbers_copied_to_clipboard": "Phone number copied to clipboard",
|
|
||||||
"account_basics_phone_numbers_dialog_title": "Add phone number",
|
|
||||||
"account_basics_phone_numbers_dialog_number_label": "Phone number",
|
|
||||||
"account_basics_phone_numbers_dialog_number_placeholder": "e.g. +1222333444",
|
|
||||||
"account_basics_phone_numbers_dialog_verify_button_sms": "Send SMS",
|
|
||||||
"account_basics_phone_numbers_dialog_verify_button_call": "Call me",
|
|
||||||
"account_basics_phone_numbers_dialog_code_label": "Verification code",
|
|
||||||
"account_basics_phone_numbers_dialog_code_placeholder": "e.g. 123456",
|
|
||||||
"account_basics_phone_numbers_dialog_check_verification_button": "Confirm code",
|
|
||||||
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
|
||||||
"account_basics_phone_numbers_dialog_channel_call": "Call",
|
|
||||||
"account_usage_title": "Usage",
|
"account_usage_title": "Usage",
|
||||||
"account_usage_of_limit": "of {{limit}}",
|
"account_usage_of_limit": "of {{limit}}",
|
||||||
"account_usage_unlimited": "Unlimited",
|
"account_usage_unlimited": "Unlimited",
|
||||||
|
@ -224,8 +203,6 @@
|
||||||
"account_basics_tier_manage_billing_button": "Manage billing",
|
"account_basics_tier_manage_billing_button": "Manage billing",
|
||||||
"account_usage_messages_title": "Published messages",
|
"account_usage_messages_title": "Published messages",
|
||||||
"account_usage_emails_title": "Emails sent",
|
"account_usage_emails_title": "Emails sent",
|
||||||
"account_usage_calls_title": "Phone calls made",
|
|
||||||
"account_usage_calls_none": "No phone calls can be made with this account",
|
|
||||||
"account_usage_reservations_title": "Reserved topics",
|
"account_usage_reservations_title": "Reserved topics",
|
||||||
"account_usage_reservations_none": "No reserved topics for this account",
|
"account_usage_reservations_none": "No reserved topics for this account",
|
||||||
"account_usage_attachment_storage_title": "Attachment storage",
|
"account_usage_attachment_storage_title": "Attachment storage",
|
||||||
|
@ -255,9 +232,6 @@
|
||||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} daily messages",
|
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} daily messages",
|
||||||
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} daily email",
|
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} daily email",
|
||||||
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} daily emails",
|
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} daily emails",
|
||||||
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} daily phone calls",
|
|
||||||
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} daily phone calls",
|
|
||||||
"account_upgrade_dialog_tier_features_no_calls": "No phone calls",
|
|
||||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file",
|
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file",
|
||||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage",
|
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage",
|
||||||
"account_upgrade_dialog_tier_price_per_month": "month",
|
"account_upgrade_dialog_tier_price_per_month": "month",
|
||||||
|
@ -280,6 +254,7 @@
|
||||||
"account_tokens_table_expires_header": "Expires",
|
"account_tokens_table_expires_header": "Expires",
|
||||||
"account_tokens_table_never_expires": "Never expires",
|
"account_tokens_table_never_expires": "Never expires",
|
||||||
"account_tokens_table_current_session": "Current browser session",
|
"account_tokens_table_current_session": "Current browser session",
|
||||||
|
"account_tokens_table_copy_to_clipboard": "Copy to clipboard",
|
||||||
"account_tokens_table_copied_to_clipboard": "Access token copied",
|
"account_tokens_table_copied_to_clipboard": "Access token copied",
|
||||||
"account_tokens_table_cannot_delete_or_edit": "Cannot edit or delete current session token",
|
"account_tokens_table_cannot_delete_or_edit": "Cannot edit or delete current session token",
|
||||||
"account_tokens_table_create_token_button": "Create access token",
|
"account_tokens_table_create_token_button": "Create access token",
|
||||||
|
|
|
@ -81,7 +81,7 @@
|
||||||
"subscribe_dialog_login_description": "Este tópico está protegido por contraseña. Por favor, introduzca su nombre de usuario y contraseña para suscribirse.",
|
"subscribe_dialog_login_description": "Este tópico está protegido por contraseña. Por favor, introduzca su nombre de usuario y contraseña para suscribirse.",
|
||||||
"subscribe_dialog_login_username_label": "Nombre de usuario, ej. phil",
|
"subscribe_dialog_login_username_label": "Nombre de usuario, ej. phil",
|
||||||
"subscribe_dialog_login_password_label": "Contraseña",
|
"subscribe_dialog_login_password_label": "Contraseña",
|
||||||
"common_back": "Volver",
|
"subscribe_dialog_login_button_back": "Volver",
|
||||||
"subscribe_dialog_login_button_login": "Iniciar sesión",
|
"subscribe_dialog_login_button_login": "Iniciar sesión",
|
||||||
"subscribe_dialog_error_user_not_authorized": "Usuario {{username}} no autorizado",
|
"subscribe_dialog_error_user_not_authorized": "Usuario {{username}} no autorizado",
|
||||||
"subscribe_dialog_error_user_anonymous": "anónimo",
|
"subscribe_dialog_error_user_anonymous": "anónimo",
|
||||||
|
@ -257,7 +257,7 @@
|
||||||
"account_tokens_table_expires_header": "Expira",
|
"account_tokens_table_expires_header": "Expira",
|
||||||
"account_tokens_table_never_expires": "Nunca expira",
|
"account_tokens_table_never_expires": "Nunca expira",
|
||||||
"account_tokens_table_current_session": "Sesión del navegador actual",
|
"account_tokens_table_current_session": "Sesión del navegador actual",
|
||||||
"common_copy_to_clipboard": "Copiar al portapapeles",
|
"account_tokens_table_copy_to_clipboard": "Copiar al portapapeles",
|
||||||
"account_tokens_table_copied_to_clipboard": "Token de acceso copiado",
|
"account_tokens_table_copied_to_clipboard": "Token de acceso copiado",
|
||||||
"account_tokens_table_cannot_delete_or_edit": "No se puede editar ni eliminar el token de sesión actual",
|
"account_tokens_table_cannot_delete_or_edit": "No se puede editar ni eliminar el token de sesión actual",
|
||||||
"account_tokens_table_create_token_button": "Crear token de acceso",
|
"account_tokens_table_create_token_button": "Crear token de acceso",
|
||||||
|
@ -355,31 +355,5 @@
|
||||||
"account_upgrade_dialog_billing_contact_email": "Para preguntas sobre facturación, por favor <Link>contáctenos</Link> directamente.",
|
"account_upgrade_dialog_billing_contact_email": "Para preguntas sobre facturación, por favor <Link>contáctenos</Link> directamente.",
|
||||||
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} mensaje diario",
|
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} mensaje diario",
|
||||||
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} correo electrónico diario",
|
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} correo electrónico diario",
|
||||||
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} tema reservado",
|
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} tema reservado"
|
||||||
"publish_dialog_call_label": "Llamada telefónica",
|
|
||||||
"publish_dialog_call_placeholder": "Número de teléfono al cual llamar con el mensaje, por ejemplo +12223334444, o \"sí\"",
|
|
||||||
"publish_dialog_chip_call_label": "Llamada telefónica",
|
|
||||||
"account_basics_phone_numbers_title": "Números de teléfono",
|
|
||||||
"account_basics_phone_numbers_description": "Para notificaciones por llamada teléfonica",
|
|
||||||
"account_basics_phone_numbers_no_phone_numbers_yet": "Aún no hay números de teléfono",
|
|
||||||
"account_basics_phone_numbers_dialog_number_label": "Número de teléfono",
|
|
||||||
"account_basics_phone_numbers_dialog_number_placeholder": "p. ej. +1222333444",
|
|
||||||
"account_basics_phone_numbers_dialog_verify_button_sms": "Envía SMS",
|
|
||||||
"account_basics_phone_numbers_dialog_verify_button_call": "Llámame",
|
|
||||||
"account_basics_phone_numbers_dialog_code_label": "Código de verificación",
|
|
||||||
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
|
||||||
"account_basics_phone_numbers_dialog_channel_call": "Llamar",
|
|
||||||
"account_usage_calls_title": "Llamadas telefónicas realizadas",
|
|
||||||
"account_usage_calls_none": "No se pueden hacer llamadas telefónicas con esta cuenta",
|
|
||||||
"account_upgrade_dialog_tier_features_calls_one": "{{llamadas}} llamadas telefónicas diarias",
|
|
||||||
"account_upgrade_dialog_tier_features_calls_other": "{{llamadas}} llamadas telefónicas diarias",
|
|
||||||
"account_upgrade_dialog_tier_features_no_calls": "No hay llamadas telefónicas",
|
|
||||||
"publish_dialog_call_reset": "Eliminar llamada telefónica",
|
|
||||||
"account_basics_phone_numbers_dialog_description": "Para utilizar la función de notificación de llamadas, tiene que añadir y verificar al menos un número de teléfono. La verificación puede realizarse mediante un SMS o una llamada telefónica.",
|
|
||||||
"account_basics_phone_numbers_copied_to_clipboard": "Número de teléfono copiado al portapapeles",
|
|
||||||
"account_basics_phone_numbers_dialog_check_verification_button": "Confirmar código",
|
|
||||||
"account_basics_phone_numbers_dialog_title": "Agregar número de teléfono",
|
|
||||||
"account_basics_phone_numbers_dialog_code_placeholder": "p.ej. 123456",
|
|
||||||
"publish_dialog_call_item": "Llamar al número de teléfono {{number}}",
|
|
||||||
"publish_dialog_chip_call_no_verified_numbers_tooltip": "No hay números de teléfono verificados"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -106,7 +106,7 @@
|
||||||
"prefs_notifications_title": "Notifications",
|
"prefs_notifications_title": "Notifications",
|
||||||
"prefs_notifications_delete_after_title": "Supprimer les notifications",
|
"prefs_notifications_delete_after_title": "Supprimer les notifications",
|
||||||
"prefs_users_add_button": "Ajouter un utilisateur",
|
"prefs_users_add_button": "Ajouter un utilisateur",
|
||||||
"common_back": "Retour",
|
"subscribe_dialog_login_button_back": "Retour",
|
||||||
"subscribe_dialog_error_user_anonymous": "anonyme",
|
"subscribe_dialog_error_user_anonymous": "anonyme",
|
||||||
"prefs_notifications_sound_no_sound": "Aucun son",
|
"prefs_notifications_sound_no_sound": "Aucun son",
|
||||||
"prefs_notifications_min_priority_title": "Priorité minimum",
|
"prefs_notifications_min_priority_title": "Priorité minimum",
|
||||||
|
@ -293,7 +293,7 @@
|
||||||
"account_tokens_table_expires_header": "Expire",
|
"account_tokens_table_expires_header": "Expire",
|
||||||
"account_tokens_table_never_expires": "N'expire jamais",
|
"account_tokens_table_never_expires": "N'expire jamais",
|
||||||
"account_tokens_table_current_session": "Session de navigation actuelle",
|
"account_tokens_table_current_session": "Session de navigation actuelle",
|
||||||
"common_copy_to_clipboard": "Copier dans le presse-papier",
|
"account_tokens_table_copy_to_clipboard": "Copier dans le presse-papier",
|
||||||
"account_tokens_table_copied_to_clipboard": "Jeton d'accès copié",
|
"account_tokens_table_copied_to_clipboard": "Jeton d'accès copié",
|
||||||
"account_tokens_table_create_token_button": "Créer un jeton d'accès",
|
"account_tokens_table_create_token_button": "Créer un jeton d'accès",
|
||||||
"account_tokens_table_last_origin_tooltip": "Depuis l'adresse IP {{ip}}, cliquer pour rechercher",
|
"account_tokens_table_last_origin_tooltip": "Depuis l'adresse IP {{ip}}, cliquer pour rechercher",
|
||||||
|
@ -352,24 +352,5 @@
|
||||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "économisez jusqu'à {{discount}}%",
|
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "économisez jusqu'à {{discount}}%",
|
||||||
"account_upgrade_dialog_tier_price_per_month": "mois",
|
"account_upgrade_dialog_tier_price_per_month": "mois",
|
||||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} prélevé annuellement. Économisez {{save}}.",
|
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} prélevé annuellement. Économisez {{save}}.",
|
||||||
"account_upgrade_dialog_billing_contact_email": "Pour des questions concernant la facturation, merci de nous <Link>contacter</Link> directement.",
|
"account_upgrade_dialog_billing_contact_email": "Pour des questions concernant la facturation, merci de nous <Link>contacter</Link> directement."
|
||||||
"publish_dialog_call_label": "Appel téléphonique",
|
|
||||||
"account_basics_phone_numbers_title": "Numéros de téléphone",
|
|
||||||
"account_basics_phone_numbers_dialog_description": "Pour utiliser la fonctionnalité de notification par appels, vous devez ajouter et vérifier au moins un numéro de téléphone. La vérification peut se faire par SMS ou appel téléphonique.",
|
|
||||||
"account_basics_phone_numbers_description": "Pour des notifications par appel téléphoniques",
|
|
||||||
"account_basics_phone_numbers_no_phone_numbers_yet": "Pas encore de numéros de téléphone",
|
|
||||||
"account_basics_phone_numbers_copied_to_clipboard": "Numéro de téléphone copié dans le presse-papier",
|
|
||||||
"account_basics_phone_numbers_dialog_title": "Ajouter un numéro de téléphone",
|
|
||||||
"account_basics_phone_numbers_dialog_number_label": "Numéro de téléphone",
|
|
||||||
"account_basics_phone_numbers_dialog_number_placeholder": "Ex : +33701020304",
|
|
||||||
"account_basics_phone_numbers_dialog_verify_button_sms": "Envoyer un SMS",
|
|
||||||
"account_basics_phone_numbers_dialog_verify_button_call": "Appelez moi",
|
|
||||||
"account_basics_phone_numbers_dialog_code_label": "Code de vérification",
|
|
||||||
"account_basics_phone_numbers_dialog_code_placeholder": "Ex : 123456",
|
|
||||||
"account_basics_phone_numbers_dialog_check_verification_button": "Code de confirmarion",
|
|
||||||
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
|
||||||
"account_basics_phone_numbers_dialog_channel_call": "Appel",
|
|
||||||
"account_usage_calls_none": "Aucun appels téléphoniques ne peut être fait avec ce compte",
|
|
||||||
"publish_dialog_call_reset": "Supprimer les appels téléphoniques",
|
|
||||||
"publish_dialog_chip_call_label": "Appel téléphonique"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -84,7 +84,7 @@
|
||||||
"subscribe_dialog_login_description": "Ez a téma jelszóval védett. Jelentkezz be a feliratkozáshoz.",
|
"subscribe_dialog_login_description": "Ez a téma jelszóval védett. Jelentkezz be a feliratkozáshoz.",
|
||||||
"subscribe_dialog_login_username_label": "Felhasználónév, pl: jozsi",
|
"subscribe_dialog_login_username_label": "Felhasználónév, pl: jozsi",
|
||||||
"subscribe_dialog_login_password_label": "Jelszó",
|
"subscribe_dialog_login_password_label": "Jelszó",
|
||||||
"common_back": "Vissza",
|
"subscribe_dialog_login_button_back": "Vissza",
|
||||||
"subscribe_dialog_login_button_login": "Belépés",
|
"subscribe_dialog_login_button_login": "Belépés",
|
||||||
"subscribe_dialog_error_user_anonymous": "névtelen",
|
"subscribe_dialog_error_user_anonymous": "névtelen",
|
||||||
"subscribe_dialog_error_user_not_authorized": "A(z) {{username}} felhasználónak nincs hozzáférése",
|
"subscribe_dialog_error_user_not_authorized": "A(z) {{username}} felhasználónak nincs hozzáférése",
|
||||||
|
|
|
@ -116,7 +116,7 @@
|
||||||
"common_save": "Simpan",
|
"common_save": "Simpan",
|
||||||
"prefs_appearance_title": "Tampilan",
|
"prefs_appearance_title": "Tampilan",
|
||||||
"subscribe_dialog_login_password_label": "Kata sandi",
|
"subscribe_dialog_login_password_label": "Kata sandi",
|
||||||
"common_back": "Kembali",
|
"subscribe_dialog_login_button_back": "Kembali",
|
||||||
"prefs_notifications_sound_title": "Suara notifikasi",
|
"prefs_notifications_sound_title": "Suara notifikasi",
|
||||||
"prefs_notifications_min_priority_low_and_higher": "Prioritas rendah dan lebih tinggi",
|
"prefs_notifications_min_priority_low_and_higher": "Prioritas rendah dan lebih tinggi",
|
||||||
"prefs_notifications_min_priority_default_and_higher": "Prioritas bawaan dan lebih tinggi",
|
"prefs_notifications_min_priority_default_and_higher": "Prioritas bawaan dan lebih tinggi",
|
||||||
|
@ -278,7 +278,7 @@
|
||||||
"account_tokens_table_expires_header": "Kedaluwarsa",
|
"account_tokens_table_expires_header": "Kedaluwarsa",
|
||||||
"account_tokens_table_never_expires": "Tidak pernah kedaluwarsa",
|
"account_tokens_table_never_expires": "Tidak pernah kedaluwarsa",
|
||||||
"account_tokens_table_current_session": "Sesi peramban saat ini",
|
"account_tokens_table_current_session": "Sesi peramban saat ini",
|
||||||
"common_copy_to_clipboard": "Salin ke papan klip",
|
"account_tokens_table_copy_to_clipboard": "Salin ke papan klip",
|
||||||
"account_tokens_table_copied_to_clipboard": "Token akses disalin",
|
"account_tokens_table_copied_to_clipboard": "Token akses disalin",
|
||||||
"account_tokens_table_cannot_delete_or_edit": "Tidak dapat menyunting atau menghapus token sesi saat ini",
|
"account_tokens_table_cannot_delete_or_edit": "Tidak dapat menyunting atau menghapus token sesi saat ini",
|
||||||
"account_tokens_table_create_token_button": "Buat token akses",
|
"account_tokens_table_create_token_button": "Buat token akses",
|
||||||
|
@ -355,31 +355,5 @@
|
||||||
"account_upgrade_dialog_billing_contact_website": "Untuk pertanyaan penagihan, silakan menuju ke <Link>situs web</Link> kami.",
|
"account_upgrade_dialog_billing_contact_website": "Untuk pertanyaan penagihan, silakan menuju ke <Link>situs web</Link> kami.",
|
||||||
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} topik yang direservasi",
|
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} topik yang direservasi",
|
||||||
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} surel harian",
|
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} surel harian",
|
||||||
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} pesan harian",
|
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} pesan harian"
|
||||||
"publish_dialog_call_label": "Panggilan telepon",
|
|
||||||
"publish_dialog_call_placeholder": "Nomor telepon untuk dipanggil dengan pesan, mis. +622223334444, atau 'yes'",
|
|
||||||
"account_basics_phone_numbers_title": "Nomor telepon",
|
|
||||||
"account_basics_phone_numbers_dialog_description": "Untuk menggunakan fitur notifikasi telepon, Anda perlu menambahkan dan memverifikasi setidaknya satu nomor telepon. Verifikasi dapat dilakukan melalui SMS atau panggilan telepon.",
|
|
||||||
"account_basics_phone_numbers_no_phone_numbers_yet": "Belum ada nomor telepon",
|
|
||||||
"account_basics_phone_numbers_dialog_title": "Tambahkan nomor telepon",
|
|
||||||
"account_basics_phone_numbers_dialog_number_label": "Nomor telepon",
|
|
||||||
"account_basics_phone_numbers_dialog_number_placeholder": "mis. +62222333444",
|
|
||||||
"account_basics_phone_numbers_dialog_verify_button_sms": "Kirim SMS",
|
|
||||||
"account_basics_phone_numbers_dialog_channel_call": "Panggil",
|
|
||||||
"account_usage_calls_title": "Panggilan telepon dilakukan",
|
|
||||||
"account_usage_calls_none": "Tidak ada panggilan telepon yang dapat dilakukan dengan akun ini",
|
|
||||||
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} panggilan telepon harian",
|
|
||||||
"publish_dialog_call_reset": "Hapus panggilan telepon",
|
|
||||||
"account_basics_phone_numbers_description": "Untuk notifikasi panggilan telepon",
|
|
||||||
"account_basics_phone_numbers_copied_to_clipboard": "Nomor telepon disalin ke papan klip",
|
|
||||||
"publish_dialog_chip_call_label": "Panggilan telepon",
|
|
||||||
"account_basics_phone_numbers_dialog_verify_button_call": "Panggil saya",
|
|
||||||
"account_basics_phone_numbers_dialog_code_placeholder": "mis. 123456",
|
|
||||||
"account_basics_phone_numbers_dialog_check_verification_button": "Konfirmasi kode",
|
|
||||||
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
|
||||||
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} panggilan telepon harian",
|
|
||||||
"account_upgrade_dialog_tier_features_no_calls": "Tidak ada panggilan telepon",
|
|
||||||
"account_basics_phone_numbers_dialog_code_label": "Kode verifikasi",
|
|
||||||
"publish_dialog_call_item": "Panggil nomor telepon {{number}}",
|
|
||||||
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Tidak ada nomor telepon terverifikasi"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -178,7 +178,7 @@
|
||||||
"prefs_notifications_sound_play": "Riproduci il suono selezionato",
|
"prefs_notifications_sound_play": "Riproduci il suono selezionato",
|
||||||
"prefs_notifications_min_priority_title": "Priorità minima",
|
"prefs_notifications_min_priority_title": "Priorità minima",
|
||||||
"subscribe_dialog_login_description": "Questo argomento è protetto da password. Per favore inserisci username e password per iscriverti.",
|
"subscribe_dialog_login_description": "Questo argomento è protetto da password. Per favore inserisci username e password per iscriverti.",
|
||||||
"common_back": "Indietro",
|
"subscribe_dialog_login_button_back": "Indietro",
|
||||||
"subscribe_dialog_error_user_not_authorized": "Utente {{username}} non autorizzato",
|
"subscribe_dialog_error_user_not_authorized": "Utente {{username}} non autorizzato",
|
||||||
"prefs_notifications_title": "Notifiche",
|
"prefs_notifications_title": "Notifiche",
|
||||||
"prefs_notifications_delete_after_title": "Elimina le notifiche",
|
"prefs_notifications_delete_after_title": "Elimina le notifiche",
|
||||||
|
@ -256,8 +256,5 @@
|
||||||
"account_basics_tier_admin_suffix_no_tier": "(nessun livello)",
|
"account_basics_tier_admin_suffix_no_tier": "(nessun livello)",
|
||||||
"account_basics_tier_basic": "Base",
|
"account_basics_tier_basic": "Base",
|
||||||
"account_basics_tier_free": "Gratuito",
|
"account_basics_tier_free": "Gratuito",
|
||||||
"account_usage_emails_title": "Email inviate",
|
"account_usage_emails_title": "Email inviate"
|
||||||
"account_usage_cannot_create_portal_session": "Impossibile aprire il portale di pagamento",
|
|
||||||
"account_delete_title": "Elimina account",
|
|
||||||
"account_basics_username_description": "Hey, sei tu ❤"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@
|
||||||
"subscribe_dialog_login_description": "このトピックはログインする必要があります。ユーザー名とパスワードを入力してください。",
|
"subscribe_dialog_login_description": "このトピックはログインする必要があります。ユーザー名とパスワードを入力してください。",
|
||||||
"subscribe_dialog_login_username_label": "ユーザー名, 例) phil",
|
"subscribe_dialog_login_username_label": "ユーザー名, 例) phil",
|
||||||
"subscribe_dialog_login_password_label": "パスワード",
|
"subscribe_dialog_login_password_label": "パスワード",
|
||||||
"common_back": "戻る",
|
"subscribe_dialog_login_button_back": "戻る",
|
||||||
"subscribe_dialog_login_button_login": "ログイン",
|
"subscribe_dialog_login_button_login": "ログイン",
|
||||||
"prefs_notifications_min_priority_high_and_higher": "優先度高 およびそれ以上",
|
"prefs_notifications_min_priority_high_and_higher": "優先度高 およびそれ以上",
|
||||||
"prefs_notifications_min_priority_max_only": "優先度最高のみ",
|
"prefs_notifications_min_priority_max_only": "優先度最高のみ",
|
||||||
|
@ -258,7 +258,7 @@
|
||||||
"account_tokens_table_expires_header": "期限",
|
"account_tokens_table_expires_header": "期限",
|
||||||
"account_tokens_table_never_expires": "無期限",
|
"account_tokens_table_never_expires": "無期限",
|
||||||
"account_tokens_table_current_session": "現在のブラウザセッション",
|
"account_tokens_table_current_session": "現在のブラウザセッション",
|
||||||
"common_copy_to_clipboard": "クリップボードにコピー",
|
"account_tokens_table_copy_to_clipboard": "クリップボードにコピー",
|
||||||
"account_tokens_table_copied_to_clipboard": "アクセストークンをコピーしました",
|
"account_tokens_table_copied_to_clipboard": "アクセストークンをコピーしました",
|
||||||
"account_tokens_table_cannot_delete_or_edit": "現在のセッショントークンは編集または削除できません",
|
"account_tokens_table_cannot_delete_or_edit": "現在のセッショントークンは編集または削除できません",
|
||||||
"account_tokens_table_create_token_button": "アクセストークンを生成",
|
"account_tokens_table_create_token_button": "アクセストークンを生成",
|
||||||
|
@ -355,6 +355,5 @@
|
||||||
"account_upgrade_dialog_billing_contact_website": "支払いに関する質問は、<Link>ウェブサイト</Link>を参照して下さい。",
|
"account_upgrade_dialog_billing_contact_website": "支払いに関する質問は、<Link>ウェブサイト</Link>を参照して下さい。",
|
||||||
"account_upgrade_dialog_tier_features_messages_one": "毎日 {{messages}} メッセージ",
|
"account_upgrade_dialog_tier_features_messages_one": "毎日 {{messages}} メッセージ",
|
||||||
"account_upgrade_dialog_tier_features_reservations_one": "予約済みトピック {{reservations}} 件",
|
"account_upgrade_dialog_tier_features_reservations_one": "予約済みトピック {{reservations}} 件",
|
||||||
"account_upgrade_dialog_tier_features_emails_one": "毎日メール {{emails}} 件",
|
"account_upgrade_dialog_tier_features_emails_one": "毎日メール {{emails}} 件"
|
||||||
"publish_dialog_call_label": "電話"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -93,7 +93,7 @@
|
||||||
"subscribe_dialog_error_user_not_authorized": "사용자 {{username}} 은(는) 인증되지 않았습니다",
|
"subscribe_dialog_error_user_not_authorized": "사용자 {{username}} 은(는) 인증되지 않았습니다",
|
||||||
"subscribe_dialog_login_username_label": "사용자 이름, 예를 들면 phil",
|
"subscribe_dialog_login_username_label": "사용자 이름, 예를 들면 phil",
|
||||||
"subscribe_dialog_login_password_label": "비밀번호",
|
"subscribe_dialog_login_password_label": "비밀번호",
|
||||||
"common_back": "뒤로가기",
|
"subscribe_dialog_login_button_back": "뒤로가기",
|
||||||
"subscribe_dialog_login_button_login": "로그인",
|
"subscribe_dialog_login_button_login": "로그인",
|
||||||
"prefs_notifications_title": "알림",
|
"prefs_notifications_title": "알림",
|
||||||
"prefs_notifications_sound_title": "알림 효과음",
|
"prefs_notifications_sound_title": "알림 효과음",
|
||||||
|
|
|
@ -113,7 +113,7 @@
|
||||||
"prefs_notifications_delete_after_one_week_description": "Merknader slettes automatisk etter én uke",
|
"prefs_notifications_delete_after_one_week_description": "Merknader slettes automatisk etter én uke",
|
||||||
"prefs_notifications_delete_after_one_month_description": "Merknader slettes automatisk etter én måned",
|
"prefs_notifications_delete_after_one_month_description": "Merknader slettes automatisk etter én måned",
|
||||||
"priority_min": "min.",
|
"priority_min": "min.",
|
||||||
"common_back": "Tilbake",
|
"subscribe_dialog_login_button_back": "Tilbake",
|
||||||
"prefs_notifications_delete_after_three_hours": "Etter tre timer",
|
"prefs_notifications_delete_after_three_hours": "Etter tre timer",
|
||||||
"prefs_users_table_base_url_header": "Tjeneste-nettadresse",
|
"prefs_users_table_base_url_header": "Tjeneste-nettadresse",
|
||||||
"common_cancel": "Avbryt",
|
"common_cancel": "Avbryt",
|
||||||
|
|
|
@ -140,7 +140,7 @@
|
||||||
"subscribe_dialog_subscribe_title": "Onderwerp abonneren",
|
"subscribe_dialog_subscribe_title": "Onderwerp abonneren",
|
||||||
"subscribe_dialog_subscribe_description": "Onderwerpen zijn mogelijk niet beschermd met een wachtwoord, kies daarom een moeilijk te raden naam. Na abonneren kun je notificaties via PUT/POST sturen.",
|
"subscribe_dialog_subscribe_description": "Onderwerpen zijn mogelijk niet beschermd met een wachtwoord, kies daarom een moeilijk te raden naam. Na abonneren kun je notificaties via PUT/POST sturen.",
|
||||||
"subscribe_dialog_login_password_label": "Wachtwoord",
|
"subscribe_dialog_login_password_label": "Wachtwoord",
|
||||||
"common_back": "Terug",
|
"subscribe_dialog_login_button_back": "Terug",
|
||||||
"subscribe_dialog_login_button_login": "Aanmelden",
|
"subscribe_dialog_login_button_login": "Aanmelden",
|
||||||
"subscribe_dialog_error_user_not_authorized": "Gebruiker {{username}} heeft geen toegang",
|
"subscribe_dialog_error_user_not_authorized": "Gebruiker {{username}} heeft geen toegang",
|
||||||
"subscribe_dialog_error_user_anonymous": "anoniem",
|
"subscribe_dialog_error_user_anonymous": "anoniem",
|
||||||
|
@ -331,7 +331,7 @@
|
||||||
"account_upgrade_dialog_button_cancel_subscription": "Abonnement opzeggen",
|
"account_upgrade_dialog_button_cancel_subscription": "Abonnement opzeggen",
|
||||||
"account_tokens_table_last_access_header": "Laatste toegang",
|
"account_tokens_table_last_access_header": "Laatste toegang",
|
||||||
"account_tokens_table_expires_header": "Verloopt op",
|
"account_tokens_table_expires_header": "Verloopt op",
|
||||||
"common_copy_to_clipboard": "Kopieer naar klembord",
|
"account_tokens_table_copy_to_clipboard": "Kopieer naar klembord",
|
||||||
"account_tokens_table_copied_to_clipboard": "Toegangstoken gekopieerd",
|
"account_tokens_table_copied_to_clipboard": "Toegangstoken gekopieerd",
|
||||||
"account_tokens_delete_dialog_submit_button": "Token definitief verwijderen",
|
"account_tokens_delete_dialog_submit_button": "Token definitief verwijderen",
|
||||||
"prefs_users_description_no_sync": "Gebruikers en wachtwoorden worden niet gesynchroniseerd met uw account.",
|
"prefs_users_description_no_sync": "Gebruikers en wachtwoorden worden niet gesynchroniseerd met uw account.",
|
||||||
|
@ -355,30 +355,5 @@
|
||||||
"prefs_reservations_table_topic_header": "Onderwerp",
|
"prefs_reservations_table_topic_header": "Onderwerp",
|
||||||
"prefs_reservations_table_access_header": "Toegang",
|
"prefs_reservations_table_access_header": "Toegang",
|
||||||
"prefs_reservations_table_everyone_read_write": "Iedereen kan publiceren en abonneren",
|
"prefs_reservations_table_everyone_read_write": "Iedereen kan publiceren en abonneren",
|
||||||
"prefs_reservations_table_not_subscribed": "Niet geabonneerd",
|
"prefs_reservations_table_not_subscribed": "Niet geabonneerd"
|
||||||
"publish_dialog_call_label": "Telefoongesprek",
|
|
||||||
"publish_dialog_call_reset": "Telefoongesprek verwijderen",
|
|
||||||
"publish_dialog_chip_call_label": "Telefoongesprek",
|
|
||||||
"account_basics_phone_numbers_title": "Telefoonnummers",
|
|
||||||
"account_basics_phone_numbers_description": "Voor meldingen via telefoongesprekken",
|
|
||||||
"account_basics_phone_numbers_no_phone_numbers_yet": "Nog geen telefoonnummers",
|
|
||||||
"account_basics_phone_numbers_dialog_verify_button_call": "Bel me",
|
|
||||||
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} dagelijkse telefoontjes",
|
|
||||||
"account_basics_phone_numbers_copied_to_clipboard": "Telefoonnummer gekopieerd naar klembord",
|
|
||||||
"publish_dialog_call_item": "Bel telefoonnummer {{nummer}}",
|
|
||||||
"account_basics_phone_numbers_dialog_check_verification_button": "Bevestig code",
|
|
||||||
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Geen geverifieerde telefoonnummers",
|
|
||||||
"account_basics_phone_numbers_dialog_channel_call": "Telefoongesprek",
|
|
||||||
"account_basics_phone_numbers_dialog_number_label": "Telefoonnummer",
|
|
||||||
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
|
||||||
"account_basics_phone_numbers_dialog_code_placeholder": "bijv. 123456",
|
|
||||||
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} dagelijkse telefoontjes",
|
|
||||||
"account_upgrade_dialog_tier_features_no_calls": "Geen telefoontjes",
|
|
||||||
"account_basics_phone_numbers_dialog_description": "Als u de functie voor oproepmeldingen wilt gebruiken, moet u ten minste één telefoonnummer toevoegen en verifiëren. Verificatie kan worden gedaan via sms of een telefoontje.",
|
|
||||||
"account_basics_phone_numbers_dialog_title": "Telefoonnummer toevoegen",
|
|
||||||
"account_basics_phone_numbers_dialog_number_placeholder": "bijv. +1222333444",
|
|
||||||
"account_basics_phone_numbers_dialog_verify_button_sms": "Stuur SMS",
|
|
||||||
"account_basics_phone_numbers_dialog_code_label": "Verificatiecode",
|
|
||||||
"account_usage_calls_title": "Aantal telefoontjes",
|
|
||||||
"account_usage_calls_none": "Met dit account kan niet worden gebeld"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -107,7 +107,7 @@
|
||||||
"subscribe_dialog_login_username_label": "Nazwa użytkownika, np. phil",
|
"subscribe_dialog_login_username_label": "Nazwa użytkownika, np. phil",
|
||||||
"subscribe_dialog_login_password_label": "Hasło",
|
"subscribe_dialog_login_password_label": "Hasło",
|
||||||
"publish_dialog_button_cancel": "Anuluj",
|
"publish_dialog_button_cancel": "Anuluj",
|
||||||
"common_back": "Powrót",
|
"subscribe_dialog_login_button_back": "Powrót",
|
||||||
"subscribe_dialog_login_button_login": "Zaloguj się",
|
"subscribe_dialog_login_button_login": "Zaloguj się",
|
||||||
"subscribe_dialog_error_user_not_authorized": "Użytkownik {{username}} nie ma uprawnień",
|
"subscribe_dialog_error_user_not_authorized": "Użytkownik {{username}} nie ma uprawnień",
|
||||||
"subscribe_dialog_error_user_anonymous": "anonim",
|
"subscribe_dialog_error_user_anonymous": "anonim",
|
||||||
|
@ -253,7 +253,7 @@
|
||||||
"account_tokens_table_expires_header": "Termin ważności",
|
"account_tokens_table_expires_header": "Termin ważności",
|
||||||
"account_tokens_table_never_expires": "Bezterminowy",
|
"account_tokens_table_never_expires": "Bezterminowy",
|
||||||
"account_tokens_table_current_session": "Aktualna sesja przeglądarki",
|
"account_tokens_table_current_session": "Aktualna sesja przeglądarki",
|
||||||
"common_copy_to_clipboard": "Kopiuj do schowka",
|
"account_tokens_table_copy_to_clipboard": "Kopiuj do schowka",
|
||||||
"account_tokens_table_copied_to_clipboard": "Token został skopiowany",
|
"account_tokens_table_copied_to_clipboard": "Token został skopiowany",
|
||||||
"account_tokens_table_cannot_delete_or_edit": "Nie można edytować ani usunąć tokenu aktualnej sesji",
|
"account_tokens_table_cannot_delete_or_edit": "Nie można edytować ani usunąć tokenu aktualnej sesji",
|
||||||
"account_tokens_table_create_token_button": "Utwórz token dostępowy",
|
"account_tokens_table_create_token_button": "Utwórz token dostępowy",
|
||||||
|
|
|
@ -144,7 +144,7 @@
|
||||||
"subscribe_dialog_login_description": "Esse tópico é protegido por palavra-passe. Por favor insira um nome de utilizador e palavra-passe para subscrever.",
|
"subscribe_dialog_login_description": "Esse tópico é protegido por palavra-passe. Por favor insira um nome de utilizador e palavra-passe para subscrever.",
|
||||||
"subscribe_dialog_login_username_label": "Nome, por exemplo: \"filipe\"",
|
"subscribe_dialog_login_username_label": "Nome, por exemplo: \"filipe\"",
|
||||||
"subscribe_dialog_login_password_label": "Palavra-passe",
|
"subscribe_dialog_login_password_label": "Palavra-passe",
|
||||||
"common_back": "Voltar",
|
"subscribe_dialog_login_button_back": "Voltar",
|
||||||
"subscribe_dialog_login_button_login": "Autenticar",
|
"subscribe_dialog_login_button_login": "Autenticar",
|
||||||
"subscribe_dialog_error_user_anonymous": "anónimo",
|
"subscribe_dialog_error_user_anonymous": "anónimo",
|
||||||
"prefs_notifications_title": "Notificações",
|
"prefs_notifications_title": "Notificações",
|
||||||
|
@ -214,17 +214,5 @@
|
||||||
"login_link_signup": "Registar",
|
"login_link_signup": "Registar",
|
||||||
"action_bar_reservation_add": "Reservar tópico",
|
"action_bar_reservation_add": "Reservar tópico",
|
||||||
"action_bar_sign_up": "Registar",
|
"action_bar_sign_up": "Registar",
|
||||||
"nav_button_account": "Conta",
|
"nav_button_account": "Conta"
|
||||||
"common_copy_to_clipboard": "Copiar",
|
|
||||||
"nav_upgrade_banner_label": "Atualizar para ntfy Pro",
|
|
||||||
"alert_not_supported_context_description": "Notificações são suportadas apenas sobre HTTPS. Essa é uma limitação da <mdnLink>API de Notificações</mdnLink>.",
|
|
||||||
"display_name_dialog_title": "Alterar nome mostrado",
|
|
||||||
"display_name_dialog_description": "Configura um nome alternativo ao tópico que é mostrado na lista de assinaturas. Isto ajuda a identificar tópicos com nomes complicados mais facilmente.",
|
|
||||||
"display_name_dialog_placeholder": "Nome exibido",
|
|
||||||
"reserve_dialog_checkbox_label": "Reservar tópico e configurar acesso",
|
|
||||||
"publish_dialog_call_label": "Chamada telefônica",
|
|
||||||
"publish_dialog_call_placeholder": "Número de telefone para ligar com a mensagem, ex: +12223334444, ou 'Sim'",
|
|
||||||
"publish_dialog_call_reset": "Remover chamada telefônica",
|
|
||||||
"publish_dialog_chip_call_label": "Chamada telefônica",
|
|
||||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Gerar nome"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -93,7 +93,7 @@
|
||||||
"prefs_notifications_min_priority_low_and_higher": "Baixa prioridade e acima",
|
"prefs_notifications_min_priority_low_and_higher": "Baixa prioridade e acima",
|
||||||
"prefs_notifications_min_priority_default_and_higher": "Prioridade padrão e acima",
|
"prefs_notifications_min_priority_default_and_higher": "Prioridade padrão e acima",
|
||||||
"subscribe_dialog_login_password_label": "Senha",
|
"subscribe_dialog_login_password_label": "Senha",
|
||||||
"common_back": "Voltar",
|
"subscribe_dialog_login_button_back": "Voltar",
|
||||||
"prefs_notifications_min_priority_high_and_higher": "Alta prioridade e acima",
|
"prefs_notifications_min_priority_high_and_higher": "Alta prioridade e acima",
|
||||||
"prefs_notifications_min_priority_max_only": "Apenas prioridade máxima",
|
"prefs_notifications_min_priority_max_only": "Apenas prioridade máxima",
|
||||||
"prefs_notifications_delete_after_title": "Apagar notificações",
|
"prefs_notifications_delete_after_title": "Apagar notificações",
|
||||||
|
|
|
@ -98,7 +98,7 @@
|
||||||
"subscribe_dialog_login_description": "Эта тема защищена паролем. Пожалуйста, введите имя пользователя и пароль, чтобы подписаться.",
|
"subscribe_dialog_login_description": "Эта тема защищена паролем. Пожалуйста, введите имя пользователя и пароль, чтобы подписаться.",
|
||||||
"subscribe_dialog_login_username_label": "Имя пользователя. Например, phil",
|
"subscribe_dialog_login_username_label": "Имя пользователя. Например, phil",
|
||||||
"subscribe_dialog_login_password_label": "Пароль",
|
"subscribe_dialog_login_password_label": "Пароль",
|
||||||
"common_back": "Назад",
|
"subscribe_dialog_login_button_back": "Назад",
|
||||||
"subscribe_dialog_login_button_login": "Войти",
|
"subscribe_dialog_login_button_login": "Войти",
|
||||||
"subscribe_dialog_error_user_not_authorized": "Пользователь {{username}} не авторизован",
|
"subscribe_dialog_error_user_not_authorized": "Пользователь {{username}} не авторизован",
|
||||||
"subscribe_dialog_error_user_anonymous": "анонимный пользователь",
|
"subscribe_dialog_error_user_anonymous": "анонимный пользователь",
|
||||||
|
@ -206,7 +206,7 @@
|
||||||
"account_basics_tier_free": "Бесплатный",
|
"account_basics_tier_free": "Бесплатный",
|
||||||
"account_tokens_dialog_title_create": "Создать токен доступа",
|
"account_tokens_dialog_title_create": "Создать токен доступа",
|
||||||
"account_tokens_dialog_title_delete": "Удалить токен доступа",
|
"account_tokens_dialog_title_delete": "Удалить токен доступа",
|
||||||
"common_copy_to_clipboard": "Скопировать в буфер обмена",
|
"account_tokens_table_copy_to_clipboard": "Скопировать в буфер обмена",
|
||||||
"account_tokens_dialog_button_cancel": "Отмена",
|
"account_tokens_dialog_button_cancel": "Отмена",
|
||||||
"account_tokens_dialog_expires_unchanged": "Оставить срок истечения без изменений",
|
"account_tokens_dialog_expires_unchanged": "Оставить срок истечения без изменений",
|
||||||
"account_tokens_dialog_expires_x_days": "Токен истекает через {{days}} дней",
|
"account_tokens_dialog_expires_x_days": "Токен истекает через {{days}} дней",
|
||||||
|
|
|
@ -95,14 +95,14 @@
|
||||||
"publish_dialog_email_placeholder": "Adress att vidarebefordra meddelandet till, t.ex. phil@example.com",
|
"publish_dialog_email_placeholder": "Adress att vidarebefordra meddelandet till, t.ex. phil@example.com",
|
||||||
"publish_dialog_details_examples_description": "Exempel och en detaljerad beskrivning av alla sändningsfunktioner finns i <docsLink>dokumentationen</docsLink> .",
|
"publish_dialog_details_examples_description": "Exempel och en detaljerad beskrivning av alla sändningsfunktioner finns i <docsLink>dokumentationen</docsLink> .",
|
||||||
"publish_dialog_button_send": "Skicka",
|
"publish_dialog_button_send": "Skicka",
|
||||||
"common_back": "Tillbaka",
|
"subscribe_dialog_login_button_back": "Tillbaka",
|
||||||
"account_basics_tier_free": "Gratis",
|
"account_basics_tier_free": "Gratis",
|
||||||
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} reserverat ämne",
|
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} reserverat ämne",
|
||||||
"account_delete_title": "Ta bort konto",
|
"account_delete_title": "Ta bort konto",
|
||||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} dagliga meddelanden",
|
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} dagliga meddelanden",
|
||||||
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} dagligt e-postmeddelande",
|
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} dagligt e-postmeddelande",
|
||||||
"account_upgrade_dialog_button_cancel": "Avbryt",
|
"account_upgrade_dialog_button_cancel": "Avbryt",
|
||||||
"common_copy_to_clipboard": "Kopiera till urklipp",
|
"account_tokens_table_copy_to_clipboard": "Kopiera till urklipp",
|
||||||
"account_tokens_table_copied_to_clipboard": "Åtkomsttoken kopierat",
|
"account_tokens_table_copied_to_clipboard": "Åtkomsttoken kopierat",
|
||||||
"account_tokens_description": "Använd åtkomsttoken när du publicerar och prenumererar via ntfy API, så att du inte behöver skicka dina kontouppgifter. Läs mer i <Link>dokumentationen</Link>.",
|
"account_tokens_description": "Använd åtkomsttoken när du publicerar och prenumererar via ntfy API, så att du inte behöver skicka dina kontouppgifter. Läs mer i <Link>dokumentationen</Link>.",
|
||||||
"account_tokens_table_create_token_button": "Skapa åtkomsttoken",
|
"account_tokens_table_create_token_button": "Skapa åtkomsttoken",
|
||||||
|
@ -355,30 +355,5 @@
|
||||||
"reservation_delete_dialog_action_keep_title": "Behåll cachade meddelanden och bilagor",
|
"reservation_delete_dialog_action_keep_title": "Behåll cachade meddelanden och bilagor",
|
||||||
"reservation_delete_dialog_action_keep_description": "Meddelanden och bilagor som lagras på servern blir offentligt synliga för personer som känner till ämnesnamnet.",
|
"reservation_delete_dialog_action_keep_description": "Meddelanden och bilagor som lagras på servern blir offentligt synliga för personer som känner till ämnesnamnet.",
|
||||||
"reservation_delete_dialog_action_delete_title": "Ta bort meddelanden och bilagor som sparats i cacheminnet",
|
"reservation_delete_dialog_action_delete_title": "Ta bort meddelanden och bilagor som sparats i cacheminnet",
|
||||||
"reservation_delete_dialog_description": "Om du tar bort en reservation ger du upp äganderätten till ämnet och låter andra reservera det. Du kan behålla eller radera befintliga meddelanden och bilagor.",
|
"reservation_delete_dialog_description": "Om du tar bort en reservation ger du upp äganderätten till ämnet och låter andra reservera det. Du kan behålla eller radera befintliga meddelanden och bilagor."
|
||||||
"publish_dialog_call_label": "Telefonsamtal",
|
|
||||||
"publish_dialog_call_reset": "Ta bort telefonsamtal",
|
|
||||||
"publish_dialog_chip_call_label": "Telefonsamtal",
|
|
||||||
"account_basics_phone_numbers_title": "Telefonnummer",
|
|
||||||
"account_basics_phone_numbers_description": "För notifieringar via telefonsamtal",
|
|
||||||
"account_basics_phone_numbers_no_phone_numbers_yet": "Inga telefonnummer ännu",
|
|
||||||
"account_basics_phone_numbers_copied_to_clipboard": "Telefonnummer kopierat till urklipp",
|
|
||||||
"account_basics_phone_numbers_dialog_title": "Lägga till telefonnummer",
|
|
||||||
"account_basics_phone_numbers_dialog_number_label": "Telefonnummer",
|
|
||||||
"account_basics_phone_numbers_dialog_number_placeholder": "t.ex. +1222333444",
|
|
||||||
"account_basics_phone_numbers_dialog_verify_button_sms": "Skicka SMS",
|
|
||||||
"account_basics_phone_numbers_dialog_verify_button_call": "Ring mig",
|
|
||||||
"account_basics_phone_numbers_dialog_code_label": "Verifieringskod",
|
|
||||||
"account_basics_phone_numbers_dialog_channel_call": "Ring",
|
|
||||||
"account_usage_calls_title": "Telefonsamtal som gjorts",
|
|
||||||
"account_usage_calls_none": "Inga telefonsamtal kan göras med detta konto",
|
|
||||||
"publish_dialog_call_item": "Ring telefonnummer {{number}}",
|
|
||||||
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Inga verifierade telefonnummer",
|
|
||||||
"account_basics_phone_numbers_dialog_description": "För att använda funktionen för samtalsavisering måste du lägga till och verifiera minst ett telefonnummer. Verifieringen kan göras via SMS eller ett telefonsamtal.",
|
|
||||||
"account_basics_phone_numbers_dialog_code_placeholder": "t.ex. 123456",
|
|
||||||
"account_basics_phone_numbers_dialog_check_verification_button": "Bekräfta kod",
|
|
||||||
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
|
||||||
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} dagliga telefonsamtal",
|
|
||||||
"account_upgrade_dialog_tier_features_no_calls": "Inga telefonsamtal",
|
|
||||||
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} dagliga telefonsamtal"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,7 +34,7 @@
|
||||||
"subscribe_dialog_login_description": "Bu konu parola korumalı. Abone olmak için lütfen kullanıcı adı ve parola girin.",
|
"subscribe_dialog_login_description": "Bu konu parola korumalı. Abone olmak için lütfen kullanıcı adı ve parola girin.",
|
||||||
"subscribe_dialog_login_username_label": "Kullanıcı adı, örn. phil",
|
"subscribe_dialog_login_username_label": "Kullanıcı adı, örn. phil",
|
||||||
"subscribe_dialog_login_password_label": "Parola",
|
"subscribe_dialog_login_password_label": "Parola",
|
||||||
"common_back": "Geri",
|
"subscribe_dialog_login_button_back": "Geri",
|
||||||
"subscribe_dialog_login_button_login": "Oturum aç",
|
"subscribe_dialog_login_button_login": "Oturum aç",
|
||||||
"subscribe_dialog_error_user_not_authorized": "{{username}} kullanıcısı yetkili değil",
|
"subscribe_dialog_error_user_not_authorized": "{{username}} kullanıcısı yetkili değil",
|
||||||
"subscribe_dialog_error_user_anonymous": "anonim",
|
"subscribe_dialog_error_user_anonymous": "anonim",
|
||||||
|
@ -268,7 +268,7 @@
|
||||||
"account_tokens_table_token_header": "Belirteç",
|
"account_tokens_table_token_header": "Belirteç",
|
||||||
"account_tokens_table_label_header": "Etiket",
|
"account_tokens_table_label_header": "Etiket",
|
||||||
"account_tokens_table_current_session": "Geçerli tarayıcı oturumu",
|
"account_tokens_table_current_session": "Geçerli tarayıcı oturumu",
|
||||||
"common_copy_to_clipboard": "Panoya kopyala",
|
"account_tokens_table_copy_to_clipboard": "Panoya kopyala",
|
||||||
"account_tokens_table_copied_to_clipboard": "Erişim belirteci kopyalandı",
|
"account_tokens_table_copied_to_clipboard": "Erişim belirteci kopyalandı",
|
||||||
"account_tokens_table_cannot_delete_or_edit": "Geçerli oturum belirteci düzenlenemez veya silinemez",
|
"account_tokens_table_cannot_delete_or_edit": "Geçerli oturum belirteci düzenlenemez veya silinemez",
|
||||||
"account_tokens_table_create_token_button": "Erişim belirteci oluştur",
|
"account_tokens_table_create_token_button": "Erişim belirteci oluştur",
|
||||||
|
|
|
@ -53,7 +53,7 @@
|
||||||
"subscribe_dialog_subscribe_use_another_label": "Використовувати інший сервер",
|
"subscribe_dialog_subscribe_use_another_label": "Використовувати інший сервер",
|
||||||
"subscribe_dialog_subscribe_base_url_label": "URL служби",
|
"subscribe_dialog_subscribe_base_url_label": "URL служби",
|
||||||
"subscribe_dialog_login_password_label": "Пароль",
|
"subscribe_dialog_login_password_label": "Пароль",
|
||||||
"common_back": "Назад",
|
"subscribe_dialog_login_button_back": "Назад",
|
||||||
"subscribe_dialog_error_user_not_authorized": "{{username}} користувач не авторизований",
|
"subscribe_dialog_error_user_not_authorized": "{{username}} користувач не авторизований",
|
||||||
"prefs_notifications_sound_description_none": "Сповіщення не відтворюють жодного звуку при надходженні",
|
"prefs_notifications_sound_description_none": "Сповіщення не відтворюють жодного звуку при надходженні",
|
||||||
"prefs_notifications_sound_description_some": "Сповіщення відтворюють звук {{sound}}",
|
"prefs_notifications_sound_description_some": "Сповіщення відтворюють звук {{sound}}",
|
||||||
|
@ -237,149 +237,5 @@
|
||||||
"display_name_dialog_description": "Задайте альтернативну назву для теми, яка відображатиметься у списку підписок. Це допоможе легше ідентифікувати теми зі складними назвами.",
|
"display_name_dialog_description": "Задайте альтернативну назву для теми, яка відображатиметься у списку підписок. Це допоможе легше ідентифікувати теми зі складними назвами.",
|
||||||
"display_name_dialog_placeholder": "Відображуване ім'я",
|
"display_name_dialog_placeholder": "Відображуване ім'я",
|
||||||
"account_basics_password_title": "Пароль",
|
"account_basics_password_title": "Пароль",
|
||||||
"account_basics_username_admin_tooltip": "Ви адміністратор",
|
"account_basics_username_admin_tooltip": "Ви адміністратор"
|
||||||
"account_basics_tier_interval_monthly": "щомісяця",
|
|
||||||
"common_copy_to_clipboard": "Скопіювати в буфер обміну",
|
|
||||||
"account_basics_phone_numbers_title": "Номери телефонів",
|
|
||||||
"account_basics_phone_numbers_description": "Для сповіщень через телефонні дзвінки",
|
|
||||||
"account_basics_phone_numbers_no_phone_numbers_yet": "Поки що немає номерів телефонів",
|
|
||||||
"account_basics_phone_numbers_copied_to_clipboard": "Номер телефону скопійовано в буфер обміну",
|
|
||||||
"account_basics_phone_numbers_dialog_title": "Додати номер телефону",
|
|
||||||
"account_basics_phone_numbers_dialog_number_label": "Номер телефону",
|
|
||||||
"account_basics_phone_numbers_dialog_number_placeholder": "наприклад, +1222333444",
|
|
||||||
"account_basics_phone_numbers_dialog_verify_button_sms": "Надіслати SMS",
|
|
||||||
"account_basics_phone_numbers_dialog_verify_button_call": "Зателефонуйте мені",
|
|
||||||
"account_basics_phone_numbers_dialog_code_label": "Код підтвердження",
|
|
||||||
"account_basics_phone_numbers_dialog_code_placeholder": "наприклад, 123456",
|
|
||||||
"account_basics_phone_numbers_dialog_check_verification_button": "Підтвердити код",
|
|
||||||
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
|
||||||
"account_basics_phone_numbers_dialog_channel_call": "Дзвінок",
|
|
||||||
"account_basics_tier_interval_yearly": "щороку",
|
|
||||||
"account_usage_calls_title": "Здійснені телефонні дзвінки",
|
|
||||||
"account_usage_calls_none": "З цього облікового запису не можна здійснювати телефонні дзвінки",
|
|
||||||
"account_usage_attachment_storage_title": "Зберігання вкладень",
|
|
||||||
"account_usage_attachment_storage_description": "{{filesize}} на файл, видаляється після {{expiry}}",
|
|
||||||
"account_usage_basis_ip_description": "Статистика використання та ліміти для цього облікового запису базуються на вашій IP-адресі, тому вони можуть бути доступні іншим користувачам. Ліміти, показані вище, є приблизними і базуються на існуючих лімітах тарифів.",
|
|
||||||
"account_usage_cannot_create_portal_session": "Не вдається відкрити білінговий портал",
|
|
||||||
"account_delete_title": "Видалення облікового запису",
|
|
||||||
"account_delete_description": "Назавжди видалити свій обліковий запис",
|
|
||||||
"account_delete_dialog_label": "Пароль",
|
|
||||||
"account_delete_dialog_button_cancel": "Скасувати",
|
|
||||||
"account_delete_dialog_button_submit": "Видалити обліковий запис назавжди",
|
|
||||||
"account_delete_dialog_billing_warning": "Видалення облікового запису також негайно скасовує вашу підписку. Ви більше не матимете доступу до білінгової панелі.",
|
|
||||||
"account_upgrade_dialog_title": "Зміна рівня облікового запису",
|
|
||||||
"account_upgrade_dialog_interval_monthly": "Щомісяця",
|
|
||||||
"account_upgrade_dialog_interval_yearly": "Щорічно",
|
|
||||||
"account_upgrade_dialog_interval_yearly_discount_save": "економія {{discount}}%",
|
|
||||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "економія до {{discount}}%",
|
|
||||||
"publish_dialog_call_label": "Телефонний дзвінок",
|
|
||||||
"publish_dialog_call_placeholder": "Номер телефону, на який потрібно зателефонувати з повідомленням, наприклад, +12223334444 або \"yes\"",
|
|
||||||
"publish_dialog_chip_call_label": "Телефонний дзвінок",
|
|
||||||
"publish_dialog_call_reset": "Видалити телефонний дзвінок",
|
|
||||||
"account_basics_phone_numbers_dialog_description": "Щоб користуватися функцією сповіщення про дзвінки, потрібно додати та верифікувати принаймні один телефонний номер. Верифікацію можна здійснити за допомогою SMS або телефонного дзвінка.",
|
|
||||||
"account_delete_dialog_description": "Це призведе до остаточного видалення вашого облікового запису, включаючи всі дані, які зберігаються на сервері. Після видалення ваше ім'я користувача буде недоступне протягом 7 днів. Якщо ви дійсно хочете продовжити, будь ласка, підтвердьте свій пароль у полі нижче.",
|
|
||||||
"account_basics_tier_upgrade_button": "Оновлення до Pro",
|
|
||||||
"account_basics_password_description": "Зміна пароля облікового запису",
|
|
||||||
"account_usage_of_limit": "з {{limit}}",
|
|
||||||
"account_usage_unlimited": "Без обмежень",
|
|
||||||
"account_basics_tier_description": "Рівень потужності вашого облікового запису",
|
|
||||||
"account_basics_tier_admin_suffix_with_tier": "(з рівнем {{tier}})",
|
|
||||||
"account_basics_tier_admin_suffix_no_tier": "(без рівня)",
|
|
||||||
"account_basics_tier_basic": "Базовий",
|
|
||||||
"account_basics_tier_free": "Безкоштовний",
|
|
||||||
"account_basics_tier_change_button": "Змінити",
|
|
||||||
"account_basics_tier_paid_until": "Підписка оплачена до {{date}} і буде автоматично поновлюватися",
|
|
||||||
"account_basics_tier_payment_overdue": "Ваш платіж прострочено. Будь ласка, оновіть спосіб оплати, інакше ваш обліковий запис буде знижено до нижчого рівня.",
|
|
||||||
"account_basics_tier_canceled_subscription": "Вашу підписку було скасовано, і з {{date}} вона буде знижена до безкоштовного акаунта.",
|
|
||||||
"account_basics_tier_manage_billing_button": "Керувати рахунками",
|
|
||||||
"account_usage_messages_title": "Опубліковані повідомлення",
|
|
||||||
"account_usage_emails_title": "Надіслані електронні листи",
|
|
||||||
"account_usage_reservations_title": "Зарезервовані теми",
|
|
||||||
"account_usage_reservations_none": "Для цього облікового запису немає зарезервованих тем",
|
|
||||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} на файл",
|
|
||||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} загальне сховище",
|
|
||||||
"account_upgrade_dialog_tier_current_label": "Поточний",
|
|
||||||
"account_upgrade_dialog_tier_selected_label": "Вибране",
|
|
||||||
"account_upgrade_dialog_cancel_warning": "Це <strong> скасує вашу підписку</strong> і знизить версію вашого облікового запису {{date}}. У цю дату резервування тем, а також повідомлення, кешовані на сервері <strong>, буде видалено</strong>.",
|
|
||||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} зарезервовані теми",
|
|
||||||
"account_upgrade_dialog_tier_features_no_reservations": "Немає зарезервованих тем",
|
|
||||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} повідомлень в день",
|
|
||||||
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} електронний лист в день",
|
|
||||||
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} електронних листів в день",
|
|
||||||
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} телефонний дзвінок в день",
|
|
||||||
"account_upgrade_dialog_tier_features_calls_other": "{{дзвінки}} телефонних дзвінків в день",
|
|
||||||
"account_upgrade_dialog_tier_features_no_calls": "Без телефонних дзвінків",
|
|
||||||
"account_upgrade_dialog_tier_price_per_month": "місяць",
|
|
||||||
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} на рік. Рахунок виставляється щомісяця.",
|
|
||||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} виставляється щорічно. Збережіть {{save}}.",
|
|
||||||
"account_upgrade_dialog_billing_contact_email": "Якщо у вас виникли запитання щодо оплати, <Link>зв’яжіться з нами</Link> безпосередньо.",
|
|
||||||
"account_upgrade_dialog_billing_contact_website": "Якщо у вас виникли запитання щодо оплати, відвідайте наш <Link>веб-сайт</Link>.",
|
|
||||||
"account_upgrade_dialog_button_cancel_subscription": "Скасувати підписку",
|
|
||||||
"account_upgrade_dialog_button_update_subscription": "Оновити підписку",
|
|
||||||
"account_tokens_title": "Токени доступу",
|
|
||||||
"account_tokens_table_expires_header": "Термін дії закінчується",
|
|
||||||
"account_tokens_description": "Використовуйте токени доступу при публікації та підписці через ntfy API, щоб не надсилати свої облікові дані. Ознайомтеся з <Link>документацією</Link>, щоб дізнатися більше.",
|
|
||||||
"account_tokens_table_token_header": "Токен",
|
|
||||||
"account_tokens_table_never_expires": "Ніколи не закінчується",
|
|
||||||
"account_tokens_table_label_header": "Мітка",
|
|
||||||
"account_tokens_table_current_session": "Поточний сеанс браузера",
|
|
||||||
"account_tokens_table_last_access_header": "Останній доступ",
|
|
||||||
"account_tokens_table_copied_to_clipboard": "Токен доступу скопійовано",
|
|
||||||
"account_tokens_table_cannot_delete_or_edit": "Неможливо редагувати або видалити токен поточного сеансу",
|
|
||||||
"account_tokens_table_create_token_button": "Створити токен доступу",
|
|
||||||
"account_tokens_table_last_origin_tooltip": "З IP-адреси {{ip}} натисніть для пошуку",
|
|
||||||
"account_tokens_dialog_title_create": "Створити токен доступу",
|
|
||||||
"account_tokens_dialog_button_cancel": "Скасувати",
|
|
||||||
"account_tokens_dialog_title_edit": "Редагувати токен доступу",
|
|
||||||
"account_tokens_dialog_title_delete": "Видалити токен доступу",
|
|
||||||
"account_tokens_dialog_label": "Мітка, наприклад, сповіщення Radarr",
|
|
||||||
"account_tokens_dialog_button_create": "Створити токен",
|
|
||||||
"account_tokens_dialog_button_update": "Оновити токен",
|
|
||||||
"account_tokens_dialog_expires_label": "Термін дії токену доступу закінчується через",
|
|
||||||
"account_tokens_dialog_expires_x_hours": "Термін дії токена закінчується через {{hours}} годин",
|
|
||||||
"account_tokens_dialog_expires_x_days": "Термін дії токена закінчується через {{days}} днів",
|
|
||||||
"account_tokens_delete_dialog_description": "Перш ніж видалити токен доступу, переконайтеся, що жодна програма або скрипт не використовує його. <strong>Ця дія не може бути скасована</strong>.",
|
|
||||||
"prefs_users_description_no_sync": "Користувачі та паролі не синхронізуються з вашим акаунтом.",
|
|
||||||
"prefs_users_table_cannot_delete_or_edit": "Неможливо видалити або відредагувати користувача, який увійшов у систему",
|
|
||||||
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} зарезервована тема",
|
|
||||||
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} повідомлення в день",
|
|
||||||
"account_tokens_dialog_expires_unchanged": "Залишити термін придатності без змін",
|
|
||||||
"account_tokens_dialog_expires_never": "Термін дії токена ніколи не закінчується",
|
|
||||||
"account_tokens_delete_dialog_title": "Видалити токен доступу",
|
|
||||||
"account_tokens_delete_dialog_submit_button": "Видалити токен назавжди",
|
|
||||||
"account_upgrade_dialog_proration_info": "<strong>Пропорція</strong>: При переході з одного тарифного плану на інший різниця в ціні буде <strong>списана негайно</strong>. При переході на нижчий рівень залишок коштів буде використано для оплати майбутніх розрахункових періодів.",
|
|
||||||
"account_upgrade_dialog_reservations_warning_one": "Обраний рівень дозволяє менше зарезервованих тем, ніж ваш поточний рівень. Перш ніж змінити свій рівень, <strong>будь ласка, видаліть принаймні одне резервування</strong>. Ви можете видалити резервування в <Link>Налаштуваннях</Link>.",
|
|
||||||
"account_upgrade_dialog_reservations_warning_other": "Обраний рівень дозволяє менше зарезервованих тем, ніж ваш поточний рівень. Перш ніж змінити свій рівень, <strong>будь ласка, видаліть принаймні {{count}} резервувань</strong>. Ви можете видалити резервування в <Link>Налаштуваннях</Link>.",
|
|
||||||
"account_upgrade_dialog_button_cancel": "Скасувати",
|
|
||||||
"account_upgrade_dialog_button_redirect_signup": "Зареєструватися зараз",
|
|
||||||
"account_upgrade_dialog_button_pay_now": "Оплатити зараз і підписатися",
|
|
||||||
"prefs_reservations_add_button": "Додати зарезервовану тему",
|
|
||||||
"prefs_reservations_edit_button": "Редагувати доступ до теми",
|
|
||||||
"prefs_reservations_limit_reached": "Ви досягли ліміту зарезервованих тем.",
|
|
||||||
"prefs_reservations_table_click_to_subscribe": "Натисніть, щоб підписатися",
|
|
||||||
"prefs_reservations_table_topic_header": "Тема",
|
|
||||||
"prefs_reservations_description": "Тут ви можете зарезервувати назви тем для особистого користування. Резервування теми дає вам право власності на тему і дозволяє визначати права доступу до неї інших користувачів.",
|
|
||||||
"prefs_reservations_table": "Таблиця зарезервованих тем",
|
|
||||||
"prefs_reservations_table_access_header": "Доступ",
|
|
||||||
"prefs_reservations_table_everyone_deny_all": "Тільки я можу публікувати та підписуватись",
|
|
||||||
"prefs_reservations_table_everyone_read_only": "Я можу публікувати та підписуватись, кожен може підписатися",
|
|
||||||
"prefs_reservations_table_everyone_write_only": "Я можу публікувати і підписуватися, кожен може публікувати",
|
|
||||||
"prefs_reservations_table_everyone_read_write": "Кожен може публікувати та підписуватися",
|
|
||||||
"prefs_reservations_table_not_subscribed": "Не підписаний",
|
|
||||||
"prefs_reservations_dialog_title_add": "Зарезервувати тему",
|
|
||||||
"prefs_reservations_dialog_title_edit": "Редагувати зарезервовану тему",
|
|
||||||
"prefs_reservations_title": "Зарезервовані теми",
|
|
||||||
"prefs_reservations_delete_button": "Скинути доступ до теми",
|
|
||||||
"prefs_reservations_dialog_description": "Резервування теми дає вам право власності на цю тему і дозволяє визначати права доступу до неї інших користувачів.",
|
|
||||||
"prefs_reservations_dialog_topic_label": "Тема",
|
|
||||||
"prefs_reservations_dialog_access_label": "Доступ",
|
|
||||||
"reservation_delete_dialog_description": "Видалення резервування позбавляє вас права власності на тему і дозволяє іншим зарезервувати її. Ви можете зберегти або видалити існуючі повідомлення і вкладення.",
|
|
||||||
"reservation_delete_dialog_submit_button": "Видалити резервування",
|
|
||||||
"publish_dialog_call_item": "Телефонувати за номером {{номер}}",
|
|
||||||
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Немає підтверджених номерів телефонів",
|
|
||||||
"prefs_reservations_dialog_title_delete": "Видалити резервування теми",
|
|
||||||
"reservation_delete_dialog_action_delete_title": "Видалення кешованих повідомлень і вкладень",
|
|
||||||
"reservation_delete_dialog_action_keep_title": "Збереження кешованих повідомлень і вкладень",
|
|
||||||
"reservation_delete_dialog_action_keep_description": "Повідомлення і вкладення, які кешуються на сервері, стають загальнодоступними для людей, які знають назву теми.",
|
|
||||||
"reservation_delete_dialog_action_delete_description": "Кешовані повідомлення та вкладення будуть видалені назавжди. Ця дія не може бути скасована."
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -103,7 +103,7 @@
|
||||||
"subscribe_dialog_login_description": "本主题受密码保护,请输入用户名和密码进行订阅。",
|
"subscribe_dialog_login_description": "本主题受密码保护,请输入用户名和密码进行订阅。",
|
||||||
"subscribe_dialog_login_username_label": "用户名,例如 phil",
|
"subscribe_dialog_login_username_label": "用户名,例如 phil",
|
||||||
"subscribe_dialog_login_password_label": "密码",
|
"subscribe_dialog_login_password_label": "密码",
|
||||||
"common_back": "返回",
|
"subscribe_dialog_login_button_back": "返回",
|
||||||
"subscribe_dialog_login_button_login": "登录",
|
"subscribe_dialog_login_button_login": "登录",
|
||||||
"subscribe_dialog_error_user_not_authorized": "未授权 {{username}} 用户",
|
"subscribe_dialog_error_user_not_authorized": "未授权 {{username}} 用户",
|
||||||
"subscribe_dialog_error_user_anonymous": "匿名",
|
"subscribe_dialog_error_user_anonymous": "匿名",
|
||||||
|
@ -333,7 +333,7 @@
|
||||||
"account_tokens_table_expires_header": "过期",
|
"account_tokens_table_expires_header": "过期",
|
||||||
"account_tokens_table_never_expires": "永不过期",
|
"account_tokens_table_never_expires": "永不过期",
|
||||||
"account_tokens_table_current_session": "当前浏览器会话",
|
"account_tokens_table_current_session": "当前浏览器会话",
|
||||||
"common_copy_to_clipboard": "复制到剪贴板",
|
"account_tokens_table_copy_to_clipboard": "复制到剪贴板",
|
||||||
"account_tokens_table_copied_to_clipboard": "已复制访问令牌",
|
"account_tokens_table_copied_to_clipboard": "已复制访问令牌",
|
||||||
"account_tokens_table_cannot_delete_or_edit": "无法编辑或删除当前会话令牌",
|
"account_tokens_table_cannot_delete_or_edit": "无法编辑或删除当前会话令牌",
|
||||||
"account_tokens_table_create_token_button": "创建访问令牌",
|
"account_tokens_table_create_token_button": "创建访问令牌",
|
||||||
|
|
|
@ -70,7 +70,7 @@
|
||||||
"subscribe_dialog_subscribe_button_subscribe": "訂閱",
|
"subscribe_dialog_subscribe_button_subscribe": "訂閱",
|
||||||
"emoji_picker_search_clear": "清除",
|
"emoji_picker_search_clear": "清除",
|
||||||
"subscribe_dialog_login_password_label": "密碼",
|
"subscribe_dialog_login_password_label": "密碼",
|
||||||
"common_back": "返回",
|
"subscribe_dialog_login_button_back": "返回",
|
||||||
"subscribe_dialog_login_button_login": "登入",
|
"subscribe_dialog_login_button_login": "登入",
|
||||||
"prefs_notifications_delete_after_never": "從不",
|
"prefs_notifications_delete_after_never": "從不",
|
||||||
"prefs_users_add_button": "新增使用者",
|
"prefs_users_add_button": "新增使用者",
|
||||||
|
|
|
@ -1,430 +1,389 @@
|
||||||
import i18n from "i18next";
|
|
||||||
import {
|
import {
|
||||||
accountBillingPortalUrl,
|
accountBillingPortalUrl,
|
||||||
accountBillingSubscriptionUrl,
|
accountBillingSubscriptionUrl,
|
||||||
accountPasswordUrl,
|
accountPasswordUrl,
|
||||||
accountPhoneUrl,
|
accountReservationSingleUrl,
|
||||||
accountPhoneVerifyUrl,
|
accountReservationUrl,
|
||||||
accountReservationSingleUrl,
|
accountSettingsUrl,
|
||||||
accountReservationUrl,
|
accountSubscriptionSingleUrl,
|
||||||
accountSettingsUrl,
|
accountSubscriptionUrl,
|
||||||
accountSubscriptionUrl,
|
accountTokenUrl,
|
||||||
accountTokenUrl,
|
accountUrl, maybeWithBearerAuth,
|
||||||
accountUrl,
|
tiersUrl,
|
||||||
maybeWithBearerAuth,
|
withBasicAuth,
|
||||||
tiersUrl,
|
withBearerAuth
|
||||||
withBasicAuth,
|
|
||||||
withBearerAuth,
|
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
import session from "./Session";
|
import session from "./Session";
|
||||||
import subscriptionManager from "./SubscriptionManager";
|
import subscriptionManager from "./SubscriptionManager";
|
||||||
|
import i18n from "i18next";
|
||||||
import prefs from "./Prefs";
|
import prefs from "./Prefs";
|
||||||
import routes from "../components/routes";
|
import routes from "../components/routes";
|
||||||
import { fetchOrThrow, UnauthorizedError } from "./errors";
|
import {fetchOrThrow, throwAppError, UnauthorizedError} from "./errors";
|
||||||
|
|
||||||
const delayMillis = 45000; // 45 seconds
|
const delayMillis = 45000; // 45 seconds
|
||||||
const intervalMillis = 900000; // 15 minutes
|
const intervalMillis = 900000; // 15 minutes
|
||||||
|
|
||||||
class AccountApi {
|
class AccountApi {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
this.listener = null; // Fired when account is fetched from remote
|
this.listener = null; // Fired when account is fetched from remote
|
||||||
this.tiers = null; // Cached
|
this.tiers = null; // Cached
|
||||||
}
|
|
||||||
|
|
||||||
registerListener(listener) {
|
|
||||||
this.listener = listener;
|
|
||||||
}
|
|
||||||
|
|
||||||
resetListener() {
|
|
||||||
this.listener = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async login(user) {
|
|
||||||
const url = accountTokenUrl(config.base_url);
|
|
||||||
console.log(`[AccountApi] Checking auth for ${url}`);
|
|
||||||
const response = await fetchOrThrow(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: withBasicAuth({}, user.username, user.password),
|
|
||||||
});
|
|
||||||
const json = await response.json(); // May throw SyntaxError
|
|
||||||
if (!json.token) {
|
|
||||||
throw new Error(`Unexpected server response: Cannot find token`);
|
|
||||||
}
|
}
|
||||||
return json.token;
|
|
||||||
}
|
|
||||||
|
|
||||||
async logout() {
|
registerListener(listener) {
|
||||||
const url = accountTokenUrl(config.base_url);
|
this.listener = listener;
|
||||||
console.log(`[AccountApi] Logging out from ${url} using token ${session.token()}`);
|
|
||||||
await fetchOrThrow(url, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: withBearerAuth({}, session.token()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(username, password) {
|
|
||||||
const url = accountUrl(config.base_url);
|
|
||||||
const body = JSON.stringify({
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
console.log(`[AccountApi] Creating user account ${url}`);
|
|
||||||
await fetchOrThrow(url, {
|
|
||||||
method: "POST",
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async get() {
|
|
||||||
const url = accountUrl(config.base_url);
|
|
||||||
console.log(`[AccountApi] Fetching user account ${url}`);
|
|
||||||
const response = await fetchOrThrow(url, {
|
|
||||||
headers: maybeWithBearerAuth({}, session.token()), // GET /v1/account endpoint can be called by anonymous
|
|
||||||
});
|
|
||||||
const account = await response.json(); // May throw SyntaxError
|
|
||||||
console.log(`[AccountApi] Account`, account);
|
|
||||||
if (this.listener) {
|
|
||||||
this.listener(account);
|
|
||||||
}
|
}
|
||||||
return account;
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(password) {
|
resetListener() {
|
||||||
const url = accountUrl(config.base_url);
|
this.listener = null;
|
||||||
console.log(`[AccountApi] Deleting user account ${url}`);
|
|
||||||
await fetchOrThrow(url, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: withBearerAuth({}, session.token()),
|
|
||||||
body: JSON.stringify({
|
|
||||||
password,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async changePassword(currentPassword, newPassword) {
|
|
||||||
const url = accountPasswordUrl(config.base_url);
|
|
||||||
console.log(`[AccountApi] Changing account password ${url}`);
|
|
||||||
await fetchOrThrow(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: withBearerAuth({}, session.token()),
|
|
||||||
body: JSON.stringify({
|
|
||||||
password: currentPassword,
|
|
||||||
new_password: newPassword,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async createToken(label, expires) {
|
|
||||||
const url = accountTokenUrl(config.base_url);
|
|
||||||
const body = {
|
|
||||||
label,
|
|
||||||
expires: expires > 0 ? Math.floor(Date.now() / 1000) + expires : 0,
|
|
||||||
};
|
|
||||||
console.log(`[AccountApi] Creating user access token ${url}`);
|
|
||||||
await fetchOrThrow(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: withBearerAuth({}, session.token()),
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateToken(token, label, expires) {
|
|
||||||
const url = accountTokenUrl(config.base_url);
|
|
||||||
const body = {
|
|
||||||
token,
|
|
||||||
label,
|
|
||||||
};
|
|
||||||
if (expires > 0) {
|
|
||||||
body.expires = Math.floor(Date.now() / 1000) + expires;
|
|
||||||
}
|
}
|
||||||
console.log(`[AccountApi] Creating user access token ${url}`);
|
|
||||||
await fetchOrThrow(url, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: withBearerAuth({}, session.token()),
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async extendToken() {
|
async login(user) {
|
||||||
const url = accountTokenUrl(config.base_url);
|
const url = accountTokenUrl(config.base_url);
|
||||||
console.log(`[AccountApi] Extending user access token ${url}`);
|
console.log(`[AccountApi] Checking auth for ${url}`);
|
||||||
await fetchOrThrow(url, {
|
const response = await fetchOrThrow(url, {
|
||||||
method: "PATCH",
|
method: "POST",
|
||||||
headers: withBearerAuth({}, session.token()),
|
headers: withBasicAuth({}, user.username, user.password)
|
||||||
});
|
});
|
||||||
}
|
const json = await response.json(); // May throw SyntaxError
|
||||||
|
if (!json.token) {
|
||||||
async deleteToken(token) {
|
throw new Error(`Unexpected server response: Cannot find token`);
|
||||||
const url = accountTokenUrl(config.base_url);
|
|
||||||
console.log(`[AccountApi] Deleting user access token ${url}`);
|
|
||||||
await fetchOrThrow(url, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: withBearerAuth({ "X-Token": token }, session.token()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateSettings(payload) {
|
|
||||||
const url = accountSettingsUrl(config.base_url);
|
|
||||||
const body = JSON.stringify(payload);
|
|
||||||
console.log(`[AccountApi] Updating user account ${url}: ${body}`);
|
|
||||||
await fetchOrThrow(url, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: withBearerAuth({}, session.token()),
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async addSubscription(baseUrl, topic) {
|
|
||||||
const url = accountSubscriptionUrl(config.base_url);
|
|
||||||
const body = JSON.stringify({
|
|
||||||
base_url: baseUrl,
|
|
||||||
topic,
|
|
||||||
});
|
|
||||||
console.log(`[AccountApi] Adding user subscription ${url}: ${body}`);
|
|
||||||
const response = await fetchOrThrow(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: withBearerAuth({}, session.token()),
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
const subscription = await response.json(); // May throw SyntaxError
|
|
||||||
console.log(`[AccountApi] Subscription`, subscription);
|
|
||||||
return subscription;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateSubscription(baseUrl, topic, payload) {
|
|
||||||
const url = accountSubscriptionUrl(config.base_url);
|
|
||||||
const body = JSON.stringify({
|
|
||||||
base_url: baseUrl,
|
|
||||||
topic,
|
|
||||||
...payload,
|
|
||||||
});
|
|
||||||
console.log(`[AccountApi] Updating user subscription ${url}: ${body}`);
|
|
||||||
const response = await fetchOrThrow(url, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: withBearerAuth({}, session.token()),
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
const subscription = await response.json(); // May throw SyntaxError
|
|
||||||
console.log(`[AccountApi] Subscription`, subscription);
|
|
||||||
return subscription;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteSubscription(baseUrl, topic) {
|
|
||||||
const url = accountSubscriptionUrl(config.base_url);
|
|
||||||
console.log(`[AccountApi] Removing user subscription ${url}`);
|
|
||||||
const headers = {
|
|
||||||
"X-BaseURL": baseUrl,
|
|
||||||
"X-Topic": topic,
|
|
||||||
};
|
|
||||||
await fetchOrThrow(url, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: withBearerAuth(headers, session.token()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async upsertReservation(topic, everyone) {
|
|
||||||
const url = accountReservationUrl(config.base_url);
|
|
||||||
console.log(`[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}`);
|
|
||||||
await fetchOrThrow(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: withBearerAuth({}, session.token()),
|
|
||||||
body: JSON.stringify({
|
|
||||||
topic,
|
|
||||||
everyone,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteReservation(topic, deleteMessages) {
|
|
||||||
const url = accountReservationSingleUrl(config.base_url, topic);
|
|
||||||
console.log(`[AccountApi] Removing topic reservation ${url}`);
|
|
||||||
const headers = {
|
|
||||||
"X-Delete-Messages": deleteMessages ? "true" : "false",
|
|
||||||
};
|
|
||||||
await fetchOrThrow(url, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: withBearerAuth(headers, session.token()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async billingTiers() {
|
|
||||||
if (this.tiers) {
|
|
||||||
return this.tiers;
|
|
||||||
}
|
|
||||||
const url = tiersUrl(config.base_url);
|
|
||||||
console.log(`[AccountApi] Fetching billing tiers`);
|
|
||||||
const response = await fetchOrThrow(url); // No auth needed!
|
|
||||||
this.tiers = await response.json(); // May throw SyntaxError
|
|
||||||
return this.tiers;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createBillingSubscription(tier, interval) {
|
|
||||||
console.log(`[AccountApi] Creating billing subscription with ${tier} and interval ${interval}`);
|
|
||||||
return this.upsertBillingSubscription("POST", tier, interval);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateBillingSubscription(tier, interval) {
|
|
||||||
console.log(`[AccountApi] Updating billing subscription with ${tier} and interval ${interval}`);
|
|
||||||
return this.upsertBillingSubscription("PUT", tier, interval);
|
|
||||||
}
|
|
||||||
|
|
||||||
async upsertBillingSubscription(method, tier, interval) {
|
|
||||||
const url = accountBillingSubscriptionUrl(config.base_url);
|
|
||||||
const response = await fetchOrThrow(url, {
|
|
||||||
method,
|
|
||||||
headers: withBearerAuth({}, session.token()),
|
|
||||||
body: JSON.stringify({
|
|
||||||
tier,
|
|
||||||
interval,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
return response.json(); // May throw SyntaxError
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteBillingSubscription() {
|
|
||||||
const url = accountBillingSubscriptionUrl(config.base_url);
|
|
||||||
console.log(`[AccountApi] Cancelling billing subscription`);
|
|
||||||
await fetchOrThrow(url, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: withBearerAuth({}, session.token()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async createBillingPortalSession() {
|
|
||||||
const url = accountBillingPortalUrl(config.base_url);
|
|
||||||
console.log(`[AccountApi] Creating billing portal session`);
|
|
||||||
const response = await fetchOrThrow(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: withBearerAuth({}, session.token()),
|
|
||||||
});
|
|
||||||
return response.json(); // May throw SyntaxError
|
|
||||||
}
|
|
||||||
|
|
||||||
async verifyPhoneNumber(phoneNumber, channel) {
|
|
||||||
const url = accountPhoneVerifyUrl(config.base_url);
|
|
||||||
console.log(`[AccountApi] Sending phone verification ${url}`);
|
|
||||||
await fetchOrThrow(url, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: withBearerAuth({}, session.token()),
|
|
||||||
body: JSON.stringify({
|
|
||||||
number: phoneNumber,
|
|
||||||
channel,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async addPhoneNumber(phoneNumber, code) {
|
|
||||||
const url = accountPhoneUrl(config.base_url);
|
|
||||||
console.log(`[AccountApi] Adding phone number with verification code ${url}`);
|
|
||||||
await fetchOrThrow(url, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: withBearerAuth({}, session.token()),
|
|
||||||
body: JSON.stringify({
|
|
||||||
number: phoneNumber,
|
|
||||||
code,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async deletePhoneNumber(phoneNumber) {
|
|
||||||
const url = accountPhoneUrl(config.base_url);
|
|
||||||
console.log(`[AccountApi] Deleting phone number ${url}`);
|
|
||||||
await fetchOrThrow(url, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: withBearerAuth({}, session.token()),
|
|
||||||
body: JSON.stringify({
|
|
||||||
number: phoneNumber,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async sync() {
|
|
||||||
try {
|
|
||||||
if (!session.token()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
console.log(`[AccountApi] Syncing account`);
|
|
||||||
const account = await this.get();
|
|
||||||
if (account.language) {
|
|
||||||
await i18n.changeLanguage(account.language);
|
|
||||||
}
|
|
||||||
if (account.notification) {
|
|
||||||
if (account.notification.sound) {
|
|
||||||
await prefs.setSound(account.notification.sound);
|
|
||||||
}
|
}
|
||||||
if (account.notification.delete_after) {
|
return json.token;
|
||||||
await prefs.setDeleteAfter(account.notification.delete_after);
|
|
||||||
}
|
|
||||||
if (account.notification.min_priority) {
|
|
||||||
await prefs.setMinPriority(account.notification.min_priority);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (account.subscriptions) {
|
|
||||||
await subscriptionManager.syncFromRemote(account.subscriptions, account.reservations);
|
|
||||||
}
|
|
||||||
return account;
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`[AccountApi] Error fetching account`, e);
|
|
||||||
if (e instanceof UnauthorizedError) {
|
|
||||||
session.resetAndRedirect(routes.login);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
startWorker() {
|
async logout() {
|
||||||
if (this.timer !== null) {
|
const url = accountTokenUrl(config.base_url);
|
||||||
return;
|
console.log(`[AccountApi] Logging out from ${url} using token ${session.token()}`);
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: withBearerAuth({}, session.token())
|
||||||
|
});
|
||||||
}
|
}
|
||||||
console.log(`[AccountApi] Starting worker`);
|
|
||||||
this.timer = setInterval(() => this.runWorker(), intervalMillis);
|
|
||||||
setTimeout(() => this.runWorker(), delayMillis);
|
|
||||||
}
|
|
||||||
|
|
||||||
async runWorker() {
|
async create(username, password) {
|
||||||
if (!session.token()) {
|
const url = accountUrl(config.base_url);
|
||||||
return;
|
const body = JSON.stringify({
|
||||||
|
username: username,
|
||||||
|
password: password
|
||||||
|
});
|
||||||
|
console.log(`[AccountApi] Creating user account ${url}`);
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "POST",
|
||||||
|
body: body
|
||||||
|
});
|
||||||
}
|
}
|
||||||
console.log(`[AccountApi] Extending user access token`);
|
|
||||||
try {
|
async get() {
|
||||||
await this.extendToken();
|
const url = accountUrl(config.base_url);
|
||||||
} catch (e) {
|
console.log(`[AccountApi] Fetching user account ${url}`);
|
||||||
console.log(`[AccountApi] Error extending user access token`, e);
|
const response = await fetchOrThrow(url, {
|
||||||
|
headers: maybeWithBearerAuth({}, session.token()) // GET /v1/account endpoint can be called by anonymous
|
||||||
|
});
|
||||||
|
const account = await response.json(); // May throw SyntaxError
|
||||||
|
console.log(`[AccountApi] Account`, account);
|
||||||
|
if (this.listener) {
|
||||||
|
this.listener(account);
|
||||||
|
}
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(password) {
|
||||||
|
const url = accountUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Deleting user account ${url}`);
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: JSON.stringify({
|
||||||
|
password: password
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async changePassword(currentPassword, newPassword) {
|
||||||
|
const url = accountPasswordUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Changing account password ${url}`);
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: JSON.stringify({
|
||||||
|
password: currentPassword,
|
||||||
|
new_password: newPassword
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createToken(label, expires) {
|
||||||
|
const url = accountTokenUrl(config.base_url);
|
||||||
|
const body = {
|
||||||
|
label: label,
|
||||||
|
expires: (expires > 0) ? Math.floor(Date.now() / 1000) + expires : 0
|
||||||
|
};
|
||||||
|
console.log(`[AccountApi] Creating user access token ${url}`);
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateToken(token, label, expires) {
|
||||||
|
const url = accountTokenUrl(config.base_url);
|
||||||
|
const body = {
|
||||||
|
token: token,
|
||||||
|
label: label
|
||||||
|
};
|
||||||
|
if (expires > 0) {
|
||||||
|
body.expires = Math.floor(Date.now() / 1000) + expires;
|
||||||
|
}
|
||||||
|
console.log(`[AccountApi] Creating user access token ${url}`);
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async extendToken() {
|
||||||
|
const url = accountTokenUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Extending user access token ${url}`);
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: withBearerAuth({}, session.token())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteToken(token) {
|
||||||
|
const url = accountTokenUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Deleting user access token ${url}`);
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: withBearerAuth({"X-Token": token}, session.token())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSettings(payload) {
|
||||||
|
const url = accountSettingsUrl(config.base_url);
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
console.log(`[AccountApi] Updating user account ${url}: ${body}`);
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: body
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addSubscription(baseUrl, topic) {
|
||||||
|
const url = accountSubscriptionUrl(config.base_url);
|
||||||
|
const body = JSON.stringify({
|
||||||
|
base_url: baseUrl,
|
||||||
|
topic: topic
|
||||||
|
});
|
||||||
|
console.log(`[AccountApi] Adding user subscription ${url}: ${body}`);
|
||||||
|
const response = await fetchOrThrow(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: body
|
||||||
|
});
|
||||||
|
const subscription = await response.json(); // May throw SyntaxError
|
||||||
|
console.log(`[AccountApi] Subscription`, subscription);
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSubscription(baseUrl, topic, payload) {
|
||||||
|
const url = accountSubscriptionUrl(config.base_url);
|
||||||
|
const body = JSON.stringify({
|
||||||
|
base_url: baseUrl,
|
||||||
|
topic: topic,
|
||||||
|
...payload
|
||||||
|
});
|
||||||
|
console.log(`[AccountApi] Updating user subscription ${url}: ${body}`);
|
||||||
|
const response = await fetchOrThrow(url, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: body
|
||||||
|
});
|
||||||
|
const subscription = await response.json(); // May throw SyntaxError
|
||||||
|
console.log(`[AccountApi] Subscription`, subscription);
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSubscription(baseUrl, topic) {
|
||||||
|
const url = accountSubscriptionUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Removing user subscription ${url}`);
|
||||||
|
const headers = {
|
||||||
|
"X-BaseURL": baseUrl,
|
||||||
|
"X-Topic": topic,
|
||||||
|
}
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: withBearerAuth(headers, session.token()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertReservation(topic, everyone) {
|
||||||
|
const url = accountReservationUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}`);
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: JSON.stringify({
|
||||||
|
topic: topic,
|
||||||
|
everyone: everyone
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteReservation(topic, deleteMessages) {
|
||||||
|
const url = accountReservationSingleUrl(config.base_url, topic);
|
||||||
|
console.log(`[AccountApi] Removing topic reservation ${url}`);
|
||||||
|
const headers = {
|
||||||
|
"X-Delete-Messages": deleteMessages ? "true" : "false"
|
||||||
|
}
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: withBearerAuth(headers, session.token())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async billingTiers() {
|
||||||
|
if (this.tiers) {
|
||||||
|
return this.tiers;
|
||||||
|
}
|
||||||
|
const url = tiersUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Fetching billing tiers`);
|
||||||
|
const response = await fetchOrThrow(url); // No auth needed!
|
||||||
|
this.tiers = await response.json(); // May throw SyntaxError
|
||||||
|
return this.tiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBillingSubscription(tier, interval) {
|
||||||
|
console.log(`[AccountApi] Creating billing subscription with ${tier} and interval ${interval}`);
|
||||||
|
return await this.upsertBillingSubscription("POST", tier, interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBillingSubscription(tier, interval) {
|
||||||
|
console.log(`[AccountApi] Updating billing subscription with ${tier} and interval ${interval}`);
|
||||||
|
return await this.upsertBillingSubscription("PUT", tier, interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertBillingSubscription(method, tier, interval) {
|
||||||
|
const url = accountBillingSubscriptionUrl(config.base_url);
|
||||||
|
const response = await fetchOrThrow(url, {
|
||||||
|
method: method,
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: JSON.stringify({
|
||||||
|
tier: tier,
|
||||||
|
interval: interval
|
||||||
|
})
|
||||||
|
});
|
||||||
|
return await response.json(); // May throw SyntaxError
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBillingSubscription() {
|
||||||
|
const url = accountBillingSubscriptionUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Cancelling billing subscription`);
|
||||||
|
await fetchOrThrow(url, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: withBearerAuth({}, session.token())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBillingPortalSession() {
|
||||||
|
const url = accountBillingPortalUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Creating billing portal session`);
|
||||||
|
const response = await fetchOrThrow(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: withBearerAuth({}, session.token())
|
||||||
|
});
|
||||||
|
return await response.json(); // May throw SyntaxError
|
||||||
|
}
|
||||||
|
|
||||||
|
async sync() {
|
||||||
|
try {
|
||||||
|
if (!session.token()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
console.log(`[AccountApi] Syncing account`);
|
||||||
|
const account = await this.get();
|
||||||
|
if (account.language) {
|
||||||
|
await i18n.changeLanguage(account.language);
|
||||||
|
}
|
||||||
|
if (account.notification) {
|
||||||
|
if (account.notification.sound) {
|
||||||
|
await prefs.setSound(account.notification.sound);
|
||||||
|
}
|
||||||
|
if (account.notification.delete_after) {
|
||||||
|
await prefs.setDeleteAfter(account.notification.delete_after);
|
||||||
|
}
|
||||||
|
if (account.notification.min_priority) {
|
||||||
|
await prefs.setMinPriority(account.notification.min_priority);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (account.subscriptions) {
|
||||||
|
await subscriptionManager.syncFromRemote(account.subscriptions, account.reservations);
|
||||||
|
}
|
||||||
|
return account;
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[AccountApi] Error fetching account`, e);
|
||||||
|
if (e instanceof UnauthorizedError) {
|
||||||
|
session.resetAndRedirect(routes.login);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startWorker() {
|
||||||
|
if (this.timer !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`[AccountApi] Starting worker`);
|
||||||
|
this.timer = setInterval(() => this.runWorker(), intervalMillis);
|
||||||
|
setTimeout(() => this.runWorker(), delayMillis);
|
||||||
|
}
|
||||||
|
|
||||||
|
async runWorker() {
|
||||||
|
if (!session.token()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`[AccountApi] Extending user access token`);
|
||||||
|
try {
|
||||||
|
await this.extendToken();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[AccountApi] Error extending user access token`, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maps to user.Role in user/types.go
|
// Maps to user.Role in user/types.go
|
||||||
export const Role = {
|
export const Role = {
|
||||||
ADMIN: "admin",
|
ADMIN: "admin",
|
||||||
USER: "user",
|
USER: "user"
|
||||||
};
|
};
|
||||||
|
|
||||||
// Maps to server.visitorLimitBasis in server/visitor.go
|
// Maps to server.visitorLimitBasis in server/visitor.go
|
||||||
export const LimitBasis = {
|
export const LimitBasis = {
|
||||||
IP: "ip",
|
IP: "ip",
|
||||||
TIER: "tier",
|
TIER: "tier"
|
||||||
};
|
};
|
||||||
|
|
||||||
// Maps to stripe.SubscriptionStatus
|
// Maps to stripe.SubscriptionStatus
|
||||||
export const SubscriptionStatus = {
|
export const SubscriptionStatus = {
|
||||||
ACTIVE: "active",
|
ACTIVE: "active",
|
||||||
PAST_DUE: "past_due",
|
PAST_DUE: "past_due"
|
||||||
};
|
};
|
||||||
|
|
||||||
// Maps to stripe.PriceRecurringInterval
|
// Maps to stripe.PriceRecurringInterval
|
||||||
export const SubscriptionInterval = {
|
export const SubscriptionInterval = {
|
||||||
MONTH: "month",
|
MONTH: "month",
|
||||||
YEAR: "year",
|
YEAR: "year"
|
||||||
};
|
};
|
||||||
|
|
||||||
// Maps to user.Permission in user/types.go
|
// Maps to user.Permission in user/types.go
|
||||||
export const Permission = {
|
export const Permission = {
|
||||||
READ_WRITE: "read-write",
|
READ_WRITE: "read-write",
|
||||||
READ_ONLY: "read-only",
|
READ_ONLY: "read-only",
|
||||||
WRITE_ONLY: "write-only",
|
WRITE_ONLY: "write-only",
|
||||||
DENY_ALL: "deny-all",
|
DENY_ALL: "deny-all"
|
||||||
};
|
};
|
||||||
|
|
||||||
const accountApi = new AccountApi();
|
const accountApi = new AccountApi();
|
||||||
|
|
|
@ -1,118 +1,115 @@
|
||||||
import {
|
import {
|
||||||
fetchLinesIterator,
|
fetchLinesIterator,
|
||||||
maybeWithAuth,
|
maybeWithAuth,
|
||||||
topicShortUrl,
|
topicShortUrl,
|
||||||
topicUrl,
|
topicUrl,
|
||||||
topicUrlAuth,
|
topicUrlAuth,
|
||||||
topicUrlJsonPoll,
|
topicUrlJsonPoll,
|
||||||
topicUrlJsonPollWithSince,
|
topicUrlJsonPollWithSince
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
import userManager from "./UserManager";
|
import userManager from "./UserManager";
|
||||||
import { fetchOrThrow } from "./errors";
|
import {fetchOrThrow} from "./errors";
|
||||||
|
|
||||||
class Api {
|
class Api {
|
||||||
async poll(baseUrl, topic, since) {
|
async poll(baseUrl, topic, since) {
|
||||||
const user = await userManager.get(baseUrl);
|
const user = await userManager.get(baseUrl);
|
||||||
const shortUrl = topicShortUrl(baseUrl, topic);
|
const shortUrl = topicShortUrl(baseUrl, topic);
|
||||||
const url = since ? topicUrlJsonPollWithSince(baseUrl, topic, since) : topicUrlJsonPoll(baseUrl, topic);
|
const url = (since)
|
||||||
const messages = [];
|
? topicUrlJsonPollWithSince(baseUrl, topic, since)
|
||||||
const headers = maybeWithAuth({}, user);
|
: topicUrlJsonPoll(baseUrl, topic);
|
||||||
console.log(`[Api] Polling ${url}`);
|
const messages = [];
|
||||||
for await (const line of fetchLinesIterator(url, headers)) {
|
const headers = maybeWithAuth({}, user);
|
||||||
const message = JSON.parse(line);
|
console.log(`[Api] Polling ${url}`);
|
||||||
if (message.id) {
|
for await (let line of fetchLinesIterator(url, headers)) {
|
||||||
console.log(`[Api, ${shortUrl}] Received message ${line}`);
|
console.log(`[Api, ${shortUrl}] Received message ${line}`);
|
||||||
messages.push(message);
|
messages.push(JSON.parse(line));
|
||||||
}
|
|
||||||
}
|
|
||||||
return messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
async publish(baseUrl, topic, message, options) {
|
|
||||||
const user = await userManager.get(baseUrl);
|
|
||||||
console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`);
|
|
||||||
const headers = {};
|
|
||||||
const body = {
|
|
||||||
topic,
|
|
||||||
message,
|
|
||||||
...options,
|
|
||||||
};
|
|
||||||
await fetchOrThrow(baseUrl, {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
headers: maybeWithAuth(headers, user),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publishes to a topic using XMLHttpRequest (XHR), and returns a Promise with the active request.
|
|
||||||
* Unfortunately, fetch() does not support a progress hook, which is why XHR has to be used.
|
|
||||||
*
|
|
||||||
* Firefox XHR bug:
|
|
||||||
* Firefox has a bug(?), which returns 0 and "" for all fields of the XHR response in the case of an error,
|
|
||||||
* so we cannot determine the exact error. It also sometimes complains about CORS violations, even when the
|
|
||||||
* correct headers are clearly set. It's quite the odd behavior.
|
|
||||||
*
|
|
||||||
* There is an example, and the bug report here:
|
|
||||||
* - https://bugzilla.mozilla.org/show_bug.cgi?id=1733755
|
|
||||||
* - https://gist.github.com/binwiederhier/627f146d1959799be207ad8c17a8f345
|
|
||||||
*/
|
|
||||||
publishXHR(url, body, headers, onProgress) {
|
|
||||||
console.log(`[Api] Publishing message to ${url}`);
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
const send = new Promise((resolve, reject) => {
|
|
||||||
xhr.open("PUT", url);
|
|
||||||
if (body.type) {
|
|
||||||
xhr.overrideMimeType(body.type);
|
|
||||||
}
|
|
||||||
for (const [key, value] of Object.entries(headers)) {
|
|
||||||
xhr.setRequestHeader(key, value);
|
|
||||||
}
|
|
||||||
xhr.upload.addEventListener("progress", onProgress);
|
|
||||||
xhr.addEventListener("readystatechange", () => {
|
|
||||||
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) {
|
|
||||||
console.log(`[Api] Publish successful (HTTP ${xhr.status})`, xhr.response);
|
|
||||||
resolve(xhr.response);
|
|
||||||
} else if (xhr.readyState === 4) {
|
|
||||||
// Firefox bug; see description above!
|
|
||||||
console.log(`[Api] Publish failed (HTTP ${xhr.status})`, xhr.responseText);
|
|
||||||
let errorText;
|
|
||||||
try {
|
|
||||||
const error = JSON.parse(xhr.responseText);
|
|
||||||
if (error.code && error.error) {
|
|
||||||
errorText = `Error ${error.code}: ${error.error}`;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Nothing
|
|
||||||
}
|
|
||||||
xhr.abort();
|
|
||||||
reject(errorText ?? "An error occurred");
|
|
||||||
}
|
}
|
||||||
});
|
return messages;
|
||||||
xhr.send(body);
|
}
|
||||||
});
|
|
||||||
send.abort = () => {
|
|
||||||
console.log(`[Api] Publish aborted by user`);
|
|
||||||
xhr.abort();
|
|
||||||
};
|
|
||||||
return send;
|
|
||||||
}
|
|
||||||
|
|
||||||
async topicAuth(baseUrl, topic, user) {
|
async publish(baseUrl, topic, message, options) {
|
||||||
const url = topicUrlAuth(baseUrl, topic);
|
const user = await userManager.get(baseUrl);
|
||||||
console.log(`[Api] Checking auth for ${url}`);
|
console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`);
|
||||||
const response = await fetch(url, {
|
const headers = {};
|
||||||
headers: maybeWithAuth({}, user),
|
const body = {
|
||||||
});
|
topic: topic,
|
||||||
if (response.status >= 200 && response.status <= 299) {
|
message: message,
|
||||||
return true;
|
...options
|
||||||
|
};
|
||||||
|
await fetchOrThrow(baseUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: maybeWithAuth(headers, user)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (response.status === 401 || response.status === 403) {
|
|
||||||
// See server/server.go
|
/**
|
||||||
return false;
|
* Publishes to a topic using XMLHttpRequest (XHR), and returns a Promise with the active request.
|
||||||
|
* Unfortunately, fetch() does not support a progress hook, which is why XHR has to be used.
|
||||||
|
*
|
||||||
|
* Firefox XHR bug:
|
||||||
|
* Firefox has a bug(?), which returns 0 and "" for all fields of the XHR response in the case of an error,
|
||||||
|
* so we cannot determine the exact error. It also sometimes complains about CORS violations, even when the
|
||||||
|
* correct headers are clearly set. It's quite the odd behavior.
|
||||||
|
*
|
||||||
|
* There is an example, and the bug report here:
|
||||||
|
* - https://bugzilla.mozilla.org/show_bug.cgi?id=1733755
|
||||||
|
* - https://gist.github.com/binwiederhier/627f146d1959799be207ad8c17a8f345
|
||||||
|
*/
|
||||||
|
publishXHR(url, body, headers, onProgress) {
|
||||||
|
console.log(`[Api] Publishing message to ${url}`);
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
const send = new Promise(function (resolve, reject) {
|
||||||
|
xhr.open("PUT", url);
|
||||||
|
if (body.type) {
|
||||||
|
xhr.overrideMimeType(body.type);
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
xhr.setRequestHeader(key, value);
|
||||||
|
}
|
||||||
|
xhr.upload.addEventListener("progress", onProgress);
|
||||||
|
xhr.addEventListener('readystatechange', () => {
|
||||||
|
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) {
|
||||||
|
console.log(`[Api] Publish successful (HTTP ${xhr.status})`, xhr.response);
|
||||||
|
resolve(xhr.response);
|
||||||
|
} else if (xhr.readyState === 4) {
|
||||||
|
// Firefox bug; see description above!
|
||||||
|
console.log(`[Api] Publish failed (HTTP ${xhr.status})`, xhr.responseText);
|
||||||
|
let errorText;
|
||||||
|
try {
|
||||||
|
const error = JSON.parse(xhr.responseText);
|
||||||
|
if (error.code && error.error) {
|
||||||
|
errorText = `Error ${error.code}: ${error.error}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Nothing
|
||||||
|
}
|
||||||
|
xhr.abort();
|
||||||
|
reject(errorText ?? "An error occurred");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
xhr.send(body);
|
||||||
|
});
|
||||||
|
send.abort = () => {
|
||||||
|
console.log(`[Api] Publish aborted by user`);
|
||||||
|
xhr.abort();
|
||||||
|
}
|
||||||
|
return send;
|
||||||
|
}
|
||||||
|
|
||||||
|
async topicAuth(baseUrl, topic, user) {
|
||||||
|
const url = topicUrlAuth(baseUrl, topic);
|
||||||
|
console.log(`[Api] Checking auth for ${url}`);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: maybeWithAuth({}, user)
|
||||||
|
});
|
||||||
|
if (response.status >= 200 && response.status <= 299) {
|
||||||
|
return true;
|
||||||
|
} else if (response.status === 401 || response.status === 403) { // See server/server.go
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected server response ${response.status}`);
|
||||||
}
|
}
|
||||||
throw new Error(`Unexpected server response ${response.status}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = new Api();
|
const api = new Api();
|
||||||
|
|
|
@ -1,14 +1,7 @@
|
||||||
/* eslint-disable max-classes-per-file */
|
import {basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs} from "./utils";
|
||||||
import { basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs } from "./utils";
|
|
||||||
|
|
||||||
const retryBackoffSeconds = [5, 10, 20, 30, 60, 120];
|
const retryBackoffSeconds = [5, 10, 20, 30, 60, 120];
|
||||||
|
|
||||||
export class ConnectionState {
|
|
||||||
static Connected = "connected";
|
|
||||||
|
|
||||||
static Connecting = "connecting";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A connection contains a single WebSocket connection for one topic. It handles its connection
|
* A connection contains a single WebSocket connection for one topic. It handles its connection
|
||||||
* status itself, including reconnect attempts and backoff.
|
* status itself, including reconnect attempts and backoff.
|
||||||
|
@ -16,103 +9,110 @@ export class ConnectionState {
|
||||||
* Incoming messages and state changes are forwarded via listeners.
|
* Incoming messages and state changes are forwarded via listeners.
|
||||||
*/
|
*/
|
||||||
class Connection {
|
class Connection {
|
||||||
constructor(connectionId, subscriptionId, baseUrl, topic, user, since, onNotification, onStateChanged) {
|
constructor(connectionId, subscriptionId, baseUrl, topic, user, since, onNotification, onStateChanged) {
|
||||||
this.connectionId = connectionId;
|
this.connectionId = connectionId;
|
||||||
this.subscriptionId = subscriptionId;
|
this.subscriptionId = subscriptionId;
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
this.topic = topic;
|
this.topic = topic;
|
||||||
this.user = user;
|
this.user = user;
|
||||||
this.since = since;
|
this.since = since;
|
||||||
this.shortUrl = topicShortUrl(baseUrl, topic);
|
this.shortUrl = topicShortUrl(baseUrl, topic);
|
||||||
this.onNotification = onNotification;
|
this.onNotification = onNotification;
|
||||||
this.onStateChanged = onStateChanged;
|
this.onStateChanged = onStateChanged;
|
||||||
this.ws = null;
|
|
||||||
this.retryCount = 0;
|
|
||||||
this.retryTimeout = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
start() {
|
|
||||||
// Don't fetch old messages; we do that as a poll() when adding a subscription;
|
|
||||||
// we don't want to re-trigger the main view re-render potentially hundreds of times.
|
|
||||||
|
|
||||||
const wsUrl = this.wsUrl();
|
|
||||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Opening connection to ${wsUrl}`);
|
|
||||||
|
|
||||||
this.ws = new WebSocket(wsUrl);
|
|
||||||
this.ws.onopen = (event) => {
|
|
||||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection established`, event);
|
|
||||||
this.retryCount = 0;
|
|
||||||
this.onStateChanged(this.subscriptionId, ConnectionState.Connected);
|
|
||||||
};
|
|
||||||
this.ws.onmessage = (event) => {
|
|
||||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`);
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
if (data.event === "open") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const relevantAndValid = data.event === "message" && "id" in data && "time" in data && "message" in data;
|
|
||||||
if (!relevantAndValid) {
|
|
||||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.since = data.id;
|
|
||||||
this.onNotification(this.subscriptionId, data);
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error handling message: ${e}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.ws.onclose = (event) => {
|
|
||||||
if (event.wasClean) {
|
|
||||||
console.log(
|
|
||||||
`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`
|
|
||||||
);
|
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
} else {
|
this.retryCount = 0;
|
||||||
const retrySeconds = retryBackoffSeconds[Math.min(this.retryCount, retryBackoffSeconds.length - 1)];
|
this.retryTimeout = null;
|
||||||
this.retryCount += 1;
|
}
|
||||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`);
|
|
||||||
this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000);
|
|
||||||
this.onStateChanged(this.subscriptionId, ConnectionState.Connecting);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.ws.onerror = (event) => {
|
|
||||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`, event);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
start() {
|
||||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`);
|
// Don't fetch old messages; we do that as a poll() when adding a subscription;
|
||||||
const socket = this.ws;
|
// we don't want to re-trigger the main view re-render potentially hundreds of times.
|
||||||
const { retryTimeout } = this;
|
|
||||||
if (socket !== null) {
|
|
||||||
socket.close();
|
|
||||||
}
|
|
||||||
if (retryTimeout !== null) {
|
|
||||||
clearTimeout(retryTimeout);
|
|
||||||
}
|
|
||||||
this.retryTimeout = null;
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
wsUrl() {
|
const wsUrl = this.wsUrl();
|
||||||
const params = [];
|
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Opening connection to ${wsUrl}`);
|
||||||
if (this.since) {
|
|
||||||
params.push(`since=${this.since}`);
|
|
||||||
}
|
|
||||||
if (this.user) {
|
|
||||||
params.push(`auth=${this.authParam()}`);
|
|
||||||
}
|
|
||||||
const wsUrl = topicUrlWs(this.baseUrl, this.topic);
|
|
||||||
return params.length === 0 ? wsUrl : `${wsUrl}?${params.join("&")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
authParam() {
|
this.ws = new WebSocket(wsUrl);
|
||||||
if (this.user.password) {
|
this.ws.onopen = (event) => {
|
||||||
return encodeBase64Url(basicAuth(this.user.username, this.user.password));
|
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection established`, event);
|
||||||
|
this.retryCount = 0;
|
||||||
|
this.onStateChanged(this.subscriptionId, ConnectionState.Connected);
|
||||||
|
}
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`);
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.event === 'open') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const relevantAndValid =
|
||||||
|
data.event === 'message' &&
|
||||||
|
'id' in data &&
|
||||||
|
'time' in data &&
|
||||||
|
'message' in data;
|
||||||
|
if (!relevantAndValid) {
|
||||||
|
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.since = data.id;
|
||||||
|
this.onNotification(this.subscriptionId, data);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error handling message: ${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.ws.onclose = (event) => {
|
||||||
|
if (event.wasClean) {
|
||||||
|
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`);
|
||||||
|
this.ws = null;
|
||||||
|
} else {
|
||||||
|
const retrySeconds = retryBackoffSeconds[Math.min(this.retryCount, retryBackoffSeconds.length-1)];
|
||||||
|
this.retryCount++;
|
||||||
|
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`);
|
||||||
|
this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000);
|
||||||
|
this.onStateChanged(this.subscriptionId, ConnectionState.Connecting);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.ws.onerror = (event) => {
|
||||||
|
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`, event);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return encodeBase64Url(bearerAuth(this.user.token));
|
|
||||||
}
|
close() {
|
||||||
|
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`);
|
||||||
|
const socket = this.ws;
|
||||||
|
const retryTimeout = this.retryTimeout;
|
||||||
|
if (socket !== null) {
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
if (retryTimeout !== null) {
|
||||||
|
clearTimeout(retryTimeout);
|
||||||
|
}
|
||||||
|
this.retryTimeout = null;
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
wsUrl() {
|
||||||
|
const params = [];
|
||||||
|
if (this.since) {
|
||||||
|
params.push(`since=${this.since}`);
|
||||||
|
}
|
||||||
|
if (this.user) {
|
||||||
|
params.push(`auth=${this.authParam()}`);
|
||||||
|
}
|
||||||
|
const wsUrl = topicUrlWs(this.baseUrl, this.topic);
|
||||||
|
return (params.length === 0) ? wsUrl : `${wsUrl}?${params.join('&')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
authParam() {
|
||||||
|
if (this.user.password) {
|
||||||
|
return encodeBase64Url(basicAuth(this.user.username, this.user.password));
|
||||||
|
}
|
||||||
|
return encodeBase64Url(bearerAuth(this.user.token));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConnectionState {
|
||||||
|
static Connected = "connected";
|
||||||
|
static Connecting = "connecting";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Connection;
|
export default Connection;
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
import Connection from "./Connection";
|
import Connection from "./Connection";
|
||||||
import { hashCode } from "./utils";
|
import {hashCode} from "./utils";
|
||||||
|
|
||||||
const makeConnectionId = async (subscription, user) =>
|
|
||||||
user ? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`) : hashCode(`${subscription.id}`);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The connection manager keeps track of active connections (WebSocket connections, see Connection).
|
* The connection manager keeps track of active connections (WebSocket connections, see Connection).
|
||||||
|
@ -11,106 +8,109 @@ const makeConnectionId = async (subscription, user) =>
|
||||||
* as required. This is done pretty much exactly the same way as in the Android app.
|
* as required. This is done pretty much exactly the same way as in the Android app.
|
||||||
*/
|
*/
|
||||||
class ConnectionManager {
|
class ConnectionManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.connections = new Map(); // ConnectionId -> Connection (hash, see below)
|
this.connections = new Map(); // ConnectionId -> Connection (hash, see below)
|
||||||
this.stateListener = null; // Fired when connection state changes
|
this.stateListener = null; // Fired when connection state changes
|
||||||
this.messageListener = null; // Fired when new notifications arrive
|
this.messageListener = null; // Fired when new notifications arrive
|
||||||
}
|
|
||||||
|
|
||||||
registerStateListener(listener) {
|
|
||||||
this.stateListener = listener;
|
|
||||||
}
|
|
||||||
|
|
||||||
resetStateListener() {
|
|
||||||
this.stateListener = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
registerMessageListener(listener) {
|
|
||||||
this.messageListener = listener;
|
|
||||||
}
|
|
||||||
|
|
||||||
resetMessageListener() {
|
|
||||||
this.messageListener = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function figures out which websocket connections should be running by comparing the
|
|
||||||
* current state of the world (connections) with the target state (targetIds).
|
|
||||||
*
|
|
||||||
* It uses a "connectionId", which is sha256($subscriptionId|$username|$password) to identify
|
|
||||||
* connections. If any of them change, the connection is closed/replaced.
|
|
||||||
*/
|
|
||||||
async refresh(subscriptions, users) {
|
|
||||||
if (!subscriptions || !users) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
console.log(`[ConnectionManager] Refreshing connections`);
|
|
||||||
const subscriptionsWithUsersAndConnectionId = await Promise.all(
|
|
||||||
subscriptions.map(async (s) => {
|
|
||||||
const [user] = users.filter((u) => u.baseUrl === s.baseUrl);
|
|
||||||
const connectionId = await makeConnectionId(s, user);
|
|
||||||
return { ...s, user, connectionId };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const targetIds = subscriptionsWithUsersAndConnectionId.map((s) => s.connectionId);
|
|
||||||
const deletedIds = Array.from(this.connections.keys()).filter((id) => !targetIds.includes(id));
|
|
||||||
|
|
||||||
// Create and add new connections
|
registerStateListener(listener) {
|
||||||
subscriptionsWithUsersAndConnectionId.forEach((subscription) => {
|
this.stateListener = listener;
|
||||||
const subscriptionId = subscription.id;
|
|
||||||
const { connectionId } = subscription;
|
|
||||||
const added = !this.connections.get(connectionId);
|
|
||||||
if (added) {
|
|
||||||
const { baseUrl, topic, user } = subscription;
|
|
||||||
const since = subscription.last;
|
|
||||||
const connection = new Connection(
|
|
||||||
connectionId,
|
|
||||||
subscriptionId,
|
|
||||||
baseUrl,
|
|
||||||
topic,
|
|
||||||
user,
|
|
||||||
since,
|
|
||||||
(subId, notification) => this.notificationReceived(subId, notification),
|
|
||||||
(subId, state) => this.stateChanged(subId, state)
|
|
||||||
);
|
|
||||||
this.connections.set(connectionId, connection);
|
|
||||||
console.log(
|
|
||||||
`[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${
|
|
||||||
user ? user.username : "anonymous"
|
|
||||||
})`
|
|
||||||
);
|
|
||||||
connection.start();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete old connections
|
|
||||||
deletedIds.forEach((id) => {
|
|
||||||
console.log(`[ConnectionManager] Closing connection ${id}`);
|
|
||||||
const connection = this.connections.get(id);
|
|
||||||
this.connections.delete(id);
|
|
||||||
connection.close();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
stateChanged(subscriptionId, state) {
|
|
||||||
if (this.stateListener) {
|
|
||||||
try {
|
|
||||||
this.stateListener(subscriptionId, state);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[ConnectionManager] Error updating state of ${subscriptionId} to ${state}`, e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
notificationReceived(subscriptionId, notification) {
|
resetStateListener() {
|
||||||
if (this.messageListener) {
|
this.stateListener = null;
|
||||||
try {
|
|
||||||
this.messageListener(subscriptionId, notification);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[ConnectionManager] Error handling notification for ${subscriptionId}`, e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
registerMessageListener(listener) {
|
||||||
|
this.messageListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetMessageListener() {
|
||||||
|
this.messageListener = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function figures out which websocket connections should be running by comparing the
|
||||||
|
* current state of the world (connections) with the target state (targetIds).
|
||||||
|
*
|
||||||
|
* It uses a "connectionId", which is sha256($subscriptionId|$username|$password) to identify
|
||||||
|
* connections. If any of them change, the connection is closed/replaced.
|
||||||
|
*/
|
||||||
|
async refresh(subscriptions, users) {
|
||||||
|
if (!subscriptions || !users) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`[ConnectionManager] Refreshing connections`);
|
||||||
|
const subscriptionsWithUsersAndConnectionId = await Promise.all(subscriptions
|
||||||
|
.map(async s => {
|
||||||
|
const [user] = users.filter(u => u.baseUrl === s.baseUrl);
|
||||||
|
const connectionId = await makeConnectionId(s, user);
|
||||||
|
return {...s, user, connectionId};
|
||||||
|
}));
|
||||||
|
const targetIds = subscriptionsWithUsersAndConnectionId.map(s => s.connectionId);
|
||||||
|
const deletedIds = Array.from(this.connections.keys()).filter(id => !targetIds.includes(id));
|
||||||
|
|
||||||
|
// Create and add new connections
|
||||||
|
subscriptionsWithUsersAndConnectionId.forEach(subscription => {
|
||||||
|
const subscriptionId = subscription.id;
|
||||||
|
const connectionId = subscription.connectionId;
|
||||||
|
const added = !this.connections.get(connectionId)
|
||||||
|
if (added) {
|
||||||
|
const baseUrl = subscription.baseUrl;
|
||||||
|
const topic = subscription.topic;
|
||||||
|
const user = subscription.user;
|
||||||
|
const since = subscription.last;
|
||||||
|
const connection = new Connection(
|
||||||
|
connectionId,
|
||||||
|
subscriptionId,
|
||||||
|
baseUrl,
|
||||||
|
topic,
|
||||||
|
user,
|
||||||
|
since,
|
||||||
|
(subscriptionId, notification) => this.notificationReceived(subscriptionId, notification),
|
||||||
|
(subscriptionId, state) => this.stateChanged(subscriptionId, state)
|
||||||
|
);
|
||||||
|
this.connections.set(connectionId, connection);
|
||||||
|
console.log(`[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${user ? user.username : "anonymous"})`);
|
||||||
|
connection.start();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete old connections
|
||||||
|
deletedIds.forEach(id => {
|
||||||
|
console.log(`[ConnectionManager] Closing connection ${id}`);
|
||||||
|
const connection = this.connections.get(id);
|
||||||
|
this.connections.delete(id);
|
||||||
|
connection.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stateChanged(subscriptionId, state) {
|
||||||
|
if (this.stateListener) {
|
||||||
|
try {
|
||||||
|
this.stateListener(subscriptionId, state);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[ConnectionManager] Error updating state of ${subscriptionId} to ${state}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notificationReceived(subscriptionId, notification) {
|
||||||
|
if (this.messageListener) {
|
||||||
|
try {
|
||||||
|
this.messageListener(subscriptionId, notification);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[ConnectionManager] Error handling notification for ${subscriptionId}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeConnectionId = async (subscription, user) => {
|
||||||
|
return (user)
|
||||||
|
? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`)
|
||||||
|
: hashCode(`${subscription.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectionManager = new ConnectionManager();
|
const connectionManager = new ConnectionManager();
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { formatMessage, formatTitleWithDefault, openUrl, playSound, topicDisplayName, topicShortUrl } from "./utils";
|
import {formatMessage, formatTitleWithDefault, openUrl, playSound, topicDisplayName, topicShortUrl} from "./utils";
|
||||||
import prefs from "./Prefs";
|
import prefs from "./Prefs";
|
||||||
import subscriptionManager from "./SubscriptionManager";
|
import subscriptionManager from "./SubscriptionManager";
|
||||||
import logo from "../img/ntfy.png";
|
import logo from "../img/ntfy.png";
|
||||||
|
@ -8,87 +8,89 @@ import logo from "../img/ntfy.png";
|
||||||
* support this; most importantly, all iOS browsers do not support window.Notification.
|
* support this; most importantly, all iOS browsers do not support window.Notification.
|
||||||
*/
|
*/
|
||||||
class Notifier {
|
class Notifier {
|
||||||
async notify(subscriptionId, notification, onClickFallback) {
|
async notify(subscriptionId, notification, onClickFallback) {
|
||||||
if (!this.supported()) {
|
if (!this.supported()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const subscription = await subscriptionManager.get(subscriptionId);
|
const subscription = await subscriptionManager.get(subscriptionId);
|
||||||
const shouldNotify = await this.shouldNotify(subscription, notification);
|
const shouldNotify = await this.shouldNotify(subscription, notification);
|
||||||
if (!shouldNotify) {
|
if (!shouldNotify) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
|
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
|
||||||
const displayName = topicDisplayName(subscription);
|
const displayName = topicDisplayName(subscription);
|
||||||
const message = formatMessage(notification);
|
const message = formatMessage(notification);
|
||||||
const title = formatTitleWithDefault(notification, displayName);
|
const title = formatTitleWithDefault(notification, displayName);
|
||||||
|
|
||||||
// Show notification
|
// Show notification
|
||||||
console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`);
|
console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`);
|
||||||
const n = new Notification(title, {
|
const n = new Notification(title, {
|
||||||
body: message,
|
body: message,
|
||||||
icon: logo,
|
icon: logo
|
||||||
});
|
});
|
||||||
if (notification.click) {
|
if (notification.click) {
|
||||||
n.onclick = () => openUrl(notification.click);
|
n.onclick = (e) => openUrl(notification.click);
|
||||||
} else {
|
} else {
|
||||||
n.onclick = () => onClickFallback(subscription);
|
n.onclick = () => onClickFallback(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play sound
|
||||||
|
const sound = await prefs.sound();
|
||||||
|
if (sound && sound !== "none") {
|
||||||
|
try {
|
||||||
|
await playSound(sound);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Notifier, ${shortUrl}] Error playing audio`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play sound
|
granted() {
|
||||||
const sound = await prefs.sound();
|
return this.supported() && Notification.permission === 'granted';
|
||||||
if (sound && sound !== "none") {
|
|
||||||
try {
|
|
||||||
await playSound(sound);
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`[Notifier, ${shortUrl}] Error playing audio`, e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
granted() {
|
maybeRequestPermission(cb) {
|
||||||
return this.supported() && Notification.permission === "granted";
|
if (!this.supported()) {
|
||||||
}
|
cb(false);
|
||||||
|
return;
|
||||||
maybeRequestPermission(cb) {
|
}
|
||||||
if (!this.supported()) {
|
if (!this.granted()) {
|
||||||
cb(false);
|
Notification.requestPermission().then((permission) => {
|
||||||
return;
|
const granted = permission === 'granted';
|
||||||
|
cb(granted);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!this.granted()) {
|
|
||||||
Notification.requestPermission().then((permission) => {
|
async shouldNotify(subscription, notification) {
|
||||||
const granted = permission === "granted";
|
if (subscription.mutedUntil === 1) {
|
||||||
cb(granted);
|
return false;
|
||||||
});
|
}
|
||||||
|
const priority = (notification.priority) ? notification.priority : 3;
|
||||||
|
const minPriority = await prefs.minPriority();
|
||||||
|
if (priority < minPriority) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async shouldNotify(subscription, notification) {
|
supported() {
|
||||||
if (subscription.mutedUntil === 1) {
|
return this.browserSupported() && this.contextSupported();
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
const priority = notification.priority ? notification.priority : 3;
|
|
||||||
const minPriority = await prefs.minPriority();
|
browserSupported() {
|
||||||
if (priority < minPriority) {
|
return 'Notification' in window;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
supported() {
|
/**
|
||||||
return this.browserSupported() && this.contextSupported();
|
* Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API
|
||||||
}
|
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
|
||||||
|
*/
|
||||||
browserSupported() {
|
contextSupported() {
|
||||||
return "Notification" in window;
|
return location.protocol === 'https:'
|
||||||
}
|
|| location.hostname.match('^127.')
|
||||||
|
|| location.hostname === 'localhost';
|
||||||
/**
|
}
|
||||||
* Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API
|
|
||||||
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
|
|
||||||
*/
|
|
||||||
contextSupported() {
|
|
||||||
return window.location.protocol === "https:" || window.location.hostname.match("^127.") || window.location.hostname === "localhost";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifier = new Notifier();
|
const notifier = new Notifier();
|
||||||
|
|
|
@ -5,57 +5,54 @@ const delayMillis = 2000; // 2 seconds
|
||||||
const intervalMillis = 300000; // 5 minutes
|
const intervalMillis = 300000; // 5 minutes
|
||||||
|
|
||||||
class Poller {
|
class Poller {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
|
||||||
|
|
||||||
startWorker() {
|
|
||||||
if (this.timer !== null) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
console.log(`[Poller] Starting worker`);
|
|
||||||
this.timer = setInterval(() => this.pollAll(), intervalMillis);
|
|
||||||
setTimeout(() => this.pollAll(), delayMillis);
|
|
||||||
}
|
|
||||||
|
|
||||||
async pollAll() {
|
startWorker() {
|
||||||
console.log(`[Poller] Polling all subscriptions`);
|
if (this.timer !== null) {
|
||||||
const subscriptions = await subscriptionManager.all();
|
return;
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
subscriptions.map(async (s) => {
|
|
||||||
try {
|
|
||||||
await this.poll(s);
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`[Poller] Error polling ${s.id}`, e);
|
|
||||||
}
|
}
|
||||||
})
|
console.log(`[Poller] Starting worker`);
|
||||||
);
|
this.timer = setInterval(() => this.pollAll(), intervalMillis);
|
||||||
}
|
setTimeout(() => this.pollAll(), delayMillis);
|
||||||
|
|
||||||
async poll(subscription) {
|
|
||||||
console.log(`[Poller] Polling ${subscription.id}`);
|
|
||||||
|
|
||||||
const since = subscription.last;
|
|
||||||
const notifications = await api.poll(subscription.baseUrl, subscription.topic, since);
|
|
||||||
if (!notifications || notifications.length === 0) {
|
|
||||||
console.log(`[Poller] No new notifications found for ${subscription.id}`);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`);
|
|
||||||
await subscriptionManager.addNotifications(subscription.id, notifications);
|
|
||||||
}
|
|
||||||
|
|
||||||
pollInBackground(subscription) {
|
async pollAll() {
|
||||||
const fn = async () => {
|
console.log(`[Poller] Polling all subscriptions`);
|
||||||
try {
|
const subscriptions = await subscriptionManager.all();
|
||||||
await this.poll(subscription);
|
for (const s of subscriptions) {
|
||||||
} catch (e) {
|
try {
|
||||||
console.error(`[App] Error polling subscription ${subscription.id}`, e);
|
await this.poll(s);
|
||||||
}
|
} catch (e) {
|
||||||
};
|
console.log(`[Poller] Error polling ${s.id}`, e);
|
||||||
setTimeout(() => fn(), 0);
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async poll(subscription) {
|
||||||
|
console.log(`[Poller] Polling ${subscription.id}`);
|
||||||
|
|
||||||
|
const since = subscription.last;
|
||||||
|
const notifications = await api.poll(subscription.baseUrl, subscription.topic, since);
|
||||||
|
if (!notifications || notifications.length === 0) {
|
||||||
|
console.log(`[Poller] No new notifications found for ${subscription.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`);
|
||||||
|
await subscriptionManager.addNotifications(subscription.id, notifications);
|
||||||
|
}
|
||||||
|
|
||||||
|
pollInBackground(subscription) {
|
||||||
|
const fn = async () => {
|
||||||
|
try {
|
||||||
|
await this.poll(subscription);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[App] Error polling subscription ${subscription.id}`, e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(() => fn(), 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const poller = new Poller();
|
const poller = new Poller();
|
||||||
|
|
|
@ -1,32 +1,32 @@
|
||||||
import db from "./db";
|
import db from "./db";
|
||||||
|
|
||||||
class Prefs {
|
class Prefs {
|
||||||
async setSound(sound) {
|
async setSound(sound) {
|
||||||
db.prefs.put({ key: "sound", value: sound.toString() });
|
db.prefs.put({key: 'sound', value: sound.toString()});
|
||||||
}
|
}
|
||||||
|
|
||||||
async sound() {
|
async sound() {
|
||||||
const sound = await db.prefs.get("sound");
|
const sound = await db.prefs.get('sound');
|
||||||
return sound ? sound.value : "ding";
|
return (sound) ? sound.value : "ding";
|
||||||
}
|
}
|
||||||
|
|
||||||
async setMinPriority(minPriority) {
|
async setMinPriority(minPriority) {
|
||||||
db.prefs.put({ key: "minPriority", value: minPriority.toString() });
|
db.prefs.put({key: 'minPriority', value: minPriority.toString()});
|
||||||
}
|
}
|
||||||
|
|
||||||
async minPriority() {
|
async minPriority() {
|
||||||
const minPriority = await db.prefs.get("minPriority");
|
const minPriority = await db.prefs.get('minPriority');
|
||||||
return minPriority ? Number(minPriority.value) : 1;
|
return (minPriority) ? Number(minPriority.value) : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setDeleteAfter(deleteAfter) {
|
async setDeleteAfter(deleteAfter) {
|
||||||
db.prefs.put({ key: "deleteAfter", value: deleteAfter.toString() });
|
db.prefs.put({key:'deleteAfter', value: deleteAfter.toString()});
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteAfter() {
|
async deleteAfter() {
|
||||||
const deleteAfter = await db.prefs.get("deleteAfter");
|
const deleteAfter = await db.prefs.get('deleteAfter');
|
||||||
return deleteAfter ? Number(deleteAfter.value) : 604800; // Default is one week
|
return (deleteAfter) ? Number(deleteAfter.value) : 604800; // Default is one week
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefs = new Prefs();
|
const prefs = new Prefs();
|
||||||
|
|
|
@ -5,33 +5,33 @@ const delayMillis = 25000; // 25 seconds
|
||||||
const intervalMillis = 1800000; // 30 minutes
|
const intervalMillis = 1800000; // 30 minutes
|
||||||
|
|
||||||
class Pruner {
|
class Pruner {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
startWorker() {
|
startWorker() {
|
||||||
if (this.timer !== null) {
|
if (this.timer !== null) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
console.log(`[Pruner] Starting worker`);
|
||||||
|
this.timer = setInterval(() => this.prune(), intervalMillis);
|
||||||
|
setTimeout(() => this.prune(), delayMillis);
|
||||||
}
|
}
|
||||||
console.log(`[Pruner] Starting worker`);
|
|
||||||
this.timer = setInterval(() => this.prune(), intervalMillis);
|
|
||||||
setTimeout(() => this.prune(), delayMillis);
|
|
||||||
}
|
|
||||||
|
|
||||||
async prune() {
|
async prune() {
|
||||||
const deleteAfterSeconds = await prefs.deleteAfter();
|
const deleteAfterSeconds = await prefs.deleteAfter();
|
||||||
const pruneThresholdTimestamp = Math.round(Date.now() / 1000) - deleteAfterSeconds;
|
const pruneThresholdTimestamp = Math.round(Date.now()/1000) - deleteAfterSeconds;
|
||||||
if (deleteAfterSeconds === 0) {
|
if (deleteAfterSeconds === 0) {
|
||||||
console.log(`[Pruner] Pruning is disabled. Skipping.`);
|
console.log(`[Pruner] Pruning is disabled. Skipping.`);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
console.log(`[Pruner] Pruning notifications older than ${deleteAfterSeconds}s (timestamp ${pruneThresholdTimestamp})`);
|
||||||
|
try {
|
||||||
|
await subscriptionManager.pruneNotifications(pruneThresholdTimestamp);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Pruner] Error pruning old subscriptions`, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
console.log(`[Pruner] Pruning notifications older than ${deleteAfterSeconds}s (timestamp ${pruneThresholdTimestamp})`);
|
|
||||||
try {
|
|
||||||
await subscriptionManager.pruneNotifications(pruneThresholdTimestamp);
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`[Pruner] Error pruning old subscriptions`, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pruner = new Pruner();
|
const pruner = new Pruner();
|
||||||
|
|
|
@ -1,30 +1,30 @@
|
||||||
class Session {
|
class Session {
|
||||||
store(username, token) {
|
store(username, token) {
|
||||||
localStorage.setItem("user", username);
|
localStorage.setItem("user", username);
|
||||||
localStorage.setItem("token", token);
|
localStorage.setItem("token", token);
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
localStorage.removeItem("user");
|
localStorage.removeItem("user");
|
||||||
localStorage.removeItem("token");
|
localStorage.removeItem("token");
|
||||||
}
|
}
|
||||||
|
|
||||||
resetAndRedirect(url) {
|
resetAndRedirect(url) {
|
||||||
this.reset();
|
this.reset();
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
}
|
}
|
||||||
|
|
||||||
exists() {
|
exists() {
|
||||||
return this.username() && this.token();
|
return this.username() && this.token();
|
||||||
}
|
}
|
||||||
|
|
||||||
username() {
|
username() {
|
||||||
return localStorage.getItem("user");
|
return localStorage.getItem("user");
|
||||||
}
|
}
|
||||||
|
|
||||||
token() {
|
token() {
|
||||||
return localStorage.getItem("token");
|
return localStorage.getItem("token");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = new Session();
|
const session = new Session();
|
||||||
|
|
|
@ -1,189 +1,192 @@
|
||||||
import db from "./db";
|
import db from "./db";
|
||||||
import { topicUrl } from "./utils";
|
import {topicUrl} from "./utils";
|
||||||
|
|
||||||
class SubscriptionManager {
|
class SubscriptionManager {
|
||||||
/** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */
|
/** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */
|
||||||
async all() {
|
async all() {
|
||||||
const subscriptions = await db.subscriptions.toArray();
|
const subscriptions = await db.subscriptions.toArray();
|
||||||
return Promise.all(
|
await Promise.all(subscriptions.map(async s => {
|
||||||
subscriptions.map(async (s) => ({
|
s.new = await db.notifications
|
||||||
...s,
|
.where({ subscriptionId: s.id, new: 1 })
|
||||||
new: await db.notifications.where({ subscriptionId: s.id, new: 1 }).count(),
|
.count();
|
||||||
}))
|
}));
|
||||||
);
|
return subscriptions;
|
||||||
}
|
|
||||||
|
|
||||||
async get(subscriptionId) {
|
|
||||||
return db.subscriptions.get(subscriptionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async add(baseUrl, topic, internal) {
|
|
||||||
const id = topicUrl(baseUrl, topic);
|
|
||||||
const existingSubscription = await this.get(id);
|
|
||||||
if (existingSubscription) {
|
|
||||||
return existingSubscription;
|
|
||||||
}
|
}
|
||||||
const subscription = {
|
|
||||||
id: topicUrl(baseUrl, topic),
|
|
||||||
baseUrl,
|
|
||||||
topic,
|
|
||||||
mutedUntil: 0,
|
|
||||||
last: null,
|
|
||||||
internal: internal || false,
|
|
||||||
};
|
|
||||||
await db.subscriptions.put(subscription);
|
|
||||||
return subscription;
|
|
||||||
}
|
|
||||||
|
|
||||||
async syncFromRemote(remoteSubscriptions, remoteReservations) {
|
async get(subscriptionId) {
|
||||||
console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions);
|
return await db.subscriptions.get(subscriptionId)
|
||||||
|
}
|
||||||
|
|
||||||
// Add remote subscriptions
|
async add(baseUrl, topic, internal) {
|
||||||
const remoteIds = await Promise.all(
|
const id = topicUrl(baseUrl, topic);
|
||||||
remoteSubscriptions.map(async (remote) => {
|
const existingSubscription = await this.get(id);
|
||||||
const local = await this.add(remote.base_url, remote.topic, false);
|
if (existingSubscription) {
|
||||||
const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null;
|
return existingSubscription;
|
||||||
|
|
||||||
await this.update(local.id, {
|
|
||||||
displayName: remote.display_name, // May be undefined
|
|
||||||
reservation, // May be null!
|
|
||||||
});
|
|
||||||
|
|
||||||
return local.id;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Remove local subscriptions that do not exist remotely
|
|
||||||
const localSubscriptions = await db.subscriptions.toArray();
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
localSubscriptions.map(async (local) => {
|
|
||||||
const remoteExists = remoteIds.includes(local.id);
|
|
||||||
if (!local.internal && !remoteExists) {
|
|
||||||
await this.remove(local.id);
|
|
||||||
}
|
}
|
||||||
})
|
const subscription = {
|
||||||
);
|
id: topicUrl(baseUrl, topic),
|
||||||
}
|
baseUrl: baseUrl,
|
||||||
|
topic: topic,
|
||||||
async updateState(subscriptionId, state) {
|
mutedUntil: 0,
|
||||||
db.subscriptions.update(subscriptionId, { state });
|
last: null,
|
||||||
}
|
internal: internal || false
|
||||||
|
};
|
||||||
async remove(subscriptionId) {
|
await db.subscriptions.put(subscription);
|
||||||
await db.subscriptions.delete(subscriptionId);
|
return subscription;
|
||||||
await db.notifications.where({ subscriptionId }).delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
async first() {
|
|
||||||
return db.subscriptions.toCollection().first(); // May be undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
async getNotifications(subscriptionId) {
|
|
||||||
// This is quite awkward, but it is the recommended approach as per the Dexie docs.
|
|
||||||
// It's actually fine, because the reading and filtering is quite fast. The rendering is what's
|
|
||||||
// killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach
|
|
||||||
|
|
||||||
return db.notifications
|
|
||||||
.orderBy("time") // Sort by time first
|
|
||||||
.filter((n) => n.subscriptionId === subscriptionId)
|
|
||||||
.reverse()
|
|
||||||
.toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAllNotifications() {
|
|
||||||
return db.notifications
|
|
||||||
.orderBy("time") // Efficient, see docs
|
|
||||||
.reverse()
|
|
||||||
.toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Adds notification, or returns false if it already exists */
|
|
||||||
async addNotification(subscriptionId, notification) {
|
|
||||||
const exists = await db.notifications.get(notification.id);
|
|
||||||
if (exists) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
await db.notifications.add({
|
async syncFromRemote(remoteSubscriptions, remoteReservations) {
|
||||||
...notification,
|
console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions);
|
||||||
subscriptionId,
|
|
||||||
// New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
|
// Add remote subscriptions
|
||||||
new: 1,
|
let remoteIds = []; // = topicUrl(baseUrl, topic)
|
||||||
}); // FIXME consider put() for double tab
|
for (let i = 0; i < remoteSubscriptions.length; i++) {
|
||||||
await db.subscriptions.update(subscriptionId, {
|
const remote = remoteSubscriptions[i];
|
||||||
last: notification.id,
|
const local = await this.add(remote.base_url, remote.topic, false);
|
||||||
});
|
const reservation = remoteReservations?.find(r => remote.base_url === config.base_url && remote.topic === r.topic) || null;
|
||||||
} catch (e) {
|
await this.update(local.id, {
|
||||||
console.error(`[SubscriptionManager] Error adding notification`, e);
|
displayName: remote.display_name, // May be undefined
|
||||||
|
reservation: reservation // May be null!
|
||||||
|
});
|
||||||
|
remoteIds.push(local.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove local subscriptions that do not exist remotely
|
||||||
|
const localSubscriptions = await db.subscriptions.toArray();
|
||||||
|
for (let i = 0; i < localSubscriptions.length; i++) {
|
||||||
|
const local = localSubscriptions[i];
|
||||||
|
const remoteExists = remoteIds.includes(local.id);
|
||||||
|
if (!local.internal && !remoteExists) {
|
||||||
|
await this.remove(local.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Adds/replaces notifications, will not throw if they exist */
|
async updateState(subscriptionId, state) {
|
||||||
async addNotifications(subscriptionId, notifications) {
|
db.subscriptions.update(subscriptionId, { state: state });
|
||||||
const notificationsWithSubscriptionId = notifications.map((notification) => ({ ...notification, subscriptionId }));
|
|
||||||
const lastNotificationId = notifications.at(-1).id;
|
|
||||||
await db.notifications.bulkPut(notificationsWithSubscriptionId);
|
|
||||||
await db.subscriptions.update(subscriptionId, {
|
|
||||||
last: lastNotificationId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateNotification(notification) {
|
|
||||||
const exists = await db.notifications.get(notification.id);
|
|
||||||
if (!exists) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
await db.notifications.put({ ...notification });
|
async remove(subscriptionId) {
|
||||||
} catch (e) {
|
await db.subscriptions.delete(subscriptionId);
|
||||||
console.error(`[SubscriptionManager] Error updating notification`, e);
|
await db.notifications
|
||||||
|
.where({subscriptionId: subscriptionId})
|
||||||
|
.delete();
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteNotification(notificationId) {
|
async first() {
|
||||||
await db.notifications.delete(notificationId);
|
return db.subscriptions.toCollection().first(); // May be undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteNotifications(subscriptionId) {
|
async getNotifications(subscriptionId) {
|
||||||
await db.notifications.where({ subscriptionId }).delete();
|
// This is quite awkward, but it is the recommended approach as per the Dexie docs.
|
||||||
}
|
// It's actually fine, because the reading and filtering is quite fast. The rendering is what's
|
||||||
|
// killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach
|
||||||
|
|
||||||
async markNotificationRead(notificationId) {
|
return db.notifications
|
||||||
await db.notifications.where({ id: notificationId }).modify({ new: 0 });
|
.orderBy("time") // Sort by time first
|
||||||
}
|
.filter(n => n.subscriptionId === subscriptionId)
|
||||||
|
.reverse()
|
||||||
|
.toArray();
|
||||||
|
}
|
||||||
|
|
||||||
async markNotificationsRead(subscriptionId) {
|
async getAllNotifications() {
|
||||||
await db.notifications.where({ subscriptionId, new: 1 }).modify({ new: 0 });
|
return db.notifications
|
||||||
}
|
.orderBy("time") // Efficient, see docs
|
||||||
|
.reverse()
|
||||||
|
.toArray();
|
||||||
|
}
|
||||||
|
|
||||||
async setMutedUntil(subscriptionId, mutedUntil) {
|
/** Adds notification, or returns false if it already exists */
|
||||||
await db.subscriptions.update(subscriptionId, {
|
async addNotification(subscriptionId, notification) {
|
||||||
mutedUntil,
|
const exists = await db.notifications.get(notification.id);
|
||||||
});
|
if (exists) {
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
notification.new = 1; // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
|
||||||
|
await db.notifications.add({ ...notification, subscriptionId }); // FIXME consider put() for double tab
|
||||||
|
await db.subscriptions.update(subscriptionId, {
|
||||||
|
last: notification.id
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[SubscriptionManager] Error adding notification`, e);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async setDisplayName(subscriptionId, displayName) {
|
/** Adds/replaces notifications, will not throw if they exist */
|
||||||
await db.subscriptions.update(subscriptionId, {
|
async addNotifications(subscriptionId, notifications) {
|
||||||
displayName,
|
const notificationsWithSubscriptionId = notifications
|
||||||
});
|
.map(notification => ({ ...notification, subscriptionId }));
|
||||||
}
|
const lastNotificationId = notifications.at(-1).id;
|
||||||
|
await db.notifications.bulkPut(notificationsWithSubscriptionId);
|
||||||
|
await db.subscriptions.update(subscriptionId, {
|
||||||
|
last: lastNotificationId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async setReservation(subscriptionId, reservation) {
|
async updateNotification(notification) {
|
||||||
await db.subscriptions.update(subscriptionId, {
|
const exists = await db.notifications.get(notification.id);
|
||||||
reservation,
|
if (!exists) {
|
||||||
});
|
return false;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await db.notifications.put({ ...notification });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[SubscriptionManager] Error updating notification`, e);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async update(subscriptionId, params) {
|
async deleteNotification(notificationId) {
|
||||||
await db.subscriptions.update(subscriptionId, params);
|
await db.notifications.delete(notificationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async pruneNotifications(thresholdTimestamp) {
|
async deleteNotifications(subscriptionId) {
|
||||||
await db.notifications.where("time").below(thresholdTimestamp).delete();
|
await db.notifications
|
||||||
}
|
.where({subscriptionId: subscriptionId})
|
||||||
|
.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
async markNotificationRead(notificationId) {
|
||||||
|
await db.notifications
|
||||||
|
.where({id: notificationId})
|
||||||
|
.modify({new: 0});
|
||||||
|
}
|
||||||
|
|
||||||
|
async markNotificationsRead(subscriptionId) {
|
||||||
|
await db.notifications
|
||||||
|
.where({subscriptionId: subscriptionId, new: 1})
|
||||||
|
.modify({new: 0});
|
||||||
|
}
|
||||||
|
|
||||||
|
async setMutedUntil(subscriptionId, mutedUntil) {
|
||||||
|
await db.subscriptions.update(subscriptionId, {
|
||||||
|
mutedUntil: mutedUntil
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async setDisplayName(subscriptionId, displayName) {
|
||||||
|
await db.subscriptions.update(subscriptionId, {
|
||||||
|
displayName: displayName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async setReservation(subscriptionId, reservation) {
|
||||||
|
await db.subscriptions.update(subscriptionId, {
|
||||||
|
reservation: reservation
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(subscriptionId, params) {
|
||||||
|
await db.subscriptions.update(subscriptionId, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async pruneNotifications(thresholdTimestamp) {
|
||||||
|
await db.notifications
|
||||||
|
.where("time").below(thresholdTimestamp)
|
||||||
|
.delete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriptionManager = new SubscriptionManager();
|
const subscriptionManager = new SubscriptionManager();
|
||||||
|
|
|
@ -2,45 +2,45 @@ import db from "./db";
|
||||||
import session from "./Session";
|
import session from "./Session";
|
||||||
|
|
||||||
class UserManager {
|
class UserManager {
|
||||||
async all() {
|
async all() {
|
||||||
const users = await db.users.toArray();
|
const users = await db.users.toArray();
|
||||||
if (session.exists()) {
|
if (session.exists()) {
|
||||||
users.unshift(this.localUser());
|
users.unshift(this.localUser());
|
||||||
|
}
|
||||||
|
return users;
|
||||||
}
|
}
|
||||||
return users;
|
|
||||||
}
|
|
||||||
|
|
||||||
async get(baseUrl) {
|
async get(baseUrl) {
|
||||||
if (session.exists() && baseUrl === config.base_url) {
|
if (session.exists() && baseUrl === config.base_url) {
|
||||||
return this.localUser();
|
return this.localUser();
|
||||||
|
}
|
||||||
|
return db.users.get(baseUrl);
|
||||||
}
|
}
|
||||||
return db.users.get(baseUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
async save(user) {
|
async save(user) {
|
||||||
if (session.exists() && user.baseUrl === config.base_url) {
|
if (session.exists() && user.baseUrl === config.base_url) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
await db.users.put(user);
|
||||||
}
|
}
|
||||||
await db.users.put(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(baseUrl) {
|
async delete(baseUrl) {
|
||||||
if (session.exists() && baseUrl === config.base_url) {
|
if (session.exists() && baseUrl === config.base_url) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
await db.users.delete(baseUrl);
|
||||||
}
|
}
|
||||||
await db.users.delete(baseUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
localUser() {
|
localUser() {
|
||||||
if (!session.exists()) {
|
if (!session.exists()) {
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
baseUrl: config.base_url,
|
||||||
|
username: session.username(),
|
||||||
|
token: session.token() // Not "password"!
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
baseUrl: config.base_url,
|
|
||||||
username: session.username(),
|
|
||||||
token: session.token(), // Not "password"!
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const userManager = new UserManager();
|
const userManager = new UserManager();
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
const { config } = window;
|
const config = window.config;
|
||||||
|
|
||||||
// The backend returns an empty base_url for the config struct,
|
// The backend returns an empty base_url for the config struct,
|
||||||
// so the frontend (hey, that's us!) can use the current location.
|
// so the frontend (hey, that's us!) can use the current location.
|
||||||
if (!config.base_url || config.base_url === "") {
|
if (!config.base_url || config.base_url === "") {
|
||||||
config.base_url = window.location.origin;
|
config.base_url = window.location.origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import Dexie from "dexie";
|
import Dexie from 'dexie';
|
||||||
import session from "./Session";
|
import session from "./Session";
|
||||||
|
|
||||||
// Uses Dexie.js
|
// Uses Dexie.js
|
||||||
|
@ -8,14 +8,14 @@ import session from "./Session";
|
||||||
// - As per docs, we only declare the indexable columns, not all columns
|
// - As per docs, we only declare the indexable columns, not all columns
|
||||||
|
|
||||||
// The IndexedDB database name is based on the logged-in user
|
// The IndexedDB database name is based on the logged-in user
|
||||||
const dbName = session.username() ? `ntfy-${session.username()}` : "ntfy";
|
const dbName = (session.username()) ? `ntfy-${session.username()}` : "ntfy";
|
||||||
const db = new Dexie(dbName);
|
const db = new Dexie(dbName);
|
||||||
|
|
||||||
db.version(1).stores({
|
db.version(1).stores({
|
||||||
subscriptions: "&id,baseUrl",
|
subscriptions: '&id,baseUrl',
|
||||||
notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance
|
notifications: '&id,subscriptionId,time,new,[subscriptionId+new]', // compound key for query performance
|
||||||
users: "&baseUrl,username",
|
users: '&baseUrl,username',
|
||||||
prefs: "&key",
|
prefs: '&key'
|
||||||
});
|
});
|
||||||
|
|
||||||
export default db;
|
export default db;
|
||||||
|
|
14499
web/src/app/emojis.js
14499
web/src/app/emojis.js
File diff suppressed because one or more lines are too long
|
@ -1,80 +1,66 @@
|
||||||
/* eslint-disable max-classes-per-file */
|
|
||||||
// This is a subset of, and the counterpart to errors.go
|
// This is a subset of, and the counterpart to errors.go
|
||||||
|
|
||||||
const maybeToJson = async (response) => {
|
export const fetchOrThrow = async (url, options) => {
|
||||||
try {
|
const response = await fetch(url, options);
|
||||||
return await response.json();
|
if (response.status !== 200) {
|
||||||
} catch (e) {
|
await throwAppError(response);
|
||||||
return null;
|
}
|
||||||
}
|
return response; // Promise!
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const throwAppError = async (response) => {
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
console.log(`[Error] HTTP ${response.status}`, response);
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
}
|
||||||
|
const error = await maybeToJson(response);
|
||||||
|
if (error?.code) {
|
||||||
|
console.log(`[Error] HTTP ${response.status}, ntfy error ${error.code}: ${error.error || ""}`, response);
|
||||||
|
if (error.code === UserExistsError.CODE) {
|
||||||
|
throw new UserExistsError();
|
||||||
|
} else if (error.code === TopicReservedError.CODE) {
|
||||||
|
throw new TopicReservedError();
|
||||||
|
} else if (error.code === AccountCreateLimitReachedError.CODE) {
|
||||||
|
throw new AccountCreateLimitReachedError();
|
||||||
|
} else if (error.code === IncorrectPasswordError.CODE) {
|
||||||
|
throw new IncorrectPasswordError();
|
||||||
|
} else if (error?.error) {
|
||||||
|
throw new Error(`Error ${error.code}: ${error.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[Error] HTTP ${response.status}, not a ntfy error`, response);
|
||||||
|
throw new Error(`Unexpected response ${response.status}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const maybeToJson = async (response) => {
|
||||||
|
try {
|
||||||
|
return await response.json();
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class UnauthorizedError extends Error {
|
export class UnauthorizedError extends Error {
|
||||||
constructor() {
|
constructor() { super("Unauthorized"); }
|
||||||
super("Unauthorized");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UserExistsError extends Error {
|
export class UserExistsError extends Error {
|
||||||
static CODE = 40901; // errHTTPConflictUserExists
|
static CODE = 40901; // errHTTPConflictUserExists
|
||||||
|
constructor() { super("Username already exists"); }
|
||||||
constructor() {
|
|
||||||
super("Username already exists");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TopicReservedError extends Error {
|
export class TopicReservedError extends Error {
|
||||||
static CODE = 40902; // errHTTPConflictTopicReserved
|
static CODE = 40902; // errHTTPConflictTopicReserved
|
||||||
|
constructor() { super("Topic already reserved"); }
|
||||||
constructor() {
|
|
||||||
super("Topic already reserved");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AccountCreateLimitReachedError extends Error {
|
export class AccountCreateLimitReachedError extends Error {
|
||||||
static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation
|
static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation
|
||||||
|
constructor() { super("Account creation limit reached"); }
|
||||||
constructor() {
|
|
||||||
super("Account creation limit reached");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IncorrectPasswordError extends Error {
|
export class IncorrectPasswordError extends Error {
|
||||||
static CODE = 40026; // errHTTPBadRequestIncorrectPasswordConfirmation
|
static CODE = 40026; // errHTTPBadRequestIncorrectPasswordConfirmation
|
||||||
|
constructor() { super("Password incorrect"); }
|
||||||
constructor() {
|
|
||||||
super("Password incorrect");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const throwAppError = async (response) => {
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
|
||||||
console.log(`[Error] HTTP ${response.status}`, response);
|
|
||||||
throw new UnauthorizedError();
|
|
||||||
}
|
|
||||||
const error = await maybeToJson(response);
|
|
||||||
if (error?.code) {
|
|
||||||
console.log(`[Error] HTTP ${response.status}, ntfy error ${error.code}: ${error.error || ""}`, response);
|
|
||||||
if (error.code === UserExistsError.CODE) {
|
|
||||||
throw new UserExistsError();
|
|
||||||
} else if (error.code === TopicReservedError.CODE) {
|
|
||||||
throw new TopicReservedError();
|
|
||||||
} else if (error.code === AccountCreateLimitReachedError.CODE) {
|
|
||||||
throw new AccountCreateLimitReachedError();
|
|
||||||
} else if (error.code === IncorrectPasswordError.CODE) {
|
|
||||||
throw new IncorrectPasswordError();
|
|
||||||
} else if (error?.error) {
|
|
||||||
throw new Error(`Error ${error.code}: ${error.error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`[Error] HTTP ${response.status}, not a ntfy error`, response);
|
|
||||||
throw new Error(`Unexpected response ${response.status}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchOrThrow = async (url, options) => {
|
|
||||||
const response = await fetch(url, options);
|
|
||||||
if (response.status !== 200) {
|
|
||||||
await throwAppError(response);
|
|
||||||
}
|
|
||||||
return response; // Promise!
|
|
||||||
};
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { Base64 } from "js-base64";
|
import {rawEmojis} from "./emojis";
|
||||||
import { rawEmojis } from "./emojis";
|
|
||||||
import beep from "../sounds/beep.mp3";
|
import beep from "../sounds/beep.mp3";
|
||||||
import juntos from "../sounds/juntos.mp3";
|
import juntos from "../sounds/juntos.mp3";
|
||||||
import pristine from "../sounds/pristine.mp3";
|
import pristine from "../sounds/pristine.mp3";
|
||||||
|
@ -8,14 +7,12 @@ import dadum from "../sounds/dadum.mp3";
|
||||||
import pop from "../sounds/pop.mp3";
|
import pop from "../sounds/pop.mp3";
|
||||||
import popSwoosh from "../sounds/pop-swoosh.mp3";
|
import popSwoosh from "../sounds/pop-swoosh.mp3";
|
||||||
import config from "./config";
|
import config from "./config";
|
||||||
|
import {Base64} from 'js-base64';
|
||||||
|
|
||||||
export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
|
|
||||||
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
|
|
||||||
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
|
|
||||||
export const expandSecureUrl = (url) => `https://${url}`;
|
|
||||||
export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`;
|
export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`;
|
||||||
export const topicUrlWs = (baseUrl, topic) =>
|
export const topicUrlWs = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/ws`
|
||||||
`${topicUrl(baseUrl, topic)}/ws`.replaceAll("https://", "wss://").replaceAll("http://", "ws://");
|
.replaceAll("https://", "wss://")
|
||||||
|
.replaceAll("http://", "ws://");
|
||||||
export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/json`;
|
export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/json`;
|
||||||
export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`;
|
export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`;
|
||||||
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
|
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
|
||||||
|
@ -30,261 +27,276 @@ export const accountReservationUrl = (baseUrl) => `${baseUrl}/v1/account/reserva
|
||||||
export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/account/reservation/${topic}`;
|
export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/account/reservation/${topic}`;
|
||||||
export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`;
|
export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`;
|
||||||
export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`;
|
export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`;
|
||||||
export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`;
|
export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
|
||||||
export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`;
|
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
|
||||||
|
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
|
||||||
|
export const expandSecureUrl = (url) => `https://${url}`;
|
||||||
|
|
||||||
export const validUrl = (url) => url.match(/^https?:\/\/.+/);
|
export const validUrl = (url) => {
|
||||||
|
return url.match(/^https?:\/\/.+/);
|
||||||
export const disallowedTopic = (topic) => config.disallowed_topics.includes(topic);
|
}
|
||||||
|
|
||||||
export const validTopic = (topic) => {
|
export const validTopic = (topic) => {
|
||||||
if (disallowedTopic(topic)) {
|
if (disallowedTopic(topic)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app!
|
return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app!
|
||||||
};
|
}
|
||||||
|
|
||||||
|
export const disallowedTopic = (topic) => {
|
||||||
|
return config.disallowed_topics.includes(topic);
|
||||||
|
}
|
||||||
|
|
||||||
export const topicDisplayName = (subscription) => {
|
export const topicDisplayName = (subscription) => {
|
||||||
if (subscription.displayName) {
|
if (subscription.displayName) {
|
||||||
return subscription.displayName;
|
return subscription.displayName;
|
||||||
}
|
} else if (subscription.baseUrl === config.base_url) {
|
||||||
if (subscription.baseUrl === config.base_url) {
|
return subscription.topic;
|
||||||
return subscription.topic;
|
}
|
||||||
}
|
return topicShortUrl(subscription.baseUrl, subscription.topic);
|
||||||
return topicShortUrl(subscription.baseUrl, subscription.topic);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Format emojis (see emoji.js)
|
// Format emojis (see emoji.js)
|
||||||
const emojis = {};
|
const emojis = {};
|
||||||
rawEmojis.forEach((emoji) => {
|
rawEmojis.forEach(emoji => {
|
||||||
emoji.aliases.forEach((alias) => {
|
emoji.aliases.forEach(alias => {
|
||||||
emojis[alias] = emoji.emoji;
|
emojis[alias] = emoji.emoji;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const toEmojis = (tags) => {
|
const toEmojis = (tags) => {
|
||||||
if (!tags) return [];
|
if (!tags) return [];
|
||||||
return tags.filter((tag) => tag in emojis).map((tag) => emojis[tag]);
|
else return tags.filter(tag => tag in emojis).map(tag => emojis[tag]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatTitleWithDefault = (m, fallback) => {
|
||||||
|
if (m.title) {
|
||||||
|
return formatTitle(m);
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatTitle = (m) => {
|
export const formatTitle = (m) => {
|
||||||
const emojiList = toEmojis(m.tags);
|
const emojiList = toEmojis(m.tags);
|
||||||
if (emojiList.length > 0) {
|
if (emojiList.length > 0) {
|
||||||
return `${emojiList.join(" ")} ${m.title}`;
|
return `${emojiList.join(" ")} ${m.title}`;
|
||||||
}
|
} else {
|
||||||
return m.title;
|
return m.title;
|
||||||
};
|
}
|
||||||
|
|
||||||
export const formatTitleWithDefault = (m, fallback) => {
|
|
||||||
if (m.title) {
|
|
||||||
return formatTitle(m);
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatMessage = (m) => {
|
export const formatMessage = (m) => {
|
||||||
if (m.title) {
|
if (m.title) {
|
||||||
return m.message;
|
return m.message;
|
||||||
}
|
} else {
|
||||||
const emojiList = toEmojis(m.tags);
|
const emojiList = toEmojis(m.tags);
|
||||||
if (emojiList.length > 0) {
|
if (emojiList.length > 0) {
|
||||||
return `${emojiList.join(" ")} ${m.message}`;
|
return `${emojiList.join(" ")} ${m.message}`;
|
||||||
}
|
} else {
|
||||||
return m.message;
|
return m.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const unmatchedTags = (tags) => {
|
export const unmatchedTags = (tags) => {
|
||||||
if (!tags) return [];
|
if (!tags) return [];
|
||||||
return tags.filter((tag) => !(tag in emojis));
|
else return tags.filter(tag => !(tag in emojis));
|
||||||
};
|
}
|
||||||
|
|
||||||
export const encodeBase64 = (s) => Base64.encode(s);
|
|
||||||
|
|
||||||
export const encodeBase64Url = (s) => Base64.encodeURI(s);
|
|
||||||
|
|
||||||
export const bearerAuth = (token) => `Bearer ${token}`;
|
|
||||||
|
|
||||||
export const basicAuth = (username, password) => `Basic ${encodeBase64(`${username}:${password}`)}`;
|
|
||||||
|
|
||||||
export const withBearerAuth = (headers, token) => ({ ...headers, Authorization: bearerAuth(token) });
|
|
||||||
|
|
||||||
export const maybeWithBearerAuth = (headers, token) => {
|
|
||||||
if (token) {
|
|
||||||
return withBearerAuth(headers, token);
|
|
||||||
}
|
|
||||||
return headers;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const withBasicAuth = (headers, username, password) => ({ ...headers, Authorization: basicAuth(username, password) });
|
|
||||||
|
|
||||||
export const maybeWithAuth = (headers, user) => {
|
export const maybeWithAuth = (headers, user) => {
|
||||||
if (user?.password) {
|
if (user && user.password) {
|
||||||
return withBasicAuth(headers, user.username, user.password);
|
return withBasicAuth(headers, user.username, user.password);
|
||||||
}
|
} else if (user && user.token) {
|
||||||
if (user?.token) {
|
return withBearerAuth(headers, user.token);
|
||||||
return withBearerAuth(headers, user.token);
|
}
|
||||||
}
|
return headers;
|
||||||
return headers;
|
}
|
||||||
};
|
|
||||||
|
export const maybeWithBearerAuth = (headers, token) => {
|
||||||
|
if (token) {
|
||||||
|
return withBearerAuth(headers, token);
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const withBasicAuth = (headers, username, password) => {
|
||||||
|
headers['Authorization'] = basicAuth(username, password);
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const basicAuth = (username, password) => {
|
||||||
|
return `Basic ${encodeBase64(`${username}:${password}`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const withBearerAuth = (headers, token) => {
|
||||||
|
headers['Authorization'] = bearerAuth(token);
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bearerAuth = (token) => {
|
||||||
|
return `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const encodeBase64 = (s) => {
|
||||||
|
return Base64.encode(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const encodeBase64Url = (s) => {
|
||||||
|
return Base64.encodeURI(s);
|
||||||
|
}
|
||||||
|
|
||||||
export const maybeAppendActionErrors = (message, notification) => {
|
export const maybeAppendActionErrors = (message, notification) => {
|
||||||
const actionErrors = (notification.actions ?? [])
|
const actionErrors = (notification.actions ?? [])
|
||||||
.map((action) => action.error)
|
.map(action => action.error)
|
||||||
.filter((action) => !!action)
|
.filter(action => !!action)
|
||||||
.join("\n");
|
.join("\n")
|
||||||
if (actionErrors.length === 0) {
|
if (actionErrors.length === 0) {
|
||||||
return message;
|
return message;
|
||||||
}
|
} else {
|
||||||
return `${message}\n\n${actionErrors}`;
|
return `${message}\n\n${actionErrors}`;
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const shuffle = (arr) => {
|
export const shuffle = (arr) => {
|
||||||
const returnArr = [...arr];
|
let j, x;
|
||||||
|
for (let index = arr.length - 1; index > 0; index--) {
|
||||||
|
j = Math.floor(Math.random() * (index + 1));
|
||||||
|
x = arr[index];
|
||||||
|
arr[index] = arr[j];
|
||||||
|
arr[j] = x;
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
for (let index = returnArr.length - 1; index > 0; index -= 1) {
|
export const splitNoEmpty = (s, delimiter) => {
|
||||||
const j = Math.floor(Math.random() * (index + 1));
|
return s
|
||||||
[returnArr[index], returnArr[j]] = [returnArr[j], returnArr[index]];
|
.split(delimiter)
|
||||||
}
|
.map(x => x.trim())
|
||||||
|
.filter(x => x !== "");
|
||||||
return returnArr;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export const splitNoEmpty = (s, delimiter) =>
|
|
||||||
s
|
|
||||||
.split(delimiter)
|
|
||||||
.map((x) => x.trim())
|
|
||||||
.filter((x) => x !== "");
|
|
||||||
|
|
||||||
/** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */
|
/** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */
|
||||||
export const hashCode = async (s) => {
|
export const hashCode = async (s) => {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < s.length; i += 1) {
|
for (let i = 0; i < s.length; i++) {
|
||||||
const char = s.charCodeAt(i);
|
const char = s.charCodeAt(i);
|
||||||
// eslint-disable-next-line no-bitwise
|
hash = ((hash<<5)-hash)+char;
|
||||||
hash = (hash << 5) - hash + char;
|
hash = hash & hash; // Convert to 32bit integer
|
||||||
// eslint-disable-next-line no-bitwise
|
}
|
||||||
hash &= hash; // Convert to 32bit integer
|
return hash;
|
||||||
}
|
}
|
||||||
return hash;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatShortDateTime = (timestamp) =>
|
export const formatShortDateTime = (timestamp) => {
|
||||||
new Intl.DateTimeFormat("default", {
|
return new Intl.DateTimeFormat('default', {dateStyle: 'short', timeStyle: 'short'})
|
||||||
dateStyle: "short",
|
.format(new Date(timestamp * 1000));
|
||||||
timeStyle: "short",
|
}
|
||||||
}).format(new Date(timestamp * 1000));
|
|
||||||
|
|
||||||
export const formatShortDate = (timestamp) => new Intl.DateTimeFormat("default", { dateStyle: "short" }).format(new Date(timestamp * 1000));
|
export const formatShortDate = (timestamp) => {
|
||||||
|
return new Intl.DateTimeFormat('default', {dateStyle: 'short'})
|
||||||
|
.format(new Date(timestamp * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
export const formatBytes = (bytes, decimals = 2) => {
|
export const formatBytes = (bytes, decimals = 2) => {
|
||||||
if (bytes === 0) return "0 bytes";
|
if (bytes === 0) return '0 bytes';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const dm = decimals < 0 ? 0 : decimals;
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
const sizes = ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
};
|
}
|
||||||
|
|
||||||
export const formatNumber = (n) => {
|
export const formatNumber = (n) => {
|
||||||
if (n === 0) {
|
if (n % 1000 === 0) {
|
||||||
|
return `${n/1000}k`;
|
||||||
|
}
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
if (n % 1000 === 0) {
|
|
||||||
return `${n / 1000}k`;
|
|
||||||
}
|
|
||||||
return n.toLocaleString();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatPrice = (n) => {
|
export const formatPrice = (n) => {
|
||||||
if (n % 100 === 0) {
|
if (n % 100 === 0) {
|
||||||
return `$${n / 100}`;
|
return `$${n/100}`;
|
||||||
}
|
}
|
||||||
return `$${(n / 100).toPrecision(2)}`;
|
return `$${(n/100).toPrecision(2)}`;
|
||||||
};
|
}
|
||||||
|
|
||||||
export const openUrl = (url) => {
|
export const openUrl = (url) => {
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sounds = {
|
export const sounds = {
|
||||||
ding: {
|
"ding": {
|
||||||
file: ding,
|
file: ding,
|
||||||
label: "Ding",
|
label: "Ding"
|
||||||
},
|
},
|
||||||
juntos: {
|
"juntos": {
|
||||||
file: juntos,
|
file: juntos,
|
||||||
label: "Juntos",
|
label: "Juntos"
|
||||||
},
|
},
|
||||||
pristine: {
|
"pristine": {
|
||||||
file: pristine,
|
file: pristine,
|
||||||
label: "Pristine",
|
label: "Pristine"
|
||||||
},
|
},
|
||||||
dadum: {
|
"dadum": {
|
||||||
file: dadum,
|
file: dadum,
|
||||||
label: "Dadum",
|
label: "Dadum"
|
||||||
},
|
},
|
||||||
pop: {
|
"pop": {
|
||||||
file: pop,
|
file: pop,
|
||||||
label: "Pop",
|
label: "Pop"
|
||||||
},
|
},
|
||||||
"pop-swoosh": {
|
"pop-swoosh": {
|
||||||
file: popSwoosh,
|
file: popSwoosh,
|
||||||
label: "Pop swoosh",
|
label: "Pop swoosh"
|
||||||
},
|
},
|
||||||
beep: {
|
"beep": {
|
||||||
file: beep,
|
file: beep,
|
||||||
label: "Beep",
|
label: "Beep"
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const playSound = async (id) => {
|
export const playSound = async (id) => {
|
||||||
const audio = new Audio(sounds[id].file);
|
const audio = new Audio(sounds[id].file);
|
||||||
return audio.play();
|
return audio.play();
|
||||||
};
|
};
|
||||||
|
|
||||||
// From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
|
// From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
|
||||||
// eslint-disable-next-line func-style
|
|
||||||
export async function* fetchLinesIterator(fileURL, headers) {
|
export async function* fetchLinesIterator(fileURL, headers) {
|
||||||
const utf8Decoder = new TextDecoder("utf-8");
|
const utf8Decoder = new TextDecoder('utf-8');
|
||||||
const response = await fetch(fileURL, {
|
const response = await fetch(fileURL, {
|
||||||
headers,
|
headers: headers
|
||||||
});
|
});
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
let { value: chunk, done: readerDone } = await reader.read();
|
let { value: chunk, done: readerDone } = await reader.read();
|
||||||
chunk = chunk ? utf8Decoder.decode(chunk) : "";
|
chunk = chunk ? utf8Decoder.decode(chunk) : '';
|
||||||
|
|
||||||
const re = /\n|\r|\r\n/gm;
|
const re = /\n|\r|\r\n/gm;
|
||||||
let startIndex = 0;
|
let startIndex = 0;
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const result = re.exec(chunk);
|
let result = re.exec(chunk);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
if (readerDone) {
|
if (readerDone) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const remainder = chunk.substr(startIndex);
|
let remainder = chunk.substr(startIndex);
|
||||||
// eslint-disable-next-line no-await-in-loop
|
({ value: chunk, done: readerDone } = await reader.read());
|
||||||
({ value: chunk, done: readerDone } = await reader.read());
|
chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : '');
|
||||||
chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : "");
|
startIndex = re.lastIndex = 0;
|
||||||
startIndex = 0;
|
continue;
|
||||||
re.lastIndex = 0;
|
}
|
||||||
// eslint-disable-next-line no-continue
|
yield chunk.substring(startIndex, result.index);
|
||||||
continue;
|
startIndex = re.lastIndex;
|
||||||
|
}
|
||||||
|
if (startIndex < chunk.length) {
|
||||||
|
yield chunk.substr(startIndex); // last line didn't end in a newline char
|
||||||
}
|
}
|
||||||
yield chunk.substring(startIndex, result.index);
|
|
||||||
startIndex = re.lastIndex;
|
|
||||||
}
|
|
||||||
if (startIndex < chunk.length) {
|
|
||||||
yield chunk.substr(startIndex); // last line didn't end in a newline char
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const randomAlphanumericString = (len) => {
|
export const randomAlphanumericString = (len) => {
|
||||||
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||||
let id = "";
|
let id = "";
|
||||||
for (let i = 0; i < len; i += 1) {
|
for (let i = 0; i < len; i++) {
|
||||||
// eslint-disable-next-line no-bitwise
|
id += alphabet[(Math.random() * alphabet.length) | 0];
|
||||||
id += alphabet[(Math.random() * alphabet.length) | 0];
|
}
|
||||||
}
|
return id;
|
||||||
return id;
|
}
|
||||||
};
|
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue