mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-09 13:25:20 +00:00
Compare commits
161
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f56dae54a | ||
|
|
1e4e3b6e36 | ||
|
|
75c687de1c | ||
|
|
432da44dc4 | ||
|
|
703d7bb9de | ||
|
|
98a0daba86 | ||
|
|
ce01b357d8 | ||
|
|
c40a2ce0a7 | ||
|
|
88e598d8b8 | ||
|
|
6869d166ae | ||
|
|
08d81a3645 | ||
|
|
202d858826 | ||
|
|
73e9d46b49 | ||
|
|
9a021aba2d | ||
|
|
812dc4cded | ||
|
|
9f6c4743b3 | ||
|
|
165f012ae6 | ||
|
|
1d69ebaf58 | ||
|
|
a001ac5195 | ||
|
|
bde864756e | ||
|
|
b6d21415bb | ||
|
|
2e3d5babc8 | ||
|
|
b16efd2cb6 | ||
|
|
24f991c6d7 | ||
|
|
7b54850c16 | ||
|
|
be3dbdcb48 | ||
|
|
2eabfd2f01 | ||
|
|
d0054ea394 | ||
|
|
97282d6e3d | ||
|
|
ef7aa31881 | ||
|
|
aaa124973b | ||
|
|
69c2c18a9e | ||
|
|
479e406493 | ||
|
|
6429d36708 | ||
|
|
73f771dafa | ||
|
|
5e1f27d709 | ||
|
|
6ed71dd4d0 | ||
|
|
b4adf85805 | ||
|
|
7c0ab6e6b0 | ||
|
|
e39de727e0 | ||
|
|
8a67b5129e | ||
|
|
f5067d295c | ||
|
|
958ce520ec | ||
|
|
edbf600cd7 | ||
|
|
6ed57ec064 | ||
|
|
e28d8aca59 | ||
|
|
a6f0447482 | ||
|
|
f2b22c114f | ||
|
|
c873064cad | ||
|
|
4c0bd70408 | ||
|
|
9078662bdd | ||
|
|
5970f03973 | ||
|
|
6cbcfd95fa | ||
|
|
6b1339ff4a | ||
|
|
7881b973d7 | ||
|
|
3a5458d237 | ||
|
|
f6ab9e7c58 | ||
|
|
a8ac283353 | ||
|
|
86b077a292 | ||
|
|
d44a566528 | ||
|
|
72b7155130 | ||
|
|
aeacd0a3d6 | ||
|
|
3302ad2479 | ||
|
|
c9105dad09 | ||
|
|
5765be7892 | ||
|
|
7a99bfc717 | ||
|
|
156ad4ae92 | ||
|
|
a6dc691672 | ||
|
|
9114d1e996 | ||
|
|
55d871aeb2 | ||
|
|
a5318a4312 | ||
|
|
f77d299aa3 | ||
|
|
af66cc3ddc | ||
|
|
a47eef5006 | ||
|
|
0ac7cbd4fe | ||
|
|
72ac95148b | ||
|
|
817a0ece7e | ||
|
|
e617c5f453 | ||
|
|
a04a12eb5f | ||
|
|
46799db753 | ||
|
|
5e32f05302 | ||
|
|
ad8391dd31 | ||
|
|
e3889746c4 | ||
|
|
df6f1f3ee1 | ||
|
|
047a1258f1 | ||
|
|
2b1f2d6b9a | ||
|
|
87e9dc9da4 | ||
|
|
5d96888f0e | ||
|
|
65e409e9e4 | ||
|
|
8022401728 | ||
|
|
6d86574a38 | ||
|
|
6a4f68897a | ||
|
|
b0411e5c92 | ||
|
|
c5e5be0746 | ||
|
|
ab2966e70f | ||
|
|
3a960b1b89 | ||
|
|
ad45f70fd1 | ||
|
|
4b070546b5 | ||
|
|
e6a201bc11 | ||
|
|
8327047b71 | ||
|
|
c44575d7a1 | ||
|
|
f121f2ba8d | ||
|
|
0c313906ef | ||
|
|
ab1e170a20 | ||
|
|
fb75a65885 | ||
|
|
74240328dc | ||
|
|
3bfb9f334b | ||
|
|
7b37f2a3eb | ||
|
|
d5b13a925e | ||
|
|
50ac2c1925 | ||
|
|
2b30ce9ee0 | ||
|
|
9b3ab5a302 | ||
|
|
4313b02fc6 | ||
|
|
d8666b66ec | ||
|
|
5808c4d4c0 | ||
|
|
954fae44dc | ||
|
|
1979dbc7c3 | ||
|
|
d7dea6d250 | ||
|
|
eab3988304 | ||
|
|
7494d0acf6 | ||
|
|
914bf3b0c4 | ||
|
|
c45744558b | ||
|
|
74332fa302 | ||
|
|
3d02c99394 | ||
|
|
36d7d3bd24 | ||
|
|
8a7b73cc7e | ||
|
|
bb2ca0facf | ||
|
|
2ee8717e0c | ||
|
|
cac3b2986a | ||
|
|
19af0b65cc | ||
|
|
01eabb288a | ||
|
|
99bc803271 | ||
|
|
d8c87d04e7 | ||
|
|
b75d0e582c | ||
|
|
44d5bcf875 | ||
|
|
fc59339f86 | ||
|
|
5834f667b2 | ||
|
|
9fa8550ef6 | ||
|
|
eff808d0f8 | ||
|
|
ca58b885cb | ||
|
|
1215e99098 | ||
|
|
f558935c1e | ||
|
|
4516adea36 | ||
|
|
33ae31055c | ||
|
|
30dd4840a2 | ||
|
|
fd0f0657b9 | ||
|
|
44dac47d76 | ||
|
|
fd716e4807 | ||
|
|
a0775701c1 | ||
|
|
d824f1a11a | ||
|
|
2d7faac0ea | ||
|
|
d1696ac5b6 | ||
|
|
737163ba59 | ||
|
|
26bc28ae24 | ||
|
|
88fff8264b | ||
|
|
9cc6124f18 | ||
|
|
5ff460f1dc | ||
|
|
9d3019004e | ||
|
|
dbcc89ed8b | ||
|
|
46a5338a30 | ||
|
|
fb89b87efb |
@@ -8,11 +8,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: '1.26.x'
|
||||
go-version-file: '.go-version'
|
||||
- name: Install node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
|
||||
@@ -9,10 +9,10 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout ntfy code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
-
|
||||
name: Checkout docs pages code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
repository: binwiederhier/ntfy-docs.github.io
|
||||
path: build/ntfy-docs.github.io
|
||||
|
||||
@@ -25,11 +25,11 @@ jobs:
|
||||
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: '1.26.x'
|
||||
go-version-file: '.go-version'
|
||||
- name: Install node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
|
||||
@@ -25,11 +25,11 @@ jobs:
|
||||
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: '1.26.x'
|
||||
go-version-file: '.go-version'
|
||||
- name: Install node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
1.26.5
|
||||
@@ -5,7 +5,9 @@ PIP := pip3
|
||||
VERSION := $(shell git describe --tag)
|
||||
COMMIT := $(shell git rev-parse --short HEAD)
|
||||
|
||||
.PHONY:
|
||||
# FORCE is an always-out-of-date target with no recipe; listing it as a prerequisite
|
||||
# forces that target's recipe to run every time (the classic "FORCE target" idiom).
|
||||
FORCE:
|
||||
|
||||
help:
|
||||
@echo "Typical commands (more see below):"
|
||||
@@ -43,6 +45,7 @@ help:
|
||||
@echo " make web-deps - Install web app dependencies (npm install the universe)"
|
||||
@echo " make web-build - Actually build the web app"
|
||||
@echo " make web-lint - Run eslint on the web app"
|
||||
@echo " make web-test - Run vitest unit tests for the web app"
|
||||
@echo " make web-fmt - Run prettier on the web app"
|
||||
@echo " make web-fmt-check - Run prettier on the web app, but don't change anything"
|
||||
@echo
|
||||
@@ -52,7 +55,9 @@ help:
|
||||
@echo " make docs-build - Actually build the documentation"
|
||||
@echo
|
||||
@echo "Test/check:"
|
||||
@echo " make test - Run tests"
|
||||
@echo " make test - Run all tests (Go + web)"
|
||||
@echo " make cli-test - Run Go tests only"
|
||||
@echo " make web-test - Run web app tests only"
|
||||
@echo " make race - Run tests with -race flag"
|
||||
@echo " make coverage - Run tests and show coverage"
|
||||
@echo " make coverage-html - Run tests and show coverage (as HTML)"
|
||||
@@ -82,12 +87,12 @@ help:
|
||||
|
||||
# Building everything
|
||||
|
||||
clean: .PHONY
|
||||
clean: FORCE
|
||||
rm -rf dist build server/docs server/site
|
||||
|
||||
build: web docs cli
|
||||
|
||||
update: web-deps-update cli-deps-update docs-deps-update
|
||||
update: web-deps-update cli-deps-update docs-deps-update go-check
|
||||
docker pull alpine
|
||||
|
||||
docker-dev:
|
||||
@@ -119,7 +124,7 @@ build-deps-ubuntu:
|
||||
|
||||
docs: docs-deps docs-build
|
||||
|
||||
docs-venv: .PHONY
|
||||
docs-venv: FORCE
|
||||
$(PYTHON) -m venv ./venv
|
||||
|
||||
docs-build: docs-venv
|
||||
@@ -128,7 +133,7 @@ docs-build: docs-venv
|
||||
docs-deps: docs-venv
|
||||
(. venv/bin/activate && $(PIP) install -r requirements.txt)
|
||||
|
||||
docs-deps-update: .PHONY
|
||||
docs-deps-update: FORCE
|
||||
(. venv/bin/activate && $(PIP) install -r requirements.txt --upgrade)
|
||||
|
||||
|
||||
@@ -163,6 +168,9 @@ web-fmt-check:
|
||||
web-lint:
|
||||
cd web && $(NPM) run lint
|
||||
|
||||
web-test:
|
||||
cd web && $(NPM) run test
|
||||
|
||||
# Main server/client build
|
||||
|
||||
cli: cli-deps
|
||||
@@ -265,17 +273,21 @@ cli-build-results:
|
||||
|
||||
# Test/check targets
|
||||
|
||||
check: test web-fmt-check fmt-check vet web-lint lint staticcheck
|
||||
check: test web-fmt-check fmt-check vet web-lint lint staticcheck template-check go-check
|
||||
|
||||
checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck
|
||||
checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck template-check go-check
|
||||
|
||||
test: .PHONY
|
||||
test: cli-test web-test
|
||||
|
||||
testv: cli-testv web-test
|
||||
|
||||
cli-test: FORCE
|
||||
go test $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
|
||||
|
||||
testv: .PHONY
|
||||
cli-testv: FORCE
|
||||
go test -v $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
|
||||
|
||||
race: .PHONY
|
||||
race: FORCE
|
||||
go test -v -race $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
|
||||
|
||||
coverage:
|
||||
@@ -305,17 +317,73 @@ vet:
|
||||
|
||||
lint:
|
||||
which golint || go install golang.org/x/lint/golint@latest
|
||||
go list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status
|
||||
go list ./... | grep -v /vendor/ | grep -vE 'ntfy/v2/template/gotext' | xargs -L1 golint -set_exit_status
|
||||
|
||||
staticcheck: .PHONY
|
||||
staticcheck: FORCE
|
||||
rm -rf build/staticcheck
|
||||
which staticcheck || go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
mkdir -p build/staticcheck
|
||||
ln -s "go" build/staticcheck/go
|
||||
PATH="$(PWD)/build/staticcheck:$(PATH)" staticcheck ./...
|
||||
PATH="$(PWD)/build/staticcheck:$(PATH)" staticcheck $$(go list ./... | grep -vE 'ntfy/v2/template/gotext')
|
||||
rm -rf build/staticcheck
|
||||
|
||||
|
||||
# Vendored template targets (see template/README.md)
|
||||
|
||||
TEMPLATE_GO_VERSION := go$(shell cat .go-version 2>/dev/null)
|
||||
|
||||
update-template:
|
||||
@if [ "$$(go env GOVERSION)" != "$(TEMPLATE_GO_VERSION)" ]; then \
|
||||
echo "ERROR: local Go $$(go env GOVERSION) != $(TEMPLATE_GO_VERSION) pinned in .go-version."; \
|
||||
echo "Bump .go-version and install that toolchain first: go install golang.org/dl/$(TEMPLATE_GO_VERSION)@latest && $(TEMPLATE_GO_VERSION) download"; \
|
||||
exit 1; \
|
||||
fi
|
||||
src="$$(go env GOROOT)/src"; \
|
||||
rm -f template/gotext/*.go template/gotext/fmtsort/*.go; \
|
||||
for f in $$(go list -f '{{range .GoFiles}}{{.}} {{end}}' text/template); do cp "$$src/text/template/$$f" template/gotext/; done; \
|
||||
for f in $$(go list -f '{{range .GoFiles}}{{.}} {{end}}' internal/fmtsort); do cp "$$src/internal/fmtsort/$$f" template/gotext/fmtsort/; done; \
|
||||
sed -i 's/^package template$$/package gotext/' template/gotext/*.go; \
|
||||
sed -i 's#"internal/fmtsort"#"heckel.io/ntfy/v2/template/gotext/fmtsort"#' template/gotext/*.go; \
|
||||
( cd template/gotext && for p in patches/*.patch; do echo "Applying $$p"; git apply "$$p" || exit 1; done )
|
||||
go env GOVERSION > template/gotext/GENERATED_FROM
|
||||
@echo "Regenerated template/gotext/ from $(TEMPLATE_GO_VERSION) (files enumerated via 'go list'); review with 'git diff'."
|
||||
|
||||
template-check: FORCE
|
||||
@if [ "$$(cat template/gotext/GENERATED_FROM)" != "$(TEMPLATE_GO_VERSION)" ]; then \
|
||||
echo "ERROR: template/gotext was generated from $$(cat template/gotext/GENERATED_FROM), but .go-version pins $(TEMPLATE_GO_VERSION). Run 'make update-template' on the pinned Go."; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if [ "$$(go env GOVERSION)" != "$(TEMPLATE_GO_VERSION)" ]; then \
|
||||
echo "SKIP: local Go $$(go env GOVERSION) != pinned $(TEMPLATE_GO_VERSION); skipping vendored template content check (version marker already verified)."; \
|
||||
exit 0; \
|
||||
fi
|
||||
@tmp=$$(mktemp -d); src="$$(go env GOROOT)/src"; \
|
||||
mkdir -p "$$tmp/gotext/fmtsort"; \
|
||||
for f in $$(go list -f '{{range .GoFiles}}{{.}} {{end}}' text/template); do cp "$$src/text/template/$$f" "$$tmp/gotext/"; done; \
|
||||
for f in $$(go list -f '{{range .GoFiles}}{{.}} {{end}}' internal/fmtsort); do cp "$$src/internal/fmtsort/$$f" "$$tmp/gotext/fmtsort/"; done; \
|
||||
sed -i 's/^package template$$/package gotext/' "$$tmp/gotext/"*.go; \
|
||||
sed -i 's#"internal/fmtsort"#"heckel.io/ntfy/v2/template/gotext/fmtsort"#' "$$tmp/gotext/"*.go; \
|
||||
cp template/gotext/patches/*.patch "$$tmp/"; \
|
||||
( cd "$$tmp/gotext" && for p in "$$tmp"/*.patch; do git apply "$$p" || exit 1; done ); \
|
||||
if diff -rq -x 'README.md' -x 'GENERATED_FROM' -x 'patches' "$$tmp/gotext" template/gotext >/dev/null 2>&1; then \
|
||||
rm -rf "$$tmp"; \
|
||||
else \
|
||||
echo "ERROR: template/gotext/ drifted from GOROOT+patches (or its file set changed). Run 'make update-template' on Go $(TEMPLATE_GO_VERSION):"; \
|
||||
diff -rq -x 'README.md' -x 'GENERATED_FROM' -x 'patches' "$$tmp/gotext" template/gotext; \
|
||||
rm -rf "$$tmp"; exit 1; \
|
||||
fi
|
||||
|
||||
# go-check is advisory only (never fails): it warns when the pinned Go (.go-version) is behind the
|
||||
# latest upstream release, so template/gotext doesn't silently fall behind on text/template fixes.
|
||||
go-check: FORCE
|
||||
@latest=$$(curl -s --max-time 10 'https://go.dev/VERSION?m=text' 2>/dev/null | head -1); \
|
||||
if [ -n "$$latest" ] && [ "$$latest" != "$(TEMPLATE_GO_VERSION)" ]; then \
|
||||
echo ""; \
|
||||
echo "note: latest Go is $$latest, but template/gotext is pinned to $(TEMPLATE_GO_VERSION) (.go-version)."; \
|
||||
echo " to bump: install $$latest, set .go-version to $${latest#go}, then run 'make update-template'."; \
|
||||
fi
|
||||
|
||||
|
||||
# Releasing targets
|
||||
|
||||
release: clean cli-deps release-checks docs web check
|
||||
@@ -326,6 +394,10 @@ release-snapshot: clean cli-deps docs web check
|
||||
|
||||
release-checks:
|
||||
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
|
||||
if [ "$$(go env GOVERSION)" != "go$$(cat .go-version)" ]; then\
|
||||
echo "ERROR: releases must use the pinned Go toolchain (go$$(cat .go-version) from .go-version), but this is $$(go env GOVERSION). This also ensures 'make check' enforces (not skips) the template/gotext drift check.";\
|
||||
exit 1;\
|
||||
fi
|
||||
if ! grep -q $(LATEST_TAG) docs/install.md; then\
|
||||
echo "ERROR: Must update docs/install.md with latest tag first.";\
|
||||
exit 1;\
|
||||
|
||||
@@ -84,8 +84,6 @@ Thank you to our commercial sponsors, who help keep the service running and the
|
||||
|
||||
<a href="https://m.do.co/c/442b929528db"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px"></a>
|
||||
|
||||
<a href="https://www.magicbell.com/?utm_source=ntfy"><img src="assets/sponsors/magicbell.png" width="180px"></a>
|
||||
|
||||
<a href="https://go.warp.dev/ntfy"><img src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Logos/Warp-Wordmark-Black.png" width="160px"></a>
|
||||
|
||||
And a big fat **Thank You** to the individuals who have sponsored ntfy in the past, or are still sponsoring ntfy:
|
||||
@@ -268,6 +266,7 @@ Third-party libraries and resources:
|
||||
* [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (MIT) is used to provide the persistent message cache
|
||||
* [Firebase Admin SDK](https://github.com/firebase/firebase-admin-go) (Apache 2.0) is used to send FCM messages
|
||||
* [github/gemoji](https://github.com/github/gemoji) (MIT) is used for emoji support (specifically the [emoji.json](https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json) file)
|
||||
* Go's [text/template](https://pkg.go.dev/text/template) (BSD-3-Clause) is vendored under [template/gotext/](template/gotext/) with a small patch adding an execution deadline (see [template/gotext/README.md](template/gotext/README.md))
|
||||
* [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox) as a lightbox on the landing page
|
||||
* [HTTP middleware for gzip compression](https://gist.github.com/CJEnright/bc2d8b8dc0c1389a9feeddb110f822d7) (MIT) is used for serving static files
|
||||
* [Regex for auto-linking](https://github.com/bryanwoods/autolink-js) (MIT) is used to highlight links (the library is not used)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
package server
|
||||
// Package action parses the "action buttons" that can be attached to a notification, in both the
|
||||
// JSON and the human-readable "simple" format described at https://ntfy.sh/docs/publish/#action-buttons.
|
||||
package action
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -37,10 +39,10 @@ type actionParser struct {
|
||||
pos int
|
||||
}
|
||||
|
||||
// parseActions parses the actions string as described in https://ntfy.sh/docs/publish/#action-buttons.
|
||||
// Parse parses the actions string as described in https://ntfy.sh/docs/publish/#action-buttons.
|
||||
// It supports both a JSON representation (if the string begins with "[", see parseActionsFromJSON),
|
||||
// and the "simple" format, which is more human-readable, but harder to parse (see parseActionsFromSimple).
|
||||
func parseActions(s string) (actions []*model.Action, err error) {
|
||||
func Parse(s string) (actions []*model.Action, err error) {
|
||||
// Parse JSON or simple format
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "[") {
|
||||
@@ -1,4 +1,4 @@
|
||||
package server
|
||||
package action
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -7,12 +7,12 @@ import (
|
||||
)
|
||||
|
||||
func TestParseActions(t *testing.T) {
|
||||
actions, err := parseActions("[]")
|
||||
actions, err := Parse("[]")
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, actions)
|
||||
|
||||
// Basic test
|
||||
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open; view, Show portal, https://door.lan")
|
||||
actions, err = Parse("action=http, label=Open door, url=https://door.lan/open; view, Show portal, https://door.lan")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -23,7 +23,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, "https://door.lan", actions[1].URL)
|
||||
|
||||
// JSON
|
||||
actions, err = parseActions(`[{"action":"http","label":"Open door","url":"https://door.lan/open"}, {"action":"view","label":"Show portal","url":"https://door.lan"}]`)
|
||||
actions, err = Parse(`[{"action":"http","label":"Open door","url":"https://door.lan/open"}, {"action":"view","label":"Show portal","url":"https://door.lan"}]`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -34,7 +34,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, "https://door.lan", actions[1].URL)
|
||||
|
||||
// Other params
|
||||
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open, body=this is a body, method=PUT")
|
||||
actions, err = Parse("action=http, label=Open door, url=https://door.lan/open, body=this is a body, method=PUT")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -44,7 +44,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, "this is a body", actions[0].Body)
|
||||
|
||||
// Extras with underscores
|
||||
actions, err = parseActions("action=broadcast, label=Do a thing, extras.command=some command, extras.some_param=a parameter")
|
||||
actions, err = Parse("action=broadcast, label=Do a thing, extras.command=some command, extras.some_param=a parameter")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "broadcast", actions[0].Action)
|
||||
@@ -54,7 +54,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, "a parameter", actions[0].Extras["some_param"])
|
||||
|
||||
// Broadcast action with intent
|
||||
actions, err = parseActions("action=broadcast, label=Do a thing, intent=io.heckel.ntfy.TEST_INTENT")
|
||||
actions, err = Parse("action=broadcast, label=Do a thing, intent=io.heckel.ntfy.TEST_INTENT")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "broadcast", actions[0].Action)
|
||||
@@ -62,7 +62,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, "io.heckel.ntfy.TEST_INTENT", actions[0].Intent)
|
||||
|
||||
// Headers with dashes
|
||||
actions, err = parseActions("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
|
||||
actions, err = Parse("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -72,7 +72,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, "Basic sdasffsf", actions[0].Headers["Authorization"])
|
||||
|
||||
// Quotes
|
||||
actions, err = parseActions(`action=http, "Look ma, \"quotes\"; and semicolons", url=http://example.com`)
|
||||
actions, err = Parse(`action=http, "Look ma, \"quotes\"; and semicolons", url=http://example.com`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -80,7 +80,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, `http://example.com`, actions[0].URL)
|
||||
|
||||
// Single quotes
|
||||
actions, err = parseActions(`action=http, '"quotes" and \'single quotes\'', url=http://example.com`)
|
||||
actions, err = Parse(`action=http, '"quotes" and \'single quotes\'', url=http://example.com`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -88,7 +88,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, `http://example.com`, actions[0].URL)
|
||||
|
||||
// Single quotes (JSON)
|
||||
actions, err = parseActions(`action=http, Post it, url=http://example.com, body='{"temperature": 65}'`)
|
||||
actions, err = Parse(`action=http, Post it, url=http://example.com, body='{"temperature": 65}'`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -97,7 +97,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, `{"temperature": 65}`, actions[0].Body)
|
||||
|
||||
// Out of order
|
||||
actions, err = parseActions(`label="Out of order!" , action="http", url=http://example.com`)
|
||||
actions, err = Parse(`label="Out of order!" , action="http", url=http://example.com`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -105,7 +105,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, `http://example.com`, actions[0].URL)
|
||||
|
||||
// Spaces
|
||||
actions, err = parseActions(`action = http, label = 'this is a label', url = "http://google.com"`)
|
||||
actions, err = Parse(`action = http, label = 'this is a label', url = "http://google.com"`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -113,7 +113,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, `http://google.com`, actions[0].URL)
|
||||
|
||||
// Non-ASCII
|
||||
actions, err = parseActions(`action = http, 'Кохайтеся а не воюйте, 💙🫤', url = "http://google.com"`)
|
||||
actions, err = Parse(`action = http, 'Кохайтеся а не воюйте, 💙🫤', url = "http://google.com"`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -121,7 +121,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, `http://google.com`, actions[0].URL)
|
||||
|
||||
// Multiple actions, awkward spacing
|
||||
actions, err = parseActions(`http , 'Make love, not war 💙🫤' , https://ntfy.sh ; view, " yo ", https://x.org, clear=true`)
|
||||
actions, err = Parse(`http , 'Make love, not war 💙🫤' , https://ntfy.sh ; view, " yo ", https://x.org, clear=true`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -134,7 +134,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, true, actions[1].Clear)
|
||||
|
||||
// Copy action (simple format)
|
||||
actions, err = parseActions("copy, Copy code, 1234")
|
||||
actions, err = Parse("copy, Copy code, 1234")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "copy", actions[0].Action)
|
||||
@@ -142,7 +142,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, "1234", actions[0].Value)
|
||||
|
||||
// Copy action (JSON)
|
||||
actions, err = parseActions(`[{"action":"copy","label":"Copy OTP","value":"567890"}]`)
|
||||
actions, err = Parse(`[{"action":"copy","label":"Copy OTP","value":"567890"}]`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "copy", actions[0].Action)
|
||||
@@ -150,7 +150,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, "567890", actions[0].Value)
|
||||
|
||||
// Copy action with clear
|
||||
actions, err = parseActions("copy, Copy code, 1234, clear=true")
|
||||
actions, err = Parse("copy, Copy code, 1234, clear=true")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "copy", actions[0].Action)
|
||||
@@ -159,7 +159,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, true, actions[0].Clear)
|
||||
|
||||
// Copy action with explicit value key
|
||||
actions, err = parseActions("action=copy, label=Copy token, clear=true, value=abc-123-def")
|
||||
actions, err = Parse("action=copy, label=Copy token, clear=true, value=abc-123-def")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "copy", actions[0].Action)
|
||||
@@ -168,56 +168,56 @@ func TestParseActions(t *testing.T) {
|
||||
require.True(t, actions[0].Clear)
|
||||
|
||||
// Copy action without value (error)
|
||||
_, err = parseActions("copy, Copy code")
|
||||
_, err = Parse("copy, Copy code")
|
||||
require.EqualError(t, err, "parameter 'value' is required for action 'copy'")
|
||||
|
||||
// Invalid syntax
|
||||
_, err = parseActions(`label="Out of order!" x, action="http", url=http://example.com`)
|
||||
_, err = Parse(`label="Out of order!" x, action="http", url=http://example.com`)
|
||||
require.EqualError(t, err, "unexpected character 'x' at position 22")
|
||||
|
||||
_, err = parseActions(`label="", action="http", url=http://example.com`)
|
||||
_, err = Parse(`label="", action="http", url=http://example.com`)
|
||||
require.EqualError(t, err, "parameter 'label' is required")
|
||||
|
||||
_, err = parseActions(`label=, action="http", url=http://example.com`)
|
||||
_, err = Parse(`label=, action="http", url=http://example.com`)
|
||||
require.EqualError(t, err, "parameter 'label' is required")
|
||||
|
||||
_, err = parseActions(`label="xx", action="http", url=http://example.com, what is this anyway`)
|
||||
_, err = Parse(`label="xx", action="http", url=http://example.com, what is this anyway`)
|
||||
require.EqualError(t, err, "term 'what is this anyway' unknown")
|
||||
|
||||
_, err = parseActions(`fdsfdsf`)
|
||||
_, err = Parse(`fdsfdsf`)
|
||||
require.EqualError(t, err, "parameter 'action' cannot be 'fdsfdsf', valid values are 'view', 'broadcast', 'http' and 'copy'")
|
||||
|
||||
_, err = parseActions(`aaa=a, "bbb, 'ccc, ddd, eee "`)
|
||||
_, err = Parse(`aaa=a, "bbb, 'ccc, ddd, eee "`)
|
||||
require.EqualError(t, err, "key 'aaa' unknown")
|
||||
|
||||
_, err = parseActions(`action=http, label="omg the end quote is missing`)
|
||||
_, err = Parse(`action=http, label="omg the end quote is missing`)
|
||||
require.EqualError(t, err, "unexpected end of input, quote started at position 20")
|
||||
|
||||
_, err = parseActions(`;;;;`)
|
||||
_, err = Parse(`;;;;`)
|
||||
require.EqualError(t, err, "only 3 actions allowed")
|
||||
|
||||
_, err = parseActions(`,,,,,,;;`)
|
||||
_, err = Parse(`,,,,,,;;`)
|
||||
require.EqualError(t, err, "term '' unknown")
|
||||
|
||||
_, err = parseActions(`''";,;"`)
|
||||
_, err = Parse(`''";,;"`)
|
||||
require.EqualError(t, err, "unexpected character '\"' at position 2")
|
||||
|
||||
_, err = parseActions(`action=http, label=a label, body=somebody`)
|
||||
_, err = Parse(`action=http, label=a label, body=somebody`)
|
||||
require.EqualError(t, err, "parameter 'url' is required for action 'http'")
|
||||
|
||||
_, err = parseActions(`action=http, label=a label, url=http://ntfy.sh, method=HEAD, body=somebody`)
|
||||
_, err = Parse(`action=http, label=a label, url=http://ntfy.sh, method=HEAD, body=somebody`)
|
||||
require.EqualError(t, err, "parameter 'body' cannot be set if method is HEAD")
|
||||
|
||||
_, err = parseActions(`[ invalid json ]`)
|
||||
_, err = Parse(`[ invalid json ]`)
|
||||
require.EqualError(t, err, "JSON error: invalid character 'i' looking for beginning of value")
|
||||
|
||||
_, err = parseActions(`[ { "some": "object" } ]`)
|
||||
_, err = Parse(`[ { "some": "object" } ]`)
|
||||
require.EqualError(t, err, "parameter 'action' cannot be '', valid values are 'view', 'broadcast', 'http' and 'copy'")
|
||||
|
||||
_, err = parseActions("\x00\x01\xFFx\xFE")
|
||||
_, err = Parse("\x00\x01\xFFx\xFE")
|
||||
require.EqualError(t, err, "invalid utf-8 string")
|
||||
|
||||
_, err = parseActions(`http, label, http://x.org, clear=x`)
|
||||
_, err = Parse(`http, label, http://x.org, clear=x`)
|
||||
require.EqualError(t, err, "parameter 'clear' cannot be 'x', only boolean values are allowed (true/yes/1/false/no/0)")
|
||||
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
+88
-3
@@ -8,11 +8,13 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
"heckel.io/ntfy/v2/mail"
|
||||
"heckel.io/ntfy/v2/server"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
@@ -32,12 +34,17 @@ var flagsUser = append(
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
|
||||
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-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-from", Aliases: []string{"smtp_sender_from"}, EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}),
|
||||
)
|
||||
|
||||
var cmdUser = &cli.Command{
|
||||
Name: "user",
|
||||
Usage: "Manage/show users",
|
||||
UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...",
|
||||
UsageText: "ntfy user [list|add|remove|change-pass|reset-pass|change-role] ...",
|
||||
Flags: flagsUser,
|
||||
Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc),
|
||||
Category: categoryServer,
|
||||
@@ -98,6 +105,30 @@ Example:
|
||||
|
||||
You may set the NTFY_PASSWORD environment variable to pass the new password or NTFY_PASSWORD_HASH to pass
|
||||
directly the bcrypt hash. This is useful if you are updating users via scripts.
|
||||
`,
|
||||
},
|
||||
{
|
||||
Name: "reset-pass",
|
||||
Aliases: []string{"rp"},
|
||||
Usage: "Generates a password reset link for a user",
|
||||
UsageText: "ntfy user reset-pass [--send-email] USERNAME",
|
||||
Action: execUserResetPass,
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{Name: "send-email", Aliases: []string{"e"}, Usage: "also email the reset link to the user's primary email"},
|
||||
},
|
||||
Description: `Generate a password reset link for the given user and print it to stdout.
|
||||
|
||||
The user completes the reset by opening the link in a browser and choosing a new password;
|
||||
the admin never learns or chooses the new password. The link is single-use and expires after
|
||||
one hour. This is an admin override of the self-service reset flow -- unlike self-service, it
|
||||
does not require the user to have a verified primary email (the token is bound to the user).
|
||||
|
||||
With --send-email, the link is additionally emailed to the user's primary email address (this
|
||||
requires SMTP to be configured and the user to have a verified primary email).
|
||||
|
||||
Example:
|
||||
ntfy user reset-pass phil # Print a reset link for user phil
|
||||
ntfy user reset-pass --send-email phil # Print and email the reset link
|
||||
`,
|
||||
},
|
||||
{
|
||||
@@ -257,7 +288,6 @@ func execUserDel(c *cli.Context) error {
|
||||
func execUserChangePass(c *cli.Context) error {
|
||||
username := c.Args().Get(0)
|
||||
password, hashed := os.LookupEnv("NTFY_PASSWORD_HASH")
|
||||
|
||||
if !hashed {
|
||||
password = os.Getenv("NTFY_PASSWORD")
|
||||
}
|
||||
@@ -286,6 +316,61 @@ func execUserChangePass(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func execUserResetPass(c *cli.Context) error {
|
||||
username := c.Args().Get(0)
|
||||
sendEmail := c.Bool("send-email")
|
||||
baseURL := strings.TrimSuffix(c.String("base-url"), "/")
|
||||
if username == "" {
|
||||
return errors.New("username expected, type 'ntfy user reset-pass --help' for help")
|
||||
} else if username == userEveryone || username == user.Everyone {
|
||||
return errors.New("username not allowed")
|
||||
} else if baseURL == "" {
|
||||
return errors.New("base-url must be configured to generate a reset link")
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u, err := manager.User(username)
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
} else if err != nil {
|
||||
return err
|
||||
} else if u.Provisioned {
|
||||
return fmt.Errorf("user %s is provisioned in the config file; its password cannot be reset", username)
|
||||
}
|
||||
// Resolve the primary email up front if we need to send -- fail before creating a token
|
||||
var primaryEmail string
|
||||
if sendEmail {
|
||||
primaryEmail, err = manager.PrimaryEmail(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if primaryEmail == "" {
|
||||
return fmt.Errorf("user %s has no primary email; cannot send reset link (omit --send-email to just print it)", username)
|
||||
}
|
||||
}
|
||||
// The reset token is bound to the user, not an email -- so this works even with no SMTP
|
||||
token, err := manager.AddMagicLink(user.MagicLinkKindPasswordReset, u.ID, "", time.Hour)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
link := baseURL + "/account/password/reset/" + token
|
||||
fmt.Fprintln(c.App.Writer, link)
|
||||
if sendEmail {
|
||||
sender := mail.NewSender(&mail.Config{
|
||||
SMTPAddr: c.String("smtp-sender-addr"),
|
||||
SMTPUser: c.String("smtp-sender-user"),
|
||||
SMTPPass: c.String("smtp-sender-pass"),
|
||||
From: c.String("smtp-sender-from"),
|
||||
})
|
||||
if err := sender.SendPasswordReset(primaryEmail, link); err != nil {
|
||||
return fmt.Errorf("failed to send reset email to %s: %w", primaryEmail, err)
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "reset link emailed to %s\n", primaryEmail)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func execUserChangeRole(c *cli.Context) error {
|
||||
username := c.Args().Get(0)
|
||||
role := user.Role(c.Args().Get(1))
|
||||
@@ -313,7 +398,7 @@ func execUserHash(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := user.HashPassword(password)
|
||||
hash, err := user.HashPassword(password, user.DefaultUserPasswordBcryptCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
@@ -122,6 +122,69 @@ func TestCLI_User_Delete(t *testing.T) {
|
||||
require.Contains(t, err.Error(), "user phil does not exist")
|
||||
}
|
||||
|
||||
func TestCLI_User_ResetPass(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
|
||||
// Prints a working-looking reset link when base-url is set
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "phil"))
|
||||
require.Contains(t, stdout.String(), "https://ntfy.example.com/account/password/reset/")
|
||||
}
|
||||
|
||||
func TestCLI_User_ResetPass_NoBaseURL(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
|
||||
app, _, _, _ = newTestApp()
|
||||
err := runUserCommand(app, conf, "reset-pass", "phil")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "base-url")
|
||||
}
|
||||
|
||||
func TestCLI_User_ResetPass_SendEmailNoPrimary(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
|
||||
// --send-email requires a primary email; phil has none
|
||||
app, _, _, _ = newTestApp()
|
||||
err := runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "--send-email", "phil")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "no primary email")
|
||||
}
|
||||
|
||||
func TestCLI_User_ResetPass_ProvisionedRejected(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
// Seed a provisioned user into the auth database via config provisioning
|
||||
m, err := user.NewSQLiteManager(conf.AuthFile, "", &user.Config{
|
||||
ProvisionEnabled: true,
|
||||
Users: []*user.User{
|
||||
{Name: "provuser", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: user.RoleUser},
|
||||
},
|
||||
})
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, m.Close())
|
||||
|
||||
app, _, _, _ := newTestApp()
|
||||
err = runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "provuser")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "provisioned")
|
||||
}
|
||||
|
||||
func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) {
|
||||
configFile := filepath.Join(t.TempDir(), "server-dummy.yml")
|
||||
require.Nil(t, os.WriteFile(configFile, []byte(""), 0600)) // Dummy config file to avoid lookup of real server.yml
|
||||
|
||||
@@ -90,6 +90,15 @@ func (d *DB) ReadOnly() *sql.DB {
|
||||
return d.primary.DB
|
||||
}
|
||||
|
||||
// MarkReplicasHealthyForTest immediately marks all configured replicas as healthy, bypassing the
|
||||
// async health-check loop's initial delay. It exists so tests can deterministically route
|
||||
// ReadOnly() to a replica without waiting; it is not used in production code.
|
||||
func (d *DB) MarkReplicasHealthyForTest() {
|
||||
for _, r := range d.replicas {
|
||||
r.healthy.Store(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the primary database and all replicas, and stops the health-check goroutine.
|
||||
func (d *DB) Close() error {
|
||||
d.cancel()
|
||||
|
||||
+6
-3
@@ -1047,9 +1047,12 @@ configured for `ntfy.sh`):
|
||||
```
|
||||
|
||||
By default, any user (including anonymous users) can send email notifications to any address. To require email
|
||||
address verification, set `smtp-sender-verify` to `true`. When enabled, anonymous users cannot send emails,
|
||||
and authenticated users can only send to email addresses they have verified in their account settings. Users can
|
||||
also use `yes`/`true`/`1` as the `X-Email` value to send to their first verified address.
|
||||
address verification, set `smtp-sender-verify` to `true`. When enabled, anonymous users cannot send emails, and
|
||||
authenticated users can only send to *literal* email addresses they have verified in their account settings.
|
||||
|
||||
Regardless of this setting, a logged-in user can pass `yes`/`true`/`1` as the `X-Email` value to send to their primary
|
||||
verified address (falling back to their first verified address if no primary is designated). `smtp-sender-verify` only
|
||||
governs whether arbitrary literal addresses are allowed.
|
||||
|
||||
Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst`
|
||||
and `visitor-email-limit-burst`. Setting these conservatively is necessary to avoid abuse.
|
||||
|
||||
+6
-4
@@ -65,8 +65,8 @@ These steps **assume Ubuntu**. Steps may vary on different Linux distributions.
|
||||
|
||||
First, install [Go](https://go.dev/) (see [official instructions](https://go.dev/doc/install)):
|
||||
``` shell
|
||||
wget https://go.dev/dl/go1.19.1.linux-amd64.tar.gz
|
||||
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.19.1.linux-amd64.tar.gz
|
||||
wget https://go.dev/dl/go1.25.8.linux-amd64.tar.gz
|
||||
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.25.8.linux-amd64.tar.gz
|
||||
export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin
|
||||
go version # verifies that it worked
|
||||
```
|
||||
@@ -77,9 +77,11 @@ go install github.com/goreleaser/goreleaser@latest
|
||||
goreleaser -v # verifies that it worked
|
||||
```
|
||||
|
||||
Install [nodejs](https://nodejs.org/en/) (see [official instructions](https://nodejs.org/en/download/package-manager/)):
|
||||
Install [nodejs](https://nodejs.org/en/) (see [official instructions](https://nodejs.org/en/download/package-manager/)).
|
||||
Use a current LTS release (Node 24 is what CI builds with; anything older than Node 20 will not work
|
||||
with the current Vite-based web build):
|
||||
``` shell
|
||||
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
npm -v # verifies that it worked
|
||||
```
|
||||
|
||||
@@ -641,6 +641,32 @@ or by simply providing traccar with a valid username/password combination.
|
||||
<entry key='sms.http.password'>mypass</entry>
|
||||
```
|
||||
|
||||
## Flowtriq DDoS detection
|
||||
[Flowtriq](https://flowtriq.com) is a real-time DDoS detection and mitigation platform. Its Linux agent, ftagent,
|
||||
supports webhook alerts that can POST directly to an ntfy topic, so you get push notifications on your phone
|
||||
whenever an attack is detected.
|
||||
|
||||
Configure the webhook URL in your ftagent configuration to point to your ntfy topic:
|
||||
|
||||
```yaml
|
||||
# /etc/ftagent/ftagent.yml
|
||||
alerts:
|
||||
webhooks:
|
||||
- url: https://ntfy.sh/flowtriq-attacks
|
||||
method: POST
|
||||
```
|
||||
|
||||
You can also use curl to test the integration manually with a sample attack alert:
|
||||
|
||||
```bash
|
||||
curl \
|
||||
-H "Title: DDoS Attack Detected" \
|
||||
-H "Priority: urgent" \
|
||||
-H "Tags: rotating_light" \
|
||||
-d "Attack detected on 203.0.113.5: 14.2 Gbps UDP flood from 3,482 sources" \
|
||||
ntfy.sh/flowtriq-attacks
|
||||
```
|
||||
|
||||
## Terminal Notifications for Long-Running Commands
|
||||
|
||||
This example provides a simple way to send notifications using [ntfy.sh](https://ntfy.sh) when a terminal command completes. It includes success or failure indicators based on the command's exit status.
|
||||
|
||||
+66
-38
@@ -34,37 +34,37 @@ as a service starting at boot time.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.24.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.24.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.24.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.26.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.26.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.24.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.24.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.24.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.26.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.26.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.24.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.24.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.24.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.26.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.26.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.24.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.24.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.24.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.26.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.26.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
@@ -84,25 +84,25 @@ Install the ntfy server unit file (which contains parameters to start the servic
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo mv ntfy_2.24.0_linux_amd64/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.26.0_linux_amd64/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo mv ntfy_2.24.0_linux_armv6/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.26.0_linux_armv6/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo mv ntfy_2.24.0_linux_armv7/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.26.0_linux_armv7/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo mv ntfy_2.24.0_linux_arm64/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.26.0_linux_arm64/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
@@ -118,25 +118,25 @@ Install the ntfy server service script:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo mv ntfy_2.24.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.26.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo mv ntfy_2.24.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.26.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo mv ntfy_2.24.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.26.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo mv ntfy_2.24.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.26.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
@@ -204,7 +204,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_amd64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -212,7 +212,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv6.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv6.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -220,7 +220,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv7.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -228,7 +228,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_arm64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -238,28 +238,28 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_amd64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv6.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv6.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv7.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
@@ -301,18 +301,18 @@ pkg install go-ntfy
|
||||
|
||||
## macOS
|
||||
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.24.0/ntfy_2.24.0_darwin_all.tar.gz),
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_darwin_all.tar.gz),
|
||||
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
|
||||
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
||||
|
||||
```bash
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_darwin_all.tar.gz > ntfy_2.24.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.24.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.24.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_darwin_all.tar.gz > ntfy_2.26.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.26.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.26.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
mkdir ~/Library/Application\ Support/ntfy
|
||||
cp ntfy_2.24.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
cp ntfy_2.26.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
ntfy --help
|
||||
```
|
||||
|
||||
@@ -333,7 +333,7 @@ brew install ntfy
|
||||
The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
|
||||
To install, you can either
|
||||
|
||||
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_windows_amd64.zip),
|
||||
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_windows_amd64.zip),
|
||||
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
||||
* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`
|
||||
|
||||
@@ -726,3 +726,31 @@ kubectl apply -k /ntfy
|
||||
cache-file: "/var/cache/ntfy/cache.db"
|
||||
attachment-cache-dir: "/var/cache/ntfy/attachments"
|
||||
```
|
||||
|
||||
## Helm
|
||||
<span class="community-badge" title="This package is maintained by the community, not the ntfy developers"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg> Community maintained</span>
|
||||
|
||||
If you prefer [Helm](https://helm.sh/), ntfy can be deployed using the third-party
|
||||
[HelmForge chart](https://helmforge.dev/docs/charts/ntfy), which packages the official
|
||||
`binwiederhier/ntfy` image with persistent storage, Service, Ingress, optional Prometheus metrics, and more.
|
||||
This chart is **not** maintained by the ntfy developers.
|
||||
|
||||
!!! warning
|
||||
The HelmForge project is young and maintained by a small community. Review the chart before deploying it,
|
||||
and use it at your own risk.
|
||||
|
||||
```bash
|
||||
helm repo add helmforge https://repo.helmforge.dev
|
||||
helm repo update
|
||||
helm install ntfy helmforge/ntfy
|
||||
```
|
||||
|
||||
Alternatively, install it directly from the OCI registry:
|
||||
|
||||
```bash
|
||||
helm install ntfy oci://ghcr.io/helmforgedev/helm/ntfy
|
||||
```
|
||||
|
||||
Because ntfy's default SQLite storage is single-writer, run the chart as a single instance rather than
|
||||
treating it as a horizontally scalable deployment. See the [chart documentation](https://helmforge.dev/docs/charts/ntfy)
|
||||
for the full list of configurable values.
|
||||
|
||||
@@ -43,6 +43,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [Miniflux](https://miniflux.app/docs/ntfy.html) - Minimalist and opinionated feed reader
|
||||
- [Beszel](https://beszel.dev/guide/notifications/ntfy) - Server monitoring platform
|
||||
- [Simple Observability](https://simpleobservability.com/docs/alerts/ntfy) - Server monitoring and observability platform
|
||||
- [Sifio](https://sifio.net) - Aggregate updates from RSS, social media, and other sources, then deliver them to ntfy, Slack, Notion and more
|
||||
|
||||
## Integration via HTTP/SMTP/etc.
|
||||
|
||||
@@ -54,6 +55,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [Proxmox-Ntfy](https://github.com/qtsone/proxmox-ntfy) - Python script that monitors Proxmox tasks and sends notifications using the Ntfy service.
|
||||
- [Scrutiny](https://github.com/AnalogJ/scrutiny) - WebUI for smartd S.M.A.R.T monitoring. Scrutiny includes shoutrrr/ntfy integration ([see integration README](https://github.com/AnalogJ/scrutiny?tab=readme-ov-file#notifications))
|
||||
- [UptimeObserver](https://uptimeobserver.com) - Uptime Monitoring tool for Websites, APIs, SSL Certificates, DNS, Domain Names and Ports. [Integration Guide](https://support.uptimeobserver.com/integrations/ntfy/)
|
||||
- [Flowtriq](https://flowtriq.com) - Real-time DDoS detection and mitigation platform (integration via [webhook alerts](https://flowtriq.com))
|
||||
|
||||
## [UnifiedPush](https://unifiedpush.org/users/apps/) integrations
|
||||
|
||||
@@ -189,6 +191,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [send_to_ntfy_extension](https://github.com/TheDuffman85/send_to_ntfy_extension/) ⭐ - A browser extension to send the notifications to ntfy (JS)
|
||||
- [SIA-Server](https://github.com/ZebMcKayhan/SIA-Server) - A light weight, self-hosted notification Server for Honywell Galaxy Flex alarm systems (Python)
|
||||
- [zabbix-ntfy](https://github.com/torgrimt/zabbix-ntfy) - Zabbix server Mediatype to add support for ntfy.sh services
|
||||
- [Rubix Notify](https://wordpress.org/plugins/rubix-notify) - WordPress Integration with ntfy (PHP + React).
|
||||
|
||||
## Blog + forum posts
|
||||
|
||||
|
||||
+8
-6
@@ -1,6 +1,6 @@
|
||||
# Privacy policy
|
||||
|
||||
**Last updated:** March 31, 2026
|
||||
**Last updated:** June 15, 2026
|
||||
|
||||
This privacy policy describes how ntfy ("we", "us", or "our") collects, uses, and handles your information
|
||||
when you use the ntfy.sh service, web app, and mobile applications (Android and iOS).
|
||||
@@ -19,8 +19,9 @@ If you create an account on ntfy.sh, we collect:
|
||||
|
||||
- **Username** - A unique identifier you choose
|
||||
- **Password** - Stored as a secure bcrypt hash (we never store your plaintext password)
|
||||
- **Email address** - If you subscribe to a paid plan (for billing purposes via Stripe), or if you add a verified
|
||||
email address for use with the email notification feature
|
||||
- **Email address** - If you add an email address to your account for account recovery and password resets, for use
|
||||
with the email notification feature, or if you subscribe to a paid plan (for billing purposes via Stripe). Email
|
||||
addresses you add to your account are verified by sending a confirmation link.
|
||||
- **Phone number** - Only if you enable the phone call notification feature (verified via SMS/call)
|
||||
|
||||
You can use ntfy without creating an account. Anonymous usage is fully supported.
|
||||
@@ -77,9 +78,10 @@ Your phone number is shared with Twilio to deliver these services. Twilio's
|
||||
|
||||
### Amazon SES (email delivery)
|
||||
|
||||
If you use the email notification feature (`X-Email` header), we use Amazon Simple Email Service (SES) to
|
||||
deliver emails. The recipient email address and message content are transmitted through Amazon's infrastructure.
|
||||
Amazon's [privacy policy](https://aws.amazon.com/privacy/) applies.
|
||||
If you use the email notification feature (`X-Email` header), or when ntfy sends account-related emails (email
|
||||
address verification and password reset links), we use Amazon Simple Email Service (SES) to deliver emails. The
|
||||
recipient email address and message content are transmitted through Amazon's infrastructure. Amazon's
|
||||
[privacy policy](https://aws.amazon.com/privacy/) applies.
|
||||
|
||||
### Stripe (payments)
|
||||
|
||||
|
||||
+91
-9
@@ -1,7 +1,7 @@
|
||||
# Publishing
|
||||
Publishing messages can be done via HTTP PUT/POST or via the [ntfy CLI](subscribe/cli.md#publish-messages) ([install instructions](install.md)).
|
||||
Topics are created on the fly by subscribing or publishing to them. Because there is no sign-up, **the topic is essentially a password**, so pick
|
||||
something that's not easily guessable.
|
||||
something that's not easily guessable (see [picking a topic](#picking-a-topic) for a handy topic name generator).
|
||||
|
||||
Here's an example showing how to publish a simple message using a POST request:
|
||||
|
||||
@@ -308,6 +308,44 @@ an [external image attachment](#attach-file-from-a-url) and [email publishing](#
|
||||
<figcaption>Notification using a click action, a user action, with an external image attachment and forwarded via email</figcaption>
|
||||
</figure>
|
||||
|
||||
## Picking a topic
|
||||
Since there is no sign-up, **the topic is essentially a password**, so pick something that's not easily guessable. Topic names may
|
||||
only contain letters, numbers, underscores and dashes (`[-_A-Za-z0-9]`), and may be up to 64 characters long.
|
||||
|
||||
Not sure what to pick? Type a name below and the generator will add a random, hard-to-guess suffix for you. Everything happens locally in your browser:
|
||||
|
||||
<div id="tg-widget" class="tg-generator">
|
||||
<div class="tg-header">
|
||||
<span class="tg-title">Topic name generator</span>
|
||||
<button type="button" id="tg-reroll" class="tg-reset" title="Generate a new random suffix">Regenerate suffix</button>
|
||||
</div>
|
||||
<div class="tg-body">
|
||||
<div class="tg-left">
|
||||
<div class="tg-field">
|
||||
<label for="tg-input">Type a topic name</label>
|
||||
<input type="text" id="tg-input" placeholder="e.g. backups, alerts, phil-home" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="tg-note">Spaces and characters other than letters, numbers, <code>-</code> and <code>_</code> are removed automatically as you type. Names are capped at 64 characters.</div>
|
||||
</div>
|
||||
<div class="tg-right">
|
||||
<div class="tg-output-row">
|
||||
<span class="tg-output-label">Your topic:</span>
|
||||
<div class="tg-output-line">
|
||||
<pre class="tg-output" id="tg-output-name"></pre>
|
||||
<button type="button" class="tg-btn-copy" data-copy="tg-output-name" title="Copy to clipboard"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tg-output-row">
|
||||
<span class="tg-output-label">Your topic URL:</span>
|
||||
<div class="tg-output-line">
|
||||
<pre class="tg-output" id="tg-output-url">https://ntfy.sh/</pre>
|
||||
<button type="button" class="tg-btn-copy" data-copy="tg-output-url" title="Copy to clipboard"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Message title
|
||||
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||
|
||||
@@ -2777,16 +2815,20 @@ Here's an example of a dead man's switch that sends an alert if the script stops
|
||||
### Canceling scheduled notifications
|
||||
|
||||
You can cancel a scheduled message before it is delivered by sending a DELETE request to the
|
||||
`/<topic>/<sequence_id>` endpoint, just like [deleting notifications](#deleting-notifications). This will remove the
|
||||
scheduled message from the server so it will never be delivered, and emit a `message_delete` event to any subscribers.
|
||||
`/<topic>/<sequence_id>` endpoint, just like [deleting notifications](#deleting-notifications). Alternatively, you can send a `GET`
|
||||
request to `/<topic>/<sequence_id>/delete`. This will remove the scheduled message from the server so it will never be delivered,
|
||||
and emit a `message_delete` event to any subscribers.
|
||||
|
||||
=== "Command line (curl)"
|
||||
```bash
|
||||
# Schedule a reminder for 2 hours from now
|
||||
curl -H "In: 2h" -d "Take a break!" ntfy.sh/mytopic/break-reminder
|
||||
|
||||
# Changed your mind? Cancel the scheduled message
|
||||
# Changed your mind? Cancel the scheduled message via DELETE
|
||||
curl -X DELETE ntfy.sh/mytopic/break-reminder
|
||||
|
||||
# Or cancel it via GET
|
||||
curl ntfy.sh/mytopic/break-reminder/delete
|
||||
```
|
||||
|
||||
=== "ntfy CLI"
|
||||
@@ -3182,6 +3224,11 @@ You can use the following features in your templates:
|
||||
A good way to experiment with Go templates is the **[Go Template Playground](https://repeatit.io)**. It is _highly recommended_ to test
|
||||
your templates there first ([example for Grafana alert](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6InRpdGxlPUdyYWZhbmErYWxlcnQ6K3t7LnRpdGxlfX0mbWVzc2FnZT17ey5tZXNzYWdlfX0iLCJpbnB1dCI6IntcbiAgXCJyZWNlaXZlclwiOiBcIm50ZnlcXFxcLmV4YW1wbGVcXFxcLmNvbS9hbGVydHNcIixcbiAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICBcImFsZXJ0c1wiOiBbXG4gICAge1xuICAgICAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICAgICAgXCJsYWJlbHNcIjoge1xuICAgICAgICBcImFsZXJ0bmFtZVwiOiBcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFwiLFxuICAgICAgICBcImdyYWZhbmFfZm9sZGVyXCI6IFwiTm9kZSBhbGVydHNcIixcbiAgICAgICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgICAgICBcImpvYlwiOiBcIm5vZGUtZXhwb3J0ZXJcIlxuICAgICAgfSxcbiAgICAgIFwiYW5ub3RhdGlvbnNcIjoge1xuICAgICAgICBcInN1bW1hcnlcIjogXCIxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXJ0c0F0XCI6IFwiMjAyNC0wMy0xNVQwMjoyODowMFpcIixcbiAgICAgIFwiZW5kc0F0XCI6IFwiMjAyNC0wMy0xNVQwMjo0MjowMFpcIixcbiAgICAgIFwiZ2VuZXJhdG9yVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvZ3JhZmFuYS9OVzlvRHctNHovdmlld1wiLFxuICAgICAgXCJmaW5nZXJwcmludFwiOiBcImJlY2JmYjk0YmQ4MWVmNDhcIixcbiAgICAgIFwic2lsZW5jZVVSTFwiOiBcImxvY2FsaG9zdDozMDAwL2FsZXJ0aW5nL3NpbGVuY2UvbmV3P2FsZXJ0bWFuYWdlcj1ncmFmYW5hJm1hdGNoZXI9YWxlcnRuYW1lJTNETG9hZCthdmcrMTVtK3RvbytoaWdoJm1hdGNoZXI9Z3JhZmFuYV9mb2xkZXIlM0ROb2RlK2FsZXJ0cyZtYXRjaGVyPWluc3RhbmNlJTNEMTAuMTA4LjAuMiUzQTkxMDAmbWF0Y2hlcj1qb2IlM0Rub2RlLWV4cG9ydGVyXCIsXG4gICAgICBcImRhc2hib2FyZFVSTFwiOiBcIlwiLFxuICAgICAgXCJwYW5lbFVSTFwiOiBcIlwiLFxuICAgICAgXCJ2YWx1ZXNcIjoge1xuICAgICAgICBcIkJcIjogMTguOTgyMTEzMTQ0NzU4NzYsXG4gICAgICAgIFwiQ1wiOiAwXG4gICAgICB9LFxuICAgICAgXCJ2YWx1ZVN0cmluZ1wiOiBcIlsgdmFyPSdCJyBsYWJlbHM9e19fbmFtZV9fPW5vZGVfbG9hZDE1LCBpbnN0YW5jZT0xMC4xMDguMC4yOjkxMDAsIGpvYj1ub2RlLWV4cG9ydGVyfSB2YWx1ZT0xOC45ODIxMTMxNDQ3NTg3NiBdLCBbIHZhcj0nQycgbGFiZWxzPXtfX25hbWVfXz1ub2RlX2xvYWQxNSwgaW5zdGFuY2U9MTAuMTA4LjAuMjo5MTAwLCBqb2I9bm9kZS1leHBvcnRlcn0gdmFsdWU9MCBdXCJcbiAgICB9XG4gIF0sXG4gIFwiZ3JvdXBMYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCJcbiAgfSxcbiAgXCJjb21tb25MYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCIsXG4gICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgIFwiam9iXCI6IFwibm9kZS1leHBvcnRlclwiXG4gIH0sXG4gIFwiY29tbW9uQW5ub3RhdGlvbnNcIjoge1xuICAgIFwic3VtbWFyeVwiOiBcIjE1bSBsb2FkIGF2ZXJhZ2UgdG9vIGhpZ2hcIlxuICB9LFxuICBcImV4dGVybmFsVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvXCIsXG4gIFwidmVyc2lvblwiOiBcIjFcIixcbiAgXCJncm91cEtleVwiOiBcInt9OnthbGVydG5hbWU9XFxcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFxcXCIsIGdyYWZhbmFfZm9sZGVyPVxcXCJOb2RlIGFsZXJ0c1xcXCJ9XCIsXG4gIFwidHJ1bmNhdGVkQWxlcnRzXCI6IDAsXG4gIFwib3JnSWRcIjogMSxcbiAgXCJ0aXRsZVwiOiBcIltSRVNPTFZFRF0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoIE5vZGUgYWxlcnRzICgxMC4xMDguMC4yOjkxMDAgbm9kZS1leHBvcnRlcilcIixcbiAgXCJzdGF0ZVwiOiBcIm9rXCIsXG4gIFwibWVzc2FnZVwiOiBcIioqUmVzb2x2ZWQqKlxcblxcblZhbHVlOiBCPTE4Ljk4MjExMzE0NDc1ODc2LCBDPTBcXG5MYWJlbHM6XFxuIC0gYWxlcnRuYW1lID0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoXFxuIC0gZ3JhZmFuYV9mb2xkZXIgPSBOb2RlIGFsZXJ0c1xcbiAtIGluc3RhbmNlID0gMTAuMTA4LjAuMjo5MTAwXFxuIC0gam9iID0gbm9kZS1leHBvcnRlclxcbkFubm90YXRpb25zOlxcbiAtIHN1bW1hcnkgPSAxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXFxuU291cmNlOiBsb2NhbGhvc3Q6MzAwMC9hbGVydGluZy9ncmFmYW5hL05XOW9Edy00ei92aWV3XFxuU2lsZW5jZTogbG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvc2lsZW5jZS9uZXc/YWxlcnRtYW5hZ2VyPWdyYWZhbmEmbWF0Y2hlcj1hbGVydG5hbWUlM0RMb2FkK2F2ZysxNW0rdG9vK2hpZ2gmbWF0Y2hlcj1ncmFmYW5hX2ZvbGRlciUzRE5vZGUrYWxlcnRzJm1hdGNoZXI9aW5zdGFuY2UlM0QxMC4xMDguMC4yJTNBOTEwMCZtYXRjaGVyPWpvYiUzRG5vZGUtZXhwb3J0ZXJcXG5cIlxufVxuIiwiY29uZmlnIjp7InRlbXBsYXRlIjoidGV4dCIsImZ1bGxTY3JlZW5IVE1MIjpmYWxzZSwiZnVuY3Rpb25zIjpbInNwcmlnIl0sIm9wdGlvbnMiOlsibGl2ZSJdLCJpbnB1dFR5cGUiOiJ5YW1sIn19)).
|
||||
|
||||
!!! info
|
||||
A few Go template features are disabled for user-supplied templates: `{{define}}`, `{{template}}`,
|
||||
`{{block}}`, and `{{call}}` are not allowed. Templates also run with a short execution time limit --
|
||||
a template that loops too long is stopped and rejected with an HTTP 400 error.
|
||||
|
||||
### Template functions
|
||||
ntfy supports a subset of the **[Sprig template functions](publish/template-functions.md)** (originally copied from [Sprig](https://github.com/Masterminds/sprig),
|
||||
thank you to the Sprig developers 🙏). This is useful for advanced message templating and for transforming the data provided through the JSON payload.
|
||||
@@ -3213,8 +3260,13 @@ You can forward messages to e-mail by specifying an address in the header. This
|
||||
you'd like to persist longer, or to blast-notify yourself on all possible channels.
|
||||
|
||||
Usage is easy: Simply pass the `X-Email` header (or any of its aliases: `X-E-mail`, `Email`, `E-mail`, `Mail`, or `e`).
|
||||
Only one e-mail address is supported. If the server has [`smtp-sender-verify`](config.md#e-mail-notifications) enabled (ntfy.sh has this enabled),
|
||||
you can also pass `yes`, `true`, or `1` to send to your first verified email address.
|
||||
Only one e-mail address is supported.
|
||||
|
||||
If you are logged in and have a verified email address on your account, you can pass `yes`, `true`, or `1` instead of an
|
||||
address to send to your **primary email address** (the one marked primary in the web app's
|
||||
[Account section](https://ntfy.sh/account)); if you haven't designated a primary, it falls back to your first verified
|
||||
address. This works regardless of the [`smtp-sender-verify`](config.md#e-mail-notifications) setting -- that setting only
|
||||
controls whether *literal* addresses must already be verified on your account.
|
||||
|
||||
ntfy allows anonymous email sending (if enabled), so the rate limiting is pretty strict (see [limitations](#limitations)). In the
|
||||
default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of
|
||||
@@ -3664,7 +3716,7 @@ all the supported fields:
|
||||
| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) |
|
||||
| `filename` | - | *string* | `file.jpg` | File name of the attachment |
|
||||
| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
|
||||
| `email` | - | *e-mail address or 'yes'* | `phil@example.com` or `yes` | E-mail address for e-mail notifications, or `yes` to use first verified address |
|
||||
| `email` | - | *e-mail address or 'yes'* | `phil@example.com` or `yes` | E-mail address for e-mail notifications, or `yes` to use your primary verified address |
|
||||
| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) |
|
||||
| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](#updating-deleting-notifications) |
|
||||
|
||||
@@ -4100,26 +4152,41 @@ field the response. A sequence of updates may look like this (first example from
|
||||
### Clearing notifications
|
||||
Clearing a notification means **marking it as read and dismissing it from the notification drawer**.
|
||||
|
||||
To do this, send a PUT request to the `/<topic>/<sequence_id>/clear` endpoint (or `/<topic>/<sequence_id>/read` as an alias).
|
||||
To do this, send a `PUT` request to the `/<topic>/<sequence_id>/clear` endpoint (or `/<topic>/<sequence_id>/read` as an alias).
|
||||
This will then emit a `message_clear` event that is used by the clients (web app and Android app) to update the read status
|
||||
and dismiss the notification.
|
||||
|
||||
Alternatively, if your client has limited HTTP support, you can send a `GET` request to the same endpoints:
|
||||
`GET /<topic>/<sequence_id>/clear` or `GET /<topic>/<sequence_id>/read`.
|
||||
|
||||
=== "Command line (curl)"
|
||||
```bash
|
||||
# Via PUT method
|
||||
curl -X PUT ntfy.sh/mytopic/my-download-123/clear
|
||||
|
||||
# Via GET method
|
||||
curl ntfy.sh/mytopic/my-download-123/clear
|
||||
```
|
||||
|
||||
=== "HTTP"
|
||||
``` http
|
||||
PUT /mytopic/my-download-123/clear HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
|
||||
# Or using GET
|
||||
GET /mytopic/my-download-123/clear HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
``` javascript
|
||||
// Via PUT method
|
||||
await fetch('https://ntfy.sh/mytopic/my-download-123/clear', {
|
||||
method: 'PUT'
|
||||
});
|
||||
|
||||
// Via GET method
|
||||
await fetch('https://ntfy.sh/mytopic/my-download-123/clear');
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
@@ -4154,25 +4221,40 @@ An example response from the server with the `message_clear` event may look like
|
||||
### Deleting notifications
|
||||
Deleting a notification means **removing it from the notification drawer and from the client's database**.
|
||||
|
||||
To do this, send a DELETE request to the `/<topic>/<sequence_id>` endpoint. This will emit a `message_delete` event
|
||||
To do this, send a `DELETE` request to the `/<topic>/<sequence_id>` endpoint. This will emit a `message_delete` event
|
||||
that is used by the clients (web app and Android app) to remove the notification entirely.
|
||||
|
||||
Alternatively, if your client has limited HTTP support (e.g. webhooks or IoT devices), you can also delete a message by sending
|
||||
a `GET` request to `/<topic>/<sequence_id>/delete`.
|
||||
|
||||
=== "Command line (curl)"
|
||||
```bash
|
||||
# Via DELETE method
|
||||
curl -X DELETE ntfy.sh/mytopic/my-download-123
|
||||
|
||||
# Via GET method
|
||||
curl ntfy.sh/mytopic/my-download-123/delete
|
||||
```
|
||||
|
||||
=== "HTTP"
|
||||
``` http
|
||||
DELETE /mytopic/my-download-123 HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
|
||||
# Or using GET
|
||||
GET /mytopic/my-download-123/delete HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
``` javascript
|
||||
// Via DELETE method
|
||||
await fetch('https://ntfy.sh/mytopic/my-download-123', {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
// Via GET method
|
||||
await fetch('https://ntfy.sh/mytopic/my-download-123/delete');
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
|
||||
+74
-11
@@ -4,15 +4,74 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
## Current stable releases
|
||||
|
||||
| Component | Version | Release date |
|
||||
|------------------|---------|--------------|
|
||||
| ntfy server | v2.24.0 | June 4, 2026 |
|
||||
| ntfy Android app | v1.24.0 | Mar 5, 2026 |
|
||||
| ntfy iOS app | v1.7.0 | May 30, 2026 |
|
||||
| Component | Version | Release date |
|
||||
|------------------|---------|---------------|
|
||||
| ntfy server | v2.26.0 | July 9, 2026 |
|
||||
| ntfy Android app | v1.24.0 | Mar 5, 2026 |
|
||||
| ntfy iOS app | v1.7.0 | May 30, 2026 |
|
||||
|
||||
Please check out the release notes for [upcoming releases](#not-released-yet) below.
|
||||
|
||||
### ntfy server v2.24.0
|
||||
### ntfy server v2.26.0
|
||||
Released July 9, 2026
|
||||
|
||||
This release hardens **message templates**, which are now executed with a hard-capped execution timeout. This closes
|
||||
a denial-of-service hole.
|
||||
|
||||
On the web app side, it adds configurable **date and time formats**, a smoother loading and page-transition experience,
|
||||
and a fix that strips unsafe URL protocols from rendered Markdown.
|
||||
|
||||
**Security:**
|
||||
|
||||
* Prevent a CPU denial of service via message templates (`Template: yes`) ([#1826](https://github.com/binwiederhier/ntfy/pull/1826), thanks to [@alanturing881](https://github.com/alanturing881) for reporting)
|
||||
|
||||
**Features:**
|
||||
|
||||
* Web app: Add "Date format" and "Time format" settings (Settings -> Appearance), with ISO 8601, day/month/year (slash or dot) and month/day/year date options and a 12-/24-hour clock option, and base the default format on your browser/system locale rather than the selected display language. When logged in, both settings sync across devices via your account ([#1647](https://github.com/binwiederhier/ntfy/issues/1647), thanks to [@wsw70](https://github.com/wsw70) for reporting)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Web app: Smooth transitions and loading animation, remove flickering
|
||||
* Web app: `GET /account` now reads from the primary database instead of a read replica, so the account view no longer shows stale data right after a change when replicas lag behind
|
||||
* Docs: Document the third-party HelmForge Helm chart as a Kubernetes installation option ([#1727](https://github.com/binwiederhier/ntfy/issues/1727), thanks to [@mberlofa](https://github.com/mberlofa))
|
||||
* Web app: Strip unsafe URL protocols (`javascript:`, `data:`, ...) from links and images in Markdown-rendered messages, so they no longer trigger an uncaught "React has blocked a javascript: URL" error (thanks to [@jvoisin](https://github.com/jvoisin) for reporting)
|
||||
|
||||
## ntfy server v2.25.0
|
||||
Released June 24, 2026
|
||||
|
||||
This release adds **password reset** via email, and reworks email verification to use durable,
|
||||
link-based magic links (replacing the old in-memory 6-digit codes). Email stays optional at
|
||||
signup; a user can reset their password only once they have a verified "primary" (recovery)
|
||||
email.
|
||||
|
||||
All of this work is probably not useful for self-hosters, but it hopefully will be useful for me,
|
||||
since I do have to reset accounts on a regular basis.
|
||||
|
||||
**Security issues:**
|
||||
|
||||
* Generate access tokens, IDs, and magic-link tokens with a cryptographically secure RNG (`crypto/rand`) instead of a clock-seeded PRNG
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add password reset via emailed magic link, with a "Forgot password" link on the login page and a `ntfy user reset-pass` CLI command for admins
|
||||
* Rework email verification to use durable, single-use, expiring magic links instead of in-memory 6-digit codes, and add a "primary" email (used for account recovery and as the `X-Email: yes` target) with verified/unverified state in the account UI
|
||||
* You can now clear/read messages and delete messages with a GET request ([#1771](https://github.com/binwiederhier/ntfy/issues/1771), thanks to [@lemmi](https://github.com/lemmi) for reporting and to [@wunter8](https://github.com/wunter8) for implementing)
|
||||
* Add a reload button to the web app's action bar when running as an installed PWA, which clears the service worker caches and hard-refreshes the app ([#1281](https://github.com/binwiederhier/ntfy/issues/1281), thanks to [@leanza](https://github.com/leanza) for reporting)
|
||||
* Add a "Back to app" link to the web app's login, signup, and password-reset pages (alongside the existing links), which previously had no way back to the app
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* `X-Email: yes` (also `true`/`1`) now sends to your primary verified email regardless of the `smtp-sender-verify` setting (previously it was rejected unless verification was enabled); it requires being logged in with a verified address
|
||||
* Grant users full access to their own sync topic (`st_...`) so cross-device subscription sync works under `auth-default-access: deny-all` ([#733](https://github.com/binwiederhier/ntfy/issues/733), [#1795](https://github.com/binwiederhier/ntfy/pull/1795), thanks to [@lmorchard](https://github.com/lmorchard) for the contribution)
|
||||
* Support HTTP (non-TLS) S3-compatible endpoints by preserving the endpoint scheme, e.g. for a local MinIO instance ([#1794](https://github.com/binwiederhier/ntfy/pull/1794), [#1734](https://github.com/binwiederhier/ntfy/issues/1734), thanks to [@sskender](https://github.com/sskender) for the contribution, and [@Kernald](https://github.com/Kernald) for reporting)
|
||||
* Stop silently stripping spaces from passwords while typing in the web app's login, signup, and password-reset forms ([#1246](https://github.com/binwiederhier/ntfy/issues/1246), thanks to [@aldem](https://github.com/aldem) for reporting)
|
||||
* Update web app dependencies, including major-version upgrades to Vite (6 -> 8, now Rolldown-based), Material UI (5 -> 9), and Dexie (3 -> 4) ([#1800](https://github.com/binwiederhier/ntfy/pull/1800), [#1764](https://github.com/binwiederhier/ntfy/pull/1764), [#1767](https://github.com/binwiederhier/ntfy/pull/1767), [#1762](https://github.com/binwiederhier/ntfy/pull/1762), [#1766](https://github.com/binwiederhier/ntfy/pull/1766), [#1765](https://github.com/binwiederhier/ntfy/pull/1765), thanks Dependabot)
|
||||
* Play notification sounds in the web app even when the Notification API is unavailable, e.g. over plain HTTP or in browsers without notification support ([#1772](https://github.com/binwiederhier/ntfy/pull/1772), thanks to [@mitya12342](https://github.com/mitya12342) for the contribution)
|
||||
* Stop escaping `<`, `>`, and `&` as `\u003c`/`\u003e`/`\u0026` in JSON responses ([#1511](https://github.com/binwiederhier/ntfy/issues/1511), [#1512](https://github.com/binwiederhier/ntfy/pull/1512), thanks to [@wunter8](https://github.com/wunter8) for the contribution)
|
||||
* Fix the web app navbar not reflecting a topic reservation (lock icon, and "Reserve topic" -> "Change reservation"/"Remove reservation" menu) until a page reload, by persisting reservation and display-name changes onto already-subscribed topics during account sync
|
||||
* Reduce the web app's initial bundle size by ~300 KB (~50 KB gzipped) by lazy-loading the emoji picker dataset and the Markdown renderer, and by importing Material UI icons individually
|
||||
|
||||
## ntfy server v2.24.0
|
||||
Released June 4, 2026
|
||||
|
||||
The main feature for this release is an in-memory ACL cache (`auth-access-cache`) that can help bring down the read load
|
||||
@@ -1948,13 +2007,14 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
## Not released yet
|
||||
|
||||
### ntfy Android v1.25.x (UNRELEASED)
|
||||
### ntfy Android v1.25.2 (UNRELEASED)
|
||||
|
||||
This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out
|
||||
or even gave ntfy bad reviews. I heard you! You can re-enable the alert in the advanced settings.
|
||||
|
||||
The release also tries to be smarter about not retrying the connection at all if the app is in flight mode, or has no network. If there
|
||||
is no network, ntfy will now stop the foreground service entirely.
|
||||
is no network, ntfy now keeps the foreground service alive and shows a "Waiting for network" notification, then resumes automatically
|
||||
once connectivity returns.
|
||||
|
||||
Another change related to the networking is that we now force-reconnect when the connection is changed, e.g. during transitions
|
||||
from Wi-Fi to cellular network, or vice versa. That should allow for faster transitions during hand-overs.
|
||||
@@ -1964,16 +2024,19 @@ especially when paired with increaseing the server-side `keepalive-interval` in
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add configurable "Alert when connection is lost" setting ([#1665](https://github.com/binwiederhier/ntfy/issues/1665), [#1662](https://github.com/binwiederhier/ntfy/issues/1662), [#1652](https://github.com/binwiederhier/ntfy/issues/1652), [#1655](https://github.com/binwiederhier/ntfy/issues/1655), thanks to [@tintamarre](https://github.com/tintamarre), [@sjozs](https://github.com/sjozs), [@TheRealOne78](https://github.com/TheRealOne78), and [@DAE51D](https://github.com/DAE51D) for reporting)
|
||||
* Suppress connection alerts and stop foreground service when there is no network ([ntfy-android#165](https://github.com/binwiederhier/ntfy-android/pull/165), thanks to [@tintamarre](https://github.com/tintamarre) for the contribution)
|
||||
* Restart the foreground service immediately when network returns, even if the app process was killed while offline
|
||||
* Add configurable "Alert when connection is lost" setting, turned off by default ([#1665](https://github.com/binwiederhier/ntfy/issues/1665), [#1662](https://github.com/binwiederhier/ntfy/issues/1662), [#1652](https://github.com/binwiederhier/ntfy/issues/1652), [#1655](https://github.com/binwiederhier/ntfy/issues/1655), thanks to [@tintamarre](https://github.com/tintamarre), [@sjozs](https://github.com/sjozs), [@TheRealOne78](https://github.com/TheRealOne78), and [@DAE51D](https://github.com/DAE51D) for reporting)
|
||||
* Handle "no network" gracefully: when the device is offline or in airplane mode, ntfy now stops retrying, suppresses the connection-lost alert, and keeps the foreground service alive with a "Waiting for network" notification, resuming instant delivery automatically when connectivity returns ([ntfy-android#165](https://github.com/binwiederhier/ntfy-android/pull/165), thanks to [@tintamarre](https://github.com/tintamarre) for the contribution, and [#1709](https://github.com/binwiederhier/ntfy/issues/1709), thanks to [@isaitgirl](https://github.com/isaitgirl) for reporting)
|
||||
* Improve battery life by increasing WebSocket client ping interval from 1 min to 3 min, and reconnect instantly on Wi-Fi/cellular/VPN transitions ([ntfy-android#113](https://github.com/binwiederhier/ntfy-android/pull/113), thanks to [@ftilde](https://github.com/ftilde) for the investigation)
|
||||
* Disable UnifiedPush components when UnifiedPush is disabled in settings ([ntfy-android#168](https://github.com/binwiederhier/ntfy-android/pull/168), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Fix the "connection lost" alert briefly disappearing and re-firing when roaming between networks (e.g. Wi-Fi to cellular), by no longer cancelling it during the transient no-network gap of a handover
|
||||
* Fix the "connection lost" alert repeatedly waking the screen while a server stayed unreachable, by no longer re-posting the alert once it is already showing
|
||||
* Fix the "connection lost" alert firing late, erratically, or not at all when a connection kept dropping (e.g. a flaky server) rather than being refused outright, by tracking how long the connection has been down independently of whether the drop warrants a UI error
|
||||
* Undo automatic phone number linking for numbers in message body ([ntfy-android#170](https://github.com/binwiederhier/ntfy-android/pull/170), thanks to [@acortelyou](https://github.com/acortelyou) for the contribution)
|
||||
* Fix subscription icons disappearing after a few days due to Android clearing cache ([#1322](https://github.com/binwiederhier/ntfy/issues/1322), thanks to [@mcanning](https://github.com/mcanning) for reporting)
|
||||
* Fix UnifiedPush `failed_reason` being sent as an enum instead of a string, which caused an exception in receiving apps that read it as a string extra ([ntfy-android#182](https://github.com/binwiederhier/ntfy-android/pull/182), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
|
||||
|
||||
### ntfy iOS app v1.8.0 (UNRELEASED)
|
||||
|
||||
|
||||
Vendored
+235
@@ -0,0 +1,235 @@
|
||||
/* Topic name generator (Publishing page) */
|
||||
/* Styled to mirror the config generator (header + left form / right output panels). */
|
||||
|
||||
.tg-generator {
|
||||
margin: 16px 0 24px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
font-size: 0.78rem;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* Header (matches .cg-modal-header) */
|
||||
.tg-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.tg-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tg-reset {
|
||||
background: none;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.72rem;
|
||||
color: #777;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px;
|
||||
font-family: inherit;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.tg-reset:hover {
|
||||
color: #333;
|
||||
border-color: #999;
|
||||
}
|
||||
|
||||
/* Body: left (form) + right (output), matches .cg-modal-body */
|
||||
.tg-body {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tg-left {
|
||||
flex: 1;
|
||||
border-right: 1px solid #ddd;
|
||||
padding: 16px 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tg-right {
|
||||
flex: 1;
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* One output per block: label on its own line, then value field + copy button */
|
||||
.tg-output-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tg-output-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Form field (matches .cg-field) */
|
||||
.tg-field > label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.78rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.tg-field input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.tg-field input[type="text"]:focus {
|
||||
border-color: var(--md-primary-fg-color);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(51, 133, 116, 0.15);
|
||||
}
|
||||
|
||||
.tg-note {
|
||||
margin-top: 10px;
|
||||
font-size: 0.72rem;
|
||||
color: #999;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tg-note code {
|
||||
font-size: 0.72rem;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.tg-output-label {
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
font-size: 0.78rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Copy button (matches .cg-btn-copy) */
|
||||
.tg-btn-copy {
|
||||
background: none;
|
||||
color: #777;
|
||||
border: none;
|
||||
padding: 2px 4px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.tg-btn-copy:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Output block (matches .cg-output-wrap pre). Scoped under .tg-generator so the margin
|
||||
reset beats the theme's .md-typeset pre rule, which otherwise adds a stray top margin. */
|
||||
.tg-generator .tg-output {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 6px 9px;
|
||||
background: #f5f5f5;
|
||||
color: var(--md-default-fg-color);
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-family: var(--md-code-font-family, monospace);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
body[data-md-color-scheme="slate"] .tg-generator {
|
||||
background: #1e1e2e;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-header {
|
||||
border-bottom-color: #444;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-title {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-reset {
|
||||
border-color: #555;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-reset:hover {
|
||||
border-color: #888;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-left {
|
||||
border-right-color: #444;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-field > label,
|
||||
body[data-md-color-scheme="slate"] .tg-output-label {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-btn-copy {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-btn-copy:hover {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-field input[type="text"] {
|
||||
background: #2a2a3a;
|
||||
border-color: #555;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-note {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-output {
|
||||
background: #161620;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
/* Responsive: stack panels like the config generator does on mobile */
|
||||
@media (max-width: 700px) {
|
||||
.tg-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tg-left {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-left {
|
||||
border-bottom-color: #444;
|
||||
}
|
||||
}
|
||||
Vendored
+121
@@ -0,0 +1,121 @@
|
||||
// Topic name generator for the ntfy docs
|
||||
//
|
||||
// A tiny helper that lives on the "Publishing" page. The user types a memorable
|
||||
// prefix (e.g. "backups"), and the widget appends a random, hard-to-guess suffix
|
||||
// (e.g. "backups-x7Kp2mQ9"). The result is a valid, unguessable topic name.
|
||||
//
|
||||
// Topic names on the server must match ^[-_A-Za-z0-9]{1,64}$ (see server.go), so as
|
||||
// the user types we strip anything that isn't allowed (spaces, slashes, punctuation,
|
||||
// emoji, ...) live and cap the whole thing at 64 characters. The random suffix is
|
||||
// generated once on load and can be re-rolled with the "Regenerate suffix" button.
|
||||
(function () {
|
||||
// Allowed topic characters per the server regex ^[-_A-Za-z0-9]{1,64}$
|
||||
const ALLOWED = /[^-_A-Za-z0-9]/g;
|
||||
const MAX_LEN = 64;
|
||||
|
||||
// Suffix alphabet: full base62 (letters + digits). We deliberately keep look-alikes
|
||||
// (0/O, l/1) for maximum entropy -- this is a generated suffix, not something typed by
|
||||
// hand. Hyphen/underscore are excluded so the "-" separator stays visually clear.
|
||||
const SUFFIX_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const SUFFIX_LEN = 10;
|
||||
|
||||
// randomSuffix returns a cryptographically random string from SUFFIX_ALPHABET.
|
||||
// It uses rejection sampling to avoid the modulo bias that a plain `byte % 62` would
|
||||
// introduce (256 is not a multiple of 62), keeping every character equally likely.
|
||||
function randomSuffix() {
|
||||
const n = SUFFIX_ALPHABET.length;
|
||||
const limit = Math.floor(256 / n) * n; // largest multiple of n that fits in a byte
|
||||
const buf = new Uint8Array(1);
|
||||
let out = "";
|
||||
while (out.length < SUFFIX_LEN) {
|
||||
crypto.getRandomValues(buf);
|
||||
if (buf[0] < limit) {
|
||||
out += SUFFIX_ALPHABET[buf[0] % n];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// sanitize strips everything that isn't a valid topic character.
|
||||
function sanitize(value) {
|
||||
return value.replace(ALLOWED, "");
|
||||
}
|
||||
|
||||
function initTopicGenerator() {
|
||||
const root = document.getElementById("tg-widget");
|
||||
if (!root) return;
|
||||
|
||||
const input = root.querySelector("#tg-input");
|
||||
const outputName = root.querySelector("#tg-output-name");
|
||||
const outputUrl = root.querySelector("#tg-output-url");
|
||||
const reroll = root.querySelector("#tg-reroll");
|
||||
|
||||
let suffix = randomSuffix();
|
||||
|
||||
// update recomputes the live preview from the (sanitized) input + current suffix.
|
||||
function update() {
|
||||
// Sanitize in place so the user sees disallowed characters disappear as they type.
|
||||
const cleaned = sanitize(input.value);
|
||||
if (cleaned !== input.value) {
|
||||
const pos = input.selectionStart - (input.value.length - cleaned.length);
|
||||
// Reassigning .value and setSelectionRange make the browser scroll the field into
|
||||
// view (there is no preventScroll option for setSelectionRange), which jumps the
|
||||
// whole page. Capture the scroll position and restore it afterwards.
|
||||
const scrollX = window.scrollX;
|
||||
const scrollY = window.scrollY;
|
||||
input.value = cleaned;
|
||||
// Best-effort caret restore so removing a bad char doesn't jump the cursor to the end.
|
||||
try { input.setSelectionRange(pos, pos); } catch { /* ignore */ }
|
||||
window.scrollTo(scrollX, scrollY);
|
||||
}
|
||||
|
||||
// Compose "<prefix>-<suffix>", capped at the 64-char topic limit. With no prefix,
|
||||
// fall back to just the random suffix so the output is always a valid topic.
|
||||
let topic;
|
||||
if (cleaned === "") {
|
||||
topic = suffix;
|
||||
} else {
|
||||
const maxPrefix = MAX_LEN - suffix.length - 1; // room for "-" + suffix
|
||||
const prefix = cleaned.slice(0, Math.max(0, maxPrefix));
|
||||
topic = prefix === "" ? suffix : prefix + "-" + suffix;
|
||||
}
|
||||
|
||||
outputName.textContent = topic;
|
||||
outputUrl.textContent = "https://ntfy.sh/" + topic;
|
||||
}
|
||||
|
||||
input.addEventListener("input", update);
|
||||
reroll.addEventListener("click", function () {
|
||||
suffix = randomSuffix();
|
||||
update();
|
||||
input.focus();
|
||||
});
|
||||
|
||||
// Copy buttons: copy the target output and briefly swap the clipboard icon for a checkmark,
|
||||
// mirroring the config generator's copy button behavior.
|
||||
const copyIcon = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\"></rect><path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"></path></svg>";
|
||||
const checkIcon = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"20 6 9 17 4 12\"></polyline></svg>";
|
||||
root.querySelectorAll(".tg-btn-copy").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
const target = root.querySelector("#" + btn.dataset.copy);
|
||||
if (!target || !target.textContent) return;
|
||||
navigator.clipboard.writeText(target.textContent).then(function () {
|
||||
btn.innerHTML = checkIcon;
|
||||
btn.style.color = "var(--md-primary-fg-color)";
|
||||
setTimeout(function () {
|
||||
btn.innerHTML = copyIcon;
|
||||
btn.style.color = "";
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
update();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initTopicGenerator);
|
||||
} else {
|
||||
initTopicGenerator();
|
||||
}
|
||||
})();
|
||||
@@ -4,22 +4,22 @@ go 1.25.8
|
||||
|
||||
require (
|
||||
cloud.google.com/go/firestore v1.22.0 // indirect
|
||||
cloud.google.com/go/storage v1.62.2 // indirect
|
||||
cloud.google.com/go/storage v1.63.0 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/emersion/go-smtp v0.24.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.13
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mattn/go-sqlite3 v1.14.44
|
||||
github.com/mattn/go-sqlite3 v1.14.47
|
||||
github.com/olebedev/when v1.1.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/sync v0.21.0
|
||||
golang.org/x/term v0.44.0
|
||||
golang.org/x/time v0.15.0
|
||||
google.golang.org/api v0.283.0
|
||||
google.golang.org/api v0.287.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
@@ -34,8 +34,8 @@ require (
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/stripe/stripe-go/v74 v74.30.0
|
||||
golang.org/x/sys v0.45.0
|
||||
golang.org/x/text v0.37.0
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/text v0.39.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -45,12 +45,12 @@ require (
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/iam v1.11.0 // indirect
|
||||
cloud.google.com/go/longrunning v1.0.0 // indirect
|
||||
cloud.google.com/go/longrunning v1.1.0 // indirect
|
||||
cloud.google.com/go/monitoring v1.29.0 // indirect
|
||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect
|
||||
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
@@ -60,7 +60,7 @@ require (
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
@@ -69,7 +69,7 @@ require (
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
@@ -79,10 +79,10 @@ require (
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.68.1 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/prometheus/common v0.69.0 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.7.0 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
@@ -94,12 +94,12 @@ require (
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/grpc v1.81.1 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect
|
||||
google.golang.org/grpc v1.82.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -14,12 +14,12 @@ cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM=
|
||||
cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
|
||||
cloud.google.com/go/logging v1.18.0 h1:KhzZq+1cSkPH9YUaKLLhLtQxIHitVayBmk0sGfoM9+k=
|
||||
cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI=
|
||||
cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY=
|
||||
cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM=
|
||||
cloud.google.com/go/longrunning v1.1.0 h1:qJ0R0IA8ONaRCNWTRPAS0iAmt1bj3TVgJ40z7ZGRslE=
|
||||
cloud.google.com/go/longrunning v1.1.0/go.mod h1:tH+A/6UvNypiPJWAQaKCsh+xiGbB23wUO8egwUXlD2E=
|
||||
cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY=
|
||||
cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
|
||||
cloud.google.com/go/storage v1.62.2 h1:WgR4U9n7bIzXkkVnwPKKE8bkaKUNsHG+0MAAlh9DGU4=
|
||||
cloud.google.com/go/storage v1.62.2/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
|
||||
cloud.google.com/go/storage v1.63.0 h1:hvXF2xfg9I32bjujggxgkEZn/Ej6sJ9pieFgeueBLrQ=
|
||||
cloud.google.com/go/storage v1.63.0/go.mod h1:tirWVptrFNo5GEX2DQ47JooF7yaweJdAJ1hYAVMvKzE=
|
||||
cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E=
|
||||
cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0=
|
||||
firebase.google.com/go/v4 v4.20.0 h1:ighpjeAC45rY/95cUQ+ojIKlKcTnz2YC0ldam56z2YU=
|
||||
@@ -28,14 +28,14 @@ github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
||||
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 h1:O2sXMyJh8b7devAGdE+163xtRurt0RVpB6DIzX5vGfg=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0/go.mod h1:hEpiGU18xf70qb3jbTcIggWAiEfX/cOIVc2OTe4OegA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0 h1:ZIT85vKP7LBS84XJ0WdJ3dPOX3iz4j3c0+lpajGQMyo=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0/go.mod h1:rqP9UEhOXv9WhQ7Gjz+G5y/pf8+BJZW5/Ts0AhE0PwE=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 h1:0YP0+/ixwu+Uqeu/FGiBZNQ19huiUxxiPXIc9WsLKuQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0/go.mod h1:6ZZMQhZKDvUvkJw2rc+oDP90tMMzuU/J+5HG1ZmPOmE=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0 h1:cSjUzZ7KU8hicTgzaSv9NmSyM9fTVK3y5lsBUl3wOis=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk=
|
||||
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
||||
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
@@ -66,8 +66,8 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
@@ -96,8 +96,8 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.17 h1:73NfMHdiqo9JFU9+7a5ExpVa10/R29pXfZIaW559nrg=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k=
|
||||
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
|
||||
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
@@ -120,8 +120,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
||||
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
|
||||
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
@@ -139,16 +139,16 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY=
|
||||
github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk=
|
||||
github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
|
||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spiffe/go-spiffe/v2 v2.7.0 h1:uXe1MflJoHw58wAUvxVlcM7WpKtijWG7I1UidcGh6g4=
|
||||
github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U=
|
||||
github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E=
|
||||
github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
@@ -173,8 +173,8 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
|
||||
@@ -195,8 +195,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -211,8 +211,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -222,8 +222,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -236,8 +236,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -247,8 +247,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -260,8 +260,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -274,18 +274,18 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/api v0.283.0 h1:0lkp8u0MPwJVHqRL+nJlMAoZVVzbmiXmFHXMOTmSPik=
|
||||
google.golang.org/api v0.283.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM=
|
||||
google.golang.org/api v0.287.0 h1:CQDMqUiqZZ0U/Yge3zyjAhNQ0OSYEH0PaA7l4xtEen4=
|
||||
google.golang.org/api v0.287.0/go.mod h1:pPW85yt3Iuc3unkpaMhFtMmOqnTdCwCqEOaUlnuxRlQ=
|
||||
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
|
||||
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
|
||||
google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa h1:mfj8IS4EA4VAR9a6QDVxTQkLY64iBybb5QI1B4pXrpE=
|
||||
google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:fuT7yonGw1Iq2oa+YC0fyqPPQJkgo/54gPNC6VitOkI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7 h1:lQG76ePMKmtujel4VIVMiFoHVWVNtJdawbCZJtWlVXU=
|
||||
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7/go.mod h1:LwlOWYBU335L+sR55UuR5fbbU8KmEX+3tUHf3SwMmhM=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 h1:eM/YSd5bBFagF51o1E745Ta7RwzpW0h+z+QDNZOgmQ8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
|
||||
google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package server
|
||||
package mail
|
||||
|
||||
import (
|
||||
_ "embed" // required by go:embed
|
||||
@@ -6,66 +6,24 @@ import (
|
||||
"fmt"
|
||||
"mime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/mail"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
type mailer interface {
|
||||
Send(v *visitor, m *model.Message, to string) error
|
||||
Counts() (total int64, success int64, failure int64)
|
||||
}
|
||||
var (
|
||||
//go:embed "mailer_emoji_map.json"
|
||||
emojisJSON string
|
||||
|
||||
type smtpSender struct {
|
||||
config *Config
|
||||
sender *mail.Sender
|
||||
success int64
|
||||
failure int64
|
||||
mu sync.Mutex
|
||||
}
|
||||
// emojiMap maps ntfy tag names to emoji, parsed once from the embedded JSON in init
|
||||
emojiMap map[string]string
|
||||
)
|
||||
|
||||
func (s *smtpSender) Send(v *visitor, m *model.Message, to string) error {
|
||||
return s.withCount(v, m, func() error {
|
||||
message, err := formatMail(s.config.BaseURL, v.ip.String(), s.sender.From(), to, m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ev := logvm(v, m).
|
||||
Tag(tagEmail).
|
||||
Fields(log.Context{
|
||||
"email_via": s.sender.Addr(),
|
||||
"email_user": s.sender.User(),
|
||||
"email_to": to,
|
||||
})
|
||||
if ev.IsTrace() {
|
||||
ev.Field("email_body", message).Trace("Sending email")
|
||||
}
|
||||
ev.Info("Sending email")
|
||||
return s.sender.SendRaw(to, []byte(message))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *smtpSender) Counts() (total int64, success int64, failure int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.success + s.failure, s.success, s.failure
|
||||
}
|
||||
|
||||
func (s *smtpSender) withCount(v *visitor, m *model.Message, fn func() error) error {
|
||||
err := fn()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err != nil {
|
||||
logvm(v, m).Err(err).Debug("Sending mail failed")
|
||||
s.failure++
|
||||
} else {
|
||||
s.success++
|
||||
func init() {
|
||||
if err := json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil {
|
||||
panic("mail: invalid embedded emoji map: " + err.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func formatMail(baseURL, senderIP, from, to string, m *model.Message) (string, error) {
|
||||
@@ -78,10 +36,7 @@ func formatMail(baseURL, senderIP, from, to string, m *model.Message) (string, e
|
||||
message := m.Message
|
||||
trailer := ""
|
||||
if len(m.Tags) > 0 {
|
||||
emojis, tags, err := toEmojis(m.Tags)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
emojis, tags := toEmojis(m.Tags)
|
||||
if len(emojis) > 0 {
|
||||
subject = strings.Join(emojis, " ") + " " + subject
|
||||
}
|
||||
@@ -126,16 +81,7 @@ This message was sent by {ip} at {time} via {topicURL}`
|
||||
return body, nil
|
||||
}
|
||||
|
||||
var (
|
||||
//go:embed "mailer_emoji_map.json"
|
||||
emojisJSON string
|
||||
)
|
||||
|
||||
func toEmojis(tags []string) (emojisOut []string, tagsOut []string, err error) {
|
||||
var emojiMap map[string]string
|
||||
if err = json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
func toEmojis(tags []string) (emojisOut []string, tagsOut []string) {
|
||||
tagsOut = make([]string, 0)
|
||||
emojisOut = make([]string, 0)
|
||||
for _, t := range tags {
|
||||
@@ -1,4 +1,4 @@
|
||||
package server
|
||||
package mail
|
||||
|
||||
import (
|
||||
"testing"
|
||||
+80
-94
@@ -10,82 +10,94 @@ import (
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
const (
|
||||
verifyCodeExpiry = 10 * time.Minute
|
||||
verifyCodeLength = 6
|
||||
verifyCodeSubject = "ntfy email verification"
|
||||
tagMail = "mail"
|
||||
|
||||
emailVerificationSubject = "Verify your email for ntfy"
|
||||
passwordResetSubject = "Reset your ntfy password"
|
||||
)
|
||||
|
||||
// Config holds the SMTP configuration for the mail sender
|
||||
type Config struct {
|
||||
BaseURL string // ntfy base URL, used to build topic URLs in notification emails
|
||||
SMTPAddr string // SMTP server address (host:port)
|
||||
SMTPUser string // SMTP auth username
|
||||
SMTPPass string // SMTP auth password
|
||||
From string // Sender email address
|
||||
}
|
||||
|
||||
// Sender sends emails and manages email verification codes
|
||||
type Sender struct {
|
||||
config *Config
|
||||
codes map[string]verifyCode // Verification codes, keyed by email
|
||||
mu sync.Mutex
|
||||
closeChan chan struct{}
|
||||
// Sender sends all of ntfy's outgoing email: notification emails (the email-on-publish feature)
|
||||
// as well as the magic-link emails for email verification and password reset. realSender is the
|
||||
// SMTP-backed implementation; tests inject a fake.
|
||||
type Sender interface {
|
||||
SendNotification(to string, m *model.Message, senderIP string) error
|
||||
NotificationCounts() (total int64, success int64, failure int64)
|
||||
SendEmailVerification(to, link string) error
|
||||
SendPasswordReset(to, link string) error
|
||||
}
|
||||
|
||||
type verifyCode struct {
|
||||
code string
|
||||
expires time.Time
|
||||
// realSender is the SMTP-backed implementation of Sender. Pending verification/reset state lives
|
||||
// in the database (see user.Manager), not in this struct.
|
||||
type realSender struct {
|
||||
config *Config
|
||||
success int64
|
||||
failure int64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewSender creates a new mail Sender with the given SMTP config
|
||||
func NewSender(config *Config) *Sender {
|
||||
s := &Sender{
|
||||
config: config,
|
||||
codes: make(map[string]verifyCode),
|
||||
closeChan: make(chan struct{}),
|
||||
}
|
||||
go s.expireLoop()
|
||||
return s
|
||||
func NewSender(config *Config) Sender {
|
||||
return &realSender{config: config}
|
||||
}
|
||||
|
||||
// Close stops the background expiry loop
|
||||
func (s *Sender) Close() {
|
||||
close(s.closeChan)
|
||||
}
|
||||
|
||||
// Addr returns the SMTP server address
|
||||
func (s *Sender) Addr() string {
|
||||
return s.config.SMTPAddr
|
||||
}
|
||||
|
||||
// User returns the SMTP username
|
||||
func (s *Sender) User() string {
|
||||
return s.config.SMTPUser
|
||||
}
|
||||
|
||||
// From returns the sender email address
|
||||
func (s *Sender) From() string {
|
||||
return s.config.From
|
||||
}
|
||||
|
||||
// SendRaw sends a raw email message via SMTP
|
||||
func (s *Sender) SendRaw(to string, message []byte) error {
|
||||
host, _, err := net.SplitHostPort(s.config.SMTPAddr)
|
||||
// SendNotification formats a ntfy message into a notification email and sends it via SMTP. It
|
||||
// tracks success/failure counts, exposed via Counts (used for the server stats).
|
||||
func (s *realSender) SendNotification(to string, m *model.Message, senderIP string) error {
|
||||
message, err := formatMail(s.config.BaseURL, senderIP, s.config.From, to, m)
|
||||
if err != nil {
|
||||
s.count(false)
|
||||
return err
|
||||
}
|
||||
var auth smtp.Auth
|
||||
if s.config.SMTPUser != "" {
|
||||
auth = smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPass, host)
|
||||
}
|
||||
return smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, message)
|
||||
log.Tag(tagMail).Field("email_to", to).Debug("Sending notification email")
|
||||
err = s.sendRaw(to, []byte(message))
|
||||
s.count(err == nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// Send sends a plain text email via SMTP
|
||||
func (s *Sender) Send(to, subject, body string) error {
|
||||
// NotificationCounts returns the number of notification emails sent, broken down into total, success and failure
|
||||
func (s *realSender) NotificationCounts() (total int64, success int64, failure int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.success + s.failure, s.success, s.failure
|
||||
}
|
||||
|
||||
// SendEmailVerification sends an email containing a magic link to verify ownership of the
|
||||
// recipient address. The link carries a one-time token validated against the database.
|
||||
func (s *realSender) SendEmailVerification(to, link string) error {
|
||||
body := fmt.Sprintf(`Click the link below to verify this email address for your ntfy account:
|
||||
|
||||
%s
|
||||
|
||||
This link expires in 24 hours. If you did not request this, you can safely ignore this email.`, link)
|
||||
return s.send(to, emailVerificationSubject, body)
|
||||
}
|
||||
|
||||
// SendPasswordReset sends an email containing a magic link to set a new password. The link
|
||||
// carries a one-time token validated against the database.
|
||||
func (s *realSender) SendPasswordReset(to, link string) error {
|
||||
body := fmt.Sprintf(`Click the link below to set a new password for your ntfy account:
|
||||
|
||||
%s
|
||||
|
||||
This link expires in 1 hour. If you did not request this, you can safely ignore this email -- your password will not change.`, link)
|
||||
return s.send(to, passwordResetSubject, body)
|
||||
}
|
||||
|
||||
// send sends a plain text email via SMTP
|
||||
func (s *realSender) send(to, subject, body string) error {
|
||||
date := time.Now().UTC().Format(time.RFC1123Z)
|
||||
encodedSubject := mime.BEncoding.Encode("utf-8", subject)
|
||||
message := `From: ntfy <{from}>
|
||||
@@ -100,55 +112,29 @@ Content-Type: text/plain; charset="utf-8"
|
||||
message = strings.ReplaceAll(message, "{date}", date)
|
||||
message = strings.ReplaceAll(message, "{subject}", encodedSubject)
|
||||
message = strings.ReplaceAll(message, "{body}", body)
|
||||
log.Tag("mail").Field("email_to", to).Debug("Sending email")
|
||||
return s.SendRaw(to, []byte(message))
|
||||
log.Tag(tagMail).Field("email_to", to).Debug("Sending email")
|
||||
return s.sendRaw(to, []byte(message))
|
||||
}
|
||||
|
||||
// SendVerification generates a random code, stores it in-memory, and sends a verification email
|
||||
func (s *Sender) SendVerification(to string) error {
|
||||
code := util.RandomString(verifyCodeLength)
|
||||
s.mu.Lock()
|
||||
s.codes[to] = verifyCode{
|
||||
code: code,
|
||||
expires: time.Now().Add(verifyCodeExpiry),
|
||||
// sendRaw sends a raw email message via SMTP
|
||||
func (s *realSender) sendRaw(to string, message []byte) error {
|
||||
host, _, err := net.SplitHostPort(s.config.SMTPAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Unlock()
|
||||
body := fmt.Sprintf("Your ntfy email verification code is: %s\n\nThis code expires in 10 minutes.", code)
|
||||
return s.Send(to, verifyCodeSubject, body)
|
||||
var auth smtp.Auth
|
||||
if s.config.SMTPUser != "" {
|
||||
auth = smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPass, host)
|
||||
}
|
||||
return smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, message)
|
||||
}
|
||||
|
||||
// CheckVerification checks if the code matches and hasn't expired. Removes the entry on success.
|
||||
func (s *Sender) CheckVerification(email, code string) bool {
|
||||
func (s *realSender) count(ok bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
vc, ok := s.codes[email]
|
||||
if !ok || time.Now().After(vc.expires) || vc.code != code {
|
||||
return false
|
||||
}
|
||||
delete(s.codes, email)
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Sender) expireLoop() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.expireVerificationCodes()
|
||||
case <-s.closeChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sender) expireVerificationCodes() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now()
|
||||
for email, vc := range s.codes {
|
||||
if now.After(vc.expires) {
|
||||
delete(s.codes, email)
|
||||
}
|
||||
if ok {
|
||||
s.success++
|
||||
} else {
|
||||
s.failure++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,9 +44,11 @@ extra_javascript:
|
||||
- static/js/extra.js
|
||||
- static/js/bcrypt.js
|
||||
- static/js/config-generator.js
|
||||
- static/js/topic-generator.js
|
||||
extra_css:
|
||||
- static/css/extra.css
|
||||
- static/css/config-generator.css
|
||||
- static/css/topic-generator.css
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
|
||||
@@ -22,6 +22,7 @@ func TestParseURL_Success(t *testing.T) {
|
||||
require.Equal(t, "us-east-1", cfg.Region)
|
||||
require.Equal(t, "AKID", cfg.AccessKey)
|
||||
require.Equal(t, "SECRET", cfg.SecretKey)
|
||||
require.Equal(t, "https", cfg.Scheme)
|
||||
require.Equal(t, "s3.us-east-1.amazonaws.com", cfg.Endpoint)
|
||||
require.False(t, cfg.PathStyle)
|
||||
}
|
||||
@@ -38,6 +39,7 @@ func TestParseURL_WithEndpoint(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "my-bucket", cfg.Bucket)
|
||||
require.Equal(t, "prefix", cfg.Prefix)
|
||||
require.Equal(t, "https", cfg.Scheme)
|
||||
require.Equal(t, "s3.example.com", cfg.Endpoint)
|
||||
require.True(t, cfg.PathStyle)
|
||||
}
|
||||
@@ -45,10 +47,32 @@ func TestParseURL_WithEndpoint(t *testing.T) {
|
||||
func TestParseURL_EndpointHTTP(t *testing.T) {
|
||||
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=http://localhost:9000")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "http", cfg.Scheme)
|
||||
require.Equal(t, "localhost:9000", cfg.Endpoint)
|
||||
require.True(t, cfg.PathStyle)
|
||||
}
|
||||
|
||||
func TestParseURL_EndpointNoScheme(t *testing.T) {
|
||||
// A bare host:port endpoint (no scheme) must default to https for backward compatibility.
|
||||
// Without this, url.Parse treats the host as the scheme ("localhost:9000" -> scheme "localhost").
|
||||
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=localhost:9000")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "https", cfg.Scheme)
|
||||
require.Equal(t, "localhost:9000", cfg.Endpoint)
|
||||
require.True(t, cfg.PathStyle)
|
||||
require.Equal(t, "https://localhost:9000/my-bucket", cfg.BucketURL())
|
||||
}
|
||||
|
||||
func TestParseURL_EndpointNoSchemeHostname(t *testing.T) {
|
||||
// A dotted hostname with a port and no scheme must also default to https
|
||||
// ("minio.example.com:9000" must not become scheme "minio.example.com").
|
||||
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=minio.example.com:9000")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "https", cfg.Scheme)
|
||||
require.Equal(t, "minio.example.com:9000", cfg.Endpoint)
|
||||
require.Equal(t, "https://minio.example.com:9000/my-bucket", cfg.BucketURL())
|
||||
}
|
||||
|
||||
func TestParseURL_EndpointTrailingSlash(t *testing.T) {
|
||||
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=https://s3.example.com/")
|
||||
require.Nil(t, err)
|
||||
@@ -111,6 +135,11 @@ func TestConfig_BucketURL_PathStyle(t *testing.T) {
|
||||
require.Equal(t, "https://s3.example.com/my-bucket", c.BucketURL())
|
||||
}
|
||||
|
||||
func TestConfig_BucketURL_PathStyle_EndpointHTTP(t *testing.T) {
|
||||
c := &Config{Scheme: "http", Endpoint: "localhost:9000", Bucket: "b", PathStyle: true}
|
||||
require.Equal(t, "http://localhost:9000/b", c.BucketURL())
|
||||
}
|
||||
|
||||
func TestConfig_BucketURL_VirtualHosted(t *testing.T) {
|
||||
c := &Config{Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "my-bucket", PathStyle: false}
|
||||
require.Equal(t, "https://my-bucket.s3.us-east-1.amazonaws.com", c.BucketURL())
|
||||
|
||||
+8
-3
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
// Config holds the parsed fields from an S3 URL. Use ParseURL to create one from a URL string.
|
||||
type Config struct {
|
||||
Scheme string // URL scheme, e.g. "https" or "http"
|
||||
Endpoint string // host[:port] only, e.g. "s3.us-east-1.amazonaws.com"
|
||||
PathStyle bool
|
||||
Bucket string
|
||||
@@ -24,10 +25,14 @@ type Config struct {
|
||||
|
||||
// BucketURL returns the base URL for bucket-level operations.
|
||||
func (c *Config) BucketURL() string {
|
||||
if c.PathStyle {
|
||||
return fmt.Sprintf("https://%s/%s", c.Endpoint, c.Bucket)
|
||||
scheme := "https"
|
||||
if c.Scheme != "" {
|
||||
scheme = c.Scheme
|
||||
}
|
||||
return fmt.Sprintf("https://%s.%s", c.Bucket, c.Endpoint)
|
||||
if c.PathStyle {
|
||||
return fmt.Sprintf("%s://%s/%s", scheme, c.Endpoint, c.Bucket)
|
||||
}
|
||||
return fmt.Sprintf("%s://%s.%s", scheme, c.Bucket, c.Endpoint)
|
||||
}
|
||||
|
||||
// HostHeader returns the value for the Host header.
|
||||
|
||||
+10
-1
@@ -70,21 +70,30 @@ func ParseURL(s3URL string) (*Config, error) {
|
||||
return nil, fmt.Errorf("s3: region query parameter is required")
|
||||
}
|
||||
endpointParam := u.Query().Get("endpoint")
|
||||
var scheme string
|
||||
var endpoint string
|
||||
var pathStyle bool
|
||||
if endpointParam != "" {
|
||||
// Custom endpoint: strip scheme prefix to extract host[:port]
|
||||
// Custom endpoint: derive the scheme from the prefix and strip it to extract host[:port].
|
||||
// Default to https for backward compatibility, including bare "host:port" endpoints (no
|
||||
// scheme) -- url.Parse would otherwise misread the host before the port colon as the scheme.
|
||||
scheme = "https"
|
||||
if strings.HasPrefix(endpointParam, "http://") {
|
||||
scheme = "http"
|
||||
}
|
||||
ep := strings.TrimRight(endpointParam, "/")
|
||||
ep = strings.TrimPrefix(ep, "https://")
|
||||
ep = strings.TrimPrefix(ep, "http://")
|
||||
endpoint = ep
|
||||
pathStyle = true
|
||||
} else {
|
||||
scheme = "https"
|
||||
endpoint = fmt.Sprintf("s3.%s.amazonaws.com", region)
|
||||
pathStyle = false
|
||||
}
|
||||
disableHTTP2, _ := strconv.ParseBool(u.Query().Get("disable_http2"))
|
||||
return &Config{
|
||||
Scheme: scheme,
|
||||
Endpoint: endpoint,
|
||||
PathStyle: pathStyle,
|
||||
Bucket: bucket,
|
||||
|
||||
@@ -11,11 +11,20 @@ if [ -z "$1" ]; then
|
||||
echo "Example:"
|
||||
echo " $0 emoji-converted.json"
|
||||
echo " $0 $ROOTDIR/web/src/app/emojis.js"
|
||||
echo " $0 $ROOTDIR/web/src/app/emojisMapped.js"
|
||||
echo " $0 $ROOTDIR/docs/emojis.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$1" == *.js ]]; then
|
||||
if [[ "$1" == *emojisMapped.js ]]; then
|
||||
# Small alias -> emoji lookup used to render tags as emojis. Precomputed so the full
|
||||
# emoji dataset (emojis.js) stays out of the main web bundle.
|
||||
echo -n "// This file is generated by scripts/emoji-convert.sh -- alias to emoji lookup
|
||||
// Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json
|
||||
export default " > "$1"
|
||||
cat "$SCRIPTDIR/emoji.json" | jq -jc '[.[] | .aliases[] as $a | {key: $a, value: .emoji}] | from_entries' >> "$1"
|
||||
echo ";" >> "$1"
|
||||
elif [[ "$1" == *.js ]]; then
|
||||
echo -n "// This file is generated by scripts/emoji-convert.sh to reduce the size
|
||||
// Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json
|
||||
export const rawEmojis = " > "$1"
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ const (
|
||||
DefaultVisitorEmailLimitReplenish = time.Hour
|
||||
DefaultVisitorTopicCreationLimitBurst = 100
|
||||
DefaultVisitorTopicCreationLimitReplenish = time.Minute
|
||||
DefaultVisitorAccountCreationLimitBurst = 3
|
||||
DefaultVisitorAccountCreationLimitBurst = 6 // Shared by signup and password-reset requests (same per-visitor bucket)
|
||||
DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
|
||||
DefaultVisitorAuthFailureLimitBurst = 30
|
||||
DefaultVisitorAuthFailureLimitReplenish = time.Minute
|
||||
|
||||
+6
-3
@@ -136,16 +136,18 @@ var (
|
||||
errHTTPBadRequestTemplateMessageTooLarge = &errHTTP{40041, http.StatusBadRequest, "invalid request: message or title is too large after replacing template", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestTemplateMessageNotJSON = &errHTTP{40042, http.StatusBadRequest, "invalid request: message body must be JSON if templating is enabled", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestTemplateInvalid = &errHTTP{40043, http.StatusBadRequest, "invalid request: could not parse template", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, or define", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, define, or block", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestTemplateExecuteFailed = &errHTTP{40045, http.StatusBadRequest, "invalid request: template execution failed", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestTemplateExecutionTimeout = &errHTTP{40055, http.StatusBadRequest, "invalid request: template execution timed out", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil}
|
||||
errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#updating-deleting-notifications", nil}
|
||||
errHTTPBadRequestEmailAddressInvalid = &errHTTP{40050, http.StatusBadRequest, "invalid request: invalid e-mail address", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
|
||||
errHTTPBadRequestEmailVerificationCodeInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification code invalid or expired", "", nil}
|
||||
errHTTPBadRequestEmailVerificationLinkInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification link invalid or expired", "", nil}
|
||||
errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
|
||||
errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
|
||||
errHTTPBadRequestResetLinkInvalid = &errHTTP{40054, http.StatusBadRequest, "invalid request: password reset link invalid or expired", "", nil}
|
||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", 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}
|
||||
@@ -156,6 +158,7 @@ var (
|
||||
errHTTPConflictProvisionedUserChange = &errHTTP{40905, http.StatusConflict, "conflict: cannot change or delete provisioned user", "", nil}
|
||||
errHTTPConflictProvisionedTokenChange = &errHTTP{40906, http.StatusConflict, "conflict: cannot change or delete provisioned token", "", nil}
|
||||
errHTTPConflictEmailExists = &errHTTP{40907, http.StatusConflict, "conflict: email address already exists", "", nil}
|
||||
errHTTPConflictEmailPrimaryElsewhere = &errHTTP{40908, http.StatusConflict, "conflict: email address is the primary email on another account", "", 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}
|
||||
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
|
||||
@@ -165,7 +168,7 @@ var (
|
||||
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
||||
errHTTPTooManyRequestsLimitAccountActions = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many account requests", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
||||
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}
|
||||
errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
||||
|
||||
+54
-349
@@ -17,13 +17,11 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/template"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -31,7 +29,7 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"gopkg.in/yaml.v2"
|
||||
"heckel.io/ntfy/v2/action"
|
||||
"heckel.io/ntfy/v2/attachment"
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
@@ -42,7 +40,6 @@ import (
|
||||
"heckel.io/ntfy/v2/payments"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"heckel.io/ntfy/v2/util/sprig"
|
||||
"heckel.io/ntfy/v2/webpush"
|
||||
)
|
||||
|
||||
@@ -57,11 +54,11 @@ type Server struct {
|
||||
unixListener net.Listener
|
||||
smtpServer *smtp.Server
|
||||
smtpServerBackend *smtpBackend
|
||||
smtpSender mailer
|
||||
mailSender *mail.Sender
|
||||
mailer mail.Sender
|
||||
topics map[string]*topic
|
||||
visitors map[string]*visitor // ip:<ip> or user:<user>
|
||||
firebaseClient *firebaseClient
|
||||
twilio *twilioClient
|
||||
messages int64 // Total number of messages (persisted if messageCache enabled)
|
||||
messagesHistory []int64 // Last n values of the messages counter, used to determine rate
|
||||
userManager *user.Manager // Might be nil!
|
||||
@@ -91,10 +88,16 @@ var (
|
||||
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
|
||||
updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`)
|
||||
clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`)
|
||||
deletePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/delete$`)
|
||||
sequenceIDRegex = topicRegex
|
||||
|
||||
webConfigPath = "/config.js"
|
||||
webManifestPath = "/manifest.webmanifest"
|
||||
webAppConfigPath = "/config.js"
|
||||
webAppManifestPath = "/manifest.webmanifest"
|
||||
webAppEmailVerifyPathPrefix = "/account/email/verify/" // Browser landing route; raw token appended
|
||||
webAppEmailVerifyRegex = regexp.MustCompile(`^/account/email/verify/[-_A-Za-z0-9]+$`) // Magic-link landing (served by the web app)
|
||||
webAppPasswordResetPathPrefix = "/account/password/reset/" // Browser landing route; raw token appended
|
||||
webAppPasswordResetRegex = regexp.MustCompile(`^/account/password/reset/[-_A-Za-z0-9]+$`) // Password-reset landing (served by the web app)
|
||||
|
||||
accountPath = "/account"
|
||||
matrixPushPath = "/_matrix/push/v1/notify"
|
||||
metricsPath = "/metrics"
|
||||
@@ -116,6 +119,10 @@ var (
|
||||
apiAccountPhoneVerifyPath = "/v1/account/phone/verify"
|
||||
apiAccountEmailPath = "/v1/account/email"
|
||||
apiAccountEmailVerifyPath = "/v1/account/email/verify"
|
||||
apiAccountEmailPrimaryPath = "/v1/account/email/primary"
|
||||
apiAccountEmailResendPath = "/v1/account/email/resend"
|
||||
apiAccountPasswordResetRequestPath = "/v1/account/password/reset/request"
|
||||
apiAccountPasswordResetPath = "/v1/account/password/reset"
|
||||
apiAccountBillingPortalPath = "/v1/account/billing/portal"
|
||||
apiAccountBillingWebhookPath = "/v1/account/billing/webhook"
|
||||
apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription"
|
||||
@@ -143,10 +150,12 @@ var (
|
||||
templatesFs embed.FS // Contains template config files (e.g. grafana.yml, github.yml, ...)
|
||||
templatesDir = "templates"
|
||||
|
||||
// templateDisallowedRegex tests a template for disallowed expressions. While not really dangerous, they
|
||||
// are not useful, and seem potentially troublesome.
|
||||
templateDisallowedRegex = regexp.MustCompile(`(?m)\{\{-?\s*(call|template|define)\b`)
|
||||
templateNameRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)
|
||||
templateNameRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)
|
||||
|
||||
// templateMaxExecutionTime is the wall-clock deadline for a single template render, a DoS guard
|
||||
// (GHSA-rhwf-xgc9-m9fp). It is a var (not a const) solely so tests can raise it; it is never
|
||||
// mutated in production.
|
||||
templateMaxExecutionTime = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -160,7 +169,6 @@ const (
|
||||
unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber
|
||||
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
|
||||
messagesHistoryMax = 10 // Number of message count values to keep in memory
|
||||
templateMaxExecutionTime = 100 * time.Millisecond // Maximum time a template can take to execute, used to prevent DoS attacks
|
||||
templateMaxOutputBytes = 1024 * 1024 // Maximum number of bytes a template can output, used to prevent DoS attacks
|
||||
templateFileExtension = ".yml" // Template files must end with this extension
|
||||
)
|
||||
@@ -176,16 +184,15 @@ const (
|
||||
// New instantiates a new Server. It creates the cache and adds a Firebase
|
||||
// subscriber (if configured).
|
||||
func New(conf *Config) (*Server, error) {
|
||||
var mailer mailer
|
||||
var mailSender *mail.Sender
|
||||
var sender mail.Sender
|
||||
if conf.SMTPSenderAddr != "" {
|
||||
mailSender = mail.NewSender(&mail.Config{
|
||||
sender = mail.NewSender(&mail.Config{
|
||||
BaseURL: conf.BaseURL,
|
||||
SMTPAddr: conf.SMTPSenderAddr,
|
||||
SMTPUser: conf.SMTPSenderUser,
|
||||
SMTPPass: conf.SMTPSenderPass,
|
||||
From: conf.SMTPSenderFrom,
|
||||
})
|
||||
mailer = &smtpSender{config: conf, sender: mailSender}
|
||||
}
|
||||
var stripe stripeAPI
|
||||
if payments.Available && conf.StripeSecretKey != "" {
|
||||
@@ -290,8 +297,8 @@ func New(conf *Config) (*Server, error) {
|
||||
webPush: wp,
|
||||
attachment: attachmentStore,
|
||||
firebaseClient: firebaseClient,
|
||||
smtpSender: mailer,
|
||||
mailSender: mailSender,
|
||||
twilio: newTwilioClient(conf, userManager),
|
||||
mailer: sender,
|
||||
topics: topics,
|
||||
userManager: userManager,
|
||||
messages: messages,
|
||||
@@ -443,9 +450,6 @@ func (s *Server) Stop() {
|
||||
if s.smtpServer != nil {
|
||||
s.smtpServer.Close()
|
||||
}
|
||||
if s.mailSender != nil {
|
||||
s.mailSender.Close()
|
||||
}
|
||||
if s.attachment != nil {
|
||||
s.attachment.Close()
|
||||
}
|
||||
@@ -542,7 +546,7 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor,
|
||||
|
||||
func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/" && s.config.WebRoot == "/" {
|
||||
return s.ensureWebEnabled(s.handleRoot)(w, r, v)
|
||||
return s.ensureWebEnabled(s.handleWebApp)(w, r, v)
|
||||
} else if r.Method == http.MethodHead && r.URL.Path == "/" {
|
||||
return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath {
|
||||
@@ -551,9 +555,9 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.ensureAdmin(s.handleVersion)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == apiConfigPath {
|
||||
return s.handleConfig(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == webAppConfigPath {
|
||||
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == webManifestPath {
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == webAppManifestPath {
|
||||
return s.ensureWebPushEnabled(s.handleWebManifest)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == apiUsersPath {
|
||||
return s.ensureAdmin(s.handleUsersGet)(w, r, v)
|
||||
@@ -611,12 +615,20 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
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.MethodPut && r.URL.Path == apiAccountEmailVerifyPath {
|
||||
return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailVerify)))(w, r, v)
|
||||
} else if r.Method == http.MethodPut && r.URL.Path == apiAccountEmailPath {
|
||||
return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailAdd)))(w, r, v)
|
||||
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountEmailVerifyPath {
|
||||
return s.ensureEmailsEnabled(s.limitRequests(s.handleAccountEmailVerify))(w, r, v) // No ensureUser: clicked from a mail client, possibly logged out
|
||||
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountEmailPath {
|
||||
return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailDelete)))(w, r, v)
|
||||
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountEmailPrimaryPath {
|
||||
return s.ensureUser(s.withAccountSync(s.handleAccountEmailSetPrimary))(w, r, v)
|
||||
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountEmailResendPath {
|
||||
return s.ensureUser(s.ensureEmailsEnabled(s.handleAccountEmailResend))(w, r, v)
|
||||
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordResetRequestPath {
|
||||
return s.ensureEmailsEnabled(s.limitRequests(s.handleAccountPasswordResetRequest))(w, r, v) // Unauthenticated
|
||||
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordResetPath {
|
||||
return s.ensureEmailsEnabled(s.limitRequests(s.handleAccountPasswordReset))(w, r, v) // Unauthenticated
|
||||
} else if r.Method == http.MethodPost && apiWebPushPath == r.URL.Path {
|
||||
return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushUpdate))(w, r, v)
|
||||
} else if r.Method == http.MethodDelete && apiWebPushPath == r.URL.Path {
|
||||
@@ -643,9 +655,9 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.transformMatrixJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublishMatrix)))(w, r, v)
|
||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && (topicPathRegex.MatchString(r.URL.Path) || updatePathRegex.MatchString(r.URL.Path)) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
||||
} else if r.Method == http.MethodDelete && updatePathRegex.MatchString(r.URL.Path) {
|
||||
} else if (r.Method == http.MethodDelete && updatePathRegex.MatchString(r.URL.Path)) || (r.Method == http.MethodGet && deletePathRegex.MatchString(r.URL.Path)) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleDelete))(w, r, v)
|
||||
} else if r.Method == http.MethodPut && clearPathRegex.MatchString(r.URL.Path) {
|
||||
} else if (r.Method == http.MethodGet || r.Method == http.MethodPut) && clearPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleClear))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
||||
@@ -659,17 +671,14 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeWS))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequests(s.authorizeTopicRead(s.handleTopicAuth))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && (webAppEmailVerifyRegex.MatchString(r.URL.Path) || webAppPasswordResetRegex.MatchString(r.URL.Path)) {
|
||||
return s.ensureWebEnabled(s.handleWebAppNoIndex)(w, r, v) // Magic-link landing pages (client-side routes)
|
||||
} else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) {
|
||||
return s.ensureWebEnabled(s.handleTopic)(w, r, v)
|
||||
}
|
||||
return errHTTPNotFound
|
||||
}
|
||||
|
||||
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
r.URL.Path = webAppIndex
|
||||
return s.handleStatic(w, r, v)
|
||||
}
|
||||
|
||||
func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
unifiedpush := readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see PUT/POST too!
|
||||
if unifiedpush {
|
||||
@@ -678,8 +687,7 @@ func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request, v *visitor)
|
||||
_, err := io.WriteString(w, `{"unifiedpush":{"version":1}}`+"\n")
|
||||
return err
|
||||
}
|
||||
r.URL.Path = webAppIndex
|
||||
return s.handleStatic(w, r, v)
|
||||
return s.handleWebApp(w, r, v)
|
||||
}
|
||||
|
||||
func (s *Server) handleEmpty(_ http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
@@ -697,61 +705,6 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor
|
||||
return s.writeJSON(w, response)
|
||||
}
|
||||
|
||||
func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
return s.writeJSON(w, s.configResponse())
|
||||
}
|
||||
|
||||
func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
b, err := json.MarshalIndent(s.configResponse(), "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/javascript")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
_, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b)))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) configResponse() *apiConfigResponse {
|
||||
return &apiConfigResponse{
|
||||
BaseURL: "", // Will translate to window.location.origin
|
||||
AppRoot: s.config.WebRoot,
|
||||
EnableLogin: s.config.EnableLogin,
|
||||
RequireLogin: s.config.RequireLogin,
|
||||
EnableSignup: s.config.EnableSignup,
|
||||
EnablePayments: s.config.StripeSecretKey != "",
|
||||
EnableCalls: s.config.TwilioAccount != "",
|
||||
EnableEmails: s.config.SMTPSenderFrom != "",
|
||||
EnableEmailVerify: s.config.SMTPSenderVerify,
|
||||
EnableReservations: s.config.EnableReservations,
|
||||
EnableWebPush: s.config.WebPushPublicKey != "",
|
||||
BillingContact: s.config.BillingContact,
|
||||
WebPushPublicKey: s.config.WebPushPublicKey,
|
||||
DisallowedTopics: s.config.DisallowedTopics,
|
||||
ConfigHash: s.config.Hash(),
|
||||
}
|
||||
}
|
||||
|
||||
// handleWebManifest serves the web app manifest for the progressive web app (PWA)
|
||||
func (s *Server) handleWebManifest(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
response := &webManifestResponse{
|
||||
Name: "ntfy",
|
||||
Description: "ntfy lets you send push notifications via scripts from any computer or phone",
|
||||
ShortName: "ntfy",
|
||||
Scope: "/",
|
||||
StartURL: s.config.WebRoot,
|
||||
Display: "standalone",
|
||||
BackgroundColor: "#ffffff",
|
||||
ThemeColor: "#317f6f",
|
||||
Icons: []*webManifestIcon{
|
||||
{SRC: "/static/images/pwa-192x192.png", Sizes: "192x192", Type: "image/png"},
|
||||
{SRC: "/static/images/pwa-512x512.png", Sizes: "512x512", Type: "image/png"},
|
||||
},
|
||||
}
|
||||
return s.writeJSONWithContentType(w, response, "application/manifest+json")
|
||||
}
|
||||
|
||||
// handleMetrics returns Prometheus metrics. This endpoint is only called if enable-metrics is set,
|
||||
// and listen-metrics-http is not set.
|
||||
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||
@@ -759,19 +712,6 @@ func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request, _ *visito
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStatic returns all static resources (excluding the docs), including the web app
|
||||
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||
r.URL.Path = webSiteDir + r.URL.Path
|
||||
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDocs returns static resources related to the docs
|
||||
func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||
util.Gzip(http.FileServer(http.FS(docsStaticCached))).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStats returns the publicly available server stats
|
||||
func (s *Server) handleStats(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
s.mu.RLock()
|
||||
@@ -901,7 +841,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
|
||||
}
|
||||
if call != "" {
|
||||
var httpErr *errHTTP
|
||||
call, httpErr = s.convertPhoneNumber(v.User(), call)
|
||||
call, httpErr = s.twilio.convertPhoneNumber(v.User(), call)
|
||||
if httpErr != nil {
|
||||
return nil, httpErr.With(t)
|
||||
} else if !vrate.CallAllowed() {
|
||||
@@ -946,11 +886,11 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
|
||||
if s.firebaseClient != nil && firebase {
|
||||
go s.sendToFirebase(v, m)
|
||||
}
|
||||
if s.smtpSender != nil && email != "" {
|
||||
if s.mailer != nil && email != "" {
|
||||
go s.sendEmail(v, m, email)
|
||||
}
|
||||
if s.config.TwilioAccount != "" && call != "" {
|
||||
go s.callPhone(v, r, m, call)
|
||||
go s.twilio.callPhone(v, r, m, call)
|
||||
}
|
||||
if s.config.UpstreamBaseURL != "" && !unifiedpush { // UP messages are not sent to upstream
|
||||
go s.forwardPollRequest(v, m)
|
||||
@@ -1108,7 +1048,7 @@ func (s *Server) sendToFirebase(v *visitor, m *model.Message) {
|
||||
|
||||
func (s *Server) sendEmail(v *visitor, m *model.Message, email string) {
|
||||
logvm(v, m).Tag(tagEmail).Field("email", email).Info("Sending email to %s", email)
|
||||
if err := s.smtpSender.Send(v, m, email); err != nil {
|
||||
if err := s.mailer.SendNotification(email, m, v.ip.String()); err != nil {
|
||||
logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error())
|
||||
minc(metricEmailsPublishedFailure)
|
||||
return
|
||||
@@ -1208,7 +1148,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bo
|
||||
if email != "" && !emailAddressRegex.MatchString(email) && !toBool(email) {
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
if s.smtpSender == nil && email != "" {
|
||||
if s.mailer == nil && email != "" {
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestEmailDisabled
|
||||
}
|
||||
call = readParam(r, "x-call", "call")
|
||||
@@ -1259,7 +1199,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bo
|
||||
}
|
||||
actionsStr := readParam(r, "x-actions", "actions", "action")
|
||||
if actionsStr != "" {
|
||||
m.Actions, e = parseActions(actionsStr)
|
||||
m.Actions, e = action.Parse(actionsStr)
|
||||
if e != nil {
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestActionsInvalid.Wrap("%s", e.Error())
|
||||
}
|
||||
@@ -1309,7 +1249,7 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *model.Message
|
||||
} else if m.Attachment != nil && m.Attachment.Name != "" {
|
||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
|
||||
} else if template.Enabled() {
|
||||
return s.handleBodyAsTemplatedTextMessage(m, template, body, priorityStr) // Case 5
|
||||
return s.handleBodyAsTemplatedTextMessage(r.Context(), m, template, body, priorityStr) // Case 5
|
||||
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
|
||||
return s.handleBodyAsTextMessage(m, body) // Case 6
|
||||
}
|
||||
@@ -1345,114 +1285,6 @@ func (s *Server) handleBodyAsTextMessage(m *model.Message, body *util.PeekedRead
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleBodyAsTemplatedTextMessage(m *model.Message, template templateMode, body *util.PeekedReadCloser, priorityStr string) error {
|
||||
body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
|
||||
if err != nil {
|
||||
return err
|
||||
} else if body.LimitReached {
|
||||
return errHTTPEntityTooLargeJSONBody
|
||||
}
|
||||
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
|
||||
if template.FileMode() {
|
||||
if err := s.renderTemplateFromFile(m, template.FileName(), peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := s.renderTemplateFromParams(m, peekedBody, priorityStr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(m.Title) > s.config.MessageSizeLimit || len(m.Message) > s.config.MessageSizeLimit {
|
||||
return errHTTPBadRequestTemplateMessageTooLarge
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderTemplateFromFile transforms the JSON message body according to a template from the filesystem.
|
||||
// The template file must be in the templates directory, or in the configured template directory.
|
||||
func (s *Server) renderTemplateFromFile(m *model.Message, templateName, peekedBody string) error {
|
||||
if !templateNameRegex.MatchString(templateName) {
|
||||
return errHTTPBadRequestTemplateFileNotFound
|
||||
}
|
||||
templateContent, _ := templatesFs.ReadFile(filepath.Join(templatesDir, templateName+templateFileExtension)) // Read from the embedded filesystem first
|
||||
if s.config.TemplateDir != "" {
|
||||
if b, _ := os.ReadFile(filepath.Join(s.config.TemplateDir, templateName+templateFileExtension)); len(b) > 0 {
|
||||
templateContent = b
|
||||
}
|
||||
}
|
||||
if len(templateContent) == 0 {
|
||||
return errHTTPBadRequestTemplateFileNotFound
|
||||
}
|
||||
var tpl templateFile
|
||||
if err := yaml.Unmarshal(templateContent, &tpl); err != nil {
|
||||
return errHTTPBadRequestTemplateFileInvalid
|
||||
}
|
||||
var err error
|
||||
if tpl.Message != nil {
|
||||
if m.Message, err = s.renderTemplate(templateName+" (message)", *tpl.Message, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tpl.Title != nil {
|
||||
if m.Title, err = s.renderTemplate(templateName+" (title)", *tpl.Title, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tpl.Priority != nil {
|
||||
renderedPriority, err := s.renderTemplate(templateName+" (priority)", *tpl.Priority, peekedBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Priority, err = util.ParsePriority(renderedPriority); err != nil {
|
||||
return errHTTPBadRequestPriorityInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderTemplateFromParams transforms the JSON message body according to the inline template in the
|
||||
// message, title, and priority parameters.
|
||||
func (s *Server) renderTemplateFromParams(m *model.Message, peekedBody string, priorityStr string) error {
|
||||
var err error
|
||||
if m.Message, err = s.renderTemplate("priority query parameter", m.Message, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Title, err = s.renderTemplate("title query parameter", m.Title, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
if priorityStr != "" {
|
||||
renderedPriority, err := s.renderTemplate("priority query parameter", priorityStr, peekedBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Priority, err = util.ParsePriority(renderedPriority); err != nil {
|
||||
return errHTTPBadRequestPriorityInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderTemplate renders a template with the given JSON source data.
|
||||
func (s *Server) renderTemplate(name, tpl, source string) (string, error) {
|
||||
if templateDisallowedRegex.MatchString(tpl) {
|
||||
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
|
||||
}
|
||||
var data any
|
||||
if err := json.Unmarshal([]byte(source), &data); err != nil {
|
||||
return "", errHTTPBadRequestTemplateMessageNotJSON
|
||||
}
|
||||
t, err := template.New("").Funcs(sprig.TxtFuncMap()).Parse(tpl)
|
||||
if err != nil {
|
||||
return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error())
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
limitWriter := util.NewLimitWriter(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), util.NewFixedLimiter(templateMaxOutputBytes))
|
||||
if err := t.Execute(limitWriter, data); err != nil {
|
||||
return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("template %s: %s", name, err.Error())
|
||||
}
|
||||
return strings.TrimSpace(strings.ReplaceAll(buf.String(), "\\n", "\n")), nil // replace any remaining "\n" (those outside of template curly braces) with newlines
|
||||
}
|
||||
|
||||
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *model.Message, body *util.PeekedReadCloser) error {
|
||||
if s.attachment == nil || s.config.BaseURL == "" {
|
||||
return errHTTPBadRequestAttachmentsDisallowed.With(m)
|
||||
@@ -1506,7 +1338,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *model.Me
|
||||
func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
encoder := func(msg *model.Message) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(msg.ForJSON()); err != nil {
|
||||
if err := util.EncodeJSON(&buf, msg.ForJSON()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
@@ -1517,7 +1349,7 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *
|
||||
func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
encoder := func(msg *model.Message) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(msg.ForJSON()); err != nil {
|
||||
if err := util.EncodeJSON(&buf, msg.ForJSON()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if msg.Event != model.MessageEvent && msg.Event != model.MessageDeleteEvent && msg.Event != model.MessageClearEvent {
|
||||
@@ -2236,130 +2068,6 @@ func (s *Server) transformMatrixJSON(next handleFunc) handleFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) authorizeTopicWrite(next handleFunc) handleFunc {
|
||||
return s.authorizeTopic(next, user.PermissionWrite)
|
||||
}
|
||||
|
||||
func (s *Server) authorizeTopicRead(next handleFunc) handleFunc {
|
||||
return s.authorizeTopic(next, user.PermissionRead)
|
||||
}
|
||||
|
||||
func (s *Server) authorizeTopic(next handleFunc, perm user.Permission) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if s.userManager == nil {
|
||||
return next(w, r, v)
|
||||
}
|
||||
topics, _, err := s.topicsFromPath(v, r.URL.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u := v.User()
|
||||
for _, t := range topics {
|
||||
if err := s.userManager.Authorize(u, t.ID, perm); err != nil {
|
||||
logvr(v, r).With(t).Err(err).Debug("Access to topic %s not authorized", t.ID)
|
||||
return errHTTPForbidden.With(t)
|
||||
}
|
||||
}
|
||||
return next(w, r, v)
|
||||
}
|
||||
}
|
||||
|
||||
// maybeAuthenticate reads the "Authorization" header and will try to authenticate the user
|
||||
// if it is set.
|
||||
//
|
||||
// - If auth-file is not configured, immediately return an IP-based visitor
|
||||
// - If the header is not set or not supported (anything non-Basic and non-Bearer),
|
||||
// an IP-based visitor is returned
|
||||
// - If the header is set, authenticate will be called to check the username/password (Basic auth),
|
||||
// or the token (Bearer auth), and read the user from the database
|
||||
//
|
||||
// This function will ALWAYS return a visitor, even if an error occurs (e.g. unauthorized), so
|
||||
// that subsequent logging calls still have a visitor context.
|
||||
func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) {
|
||||
// Read the "Authorization" header value and exit out early if it's not set
|
||||
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes)
|
||||
vip := s.visitor(ip, nil)
|
||||
if s.userManager == nil {
|
||||
return vip, nil
|
||||
}
|
||||
header, err := readAuthHeader(r)
|
||||
if err != nil {
|
||||
return vip, err
|
||||
} else if !supportedAuthHeader(header) {
|
||||
return vip, nil
|
||||
}
|
||||
// If we're trying to auth, check the rate limiter first
|
||||
if !vip.AuthAllowed() {
|
||||
return vip, errHTTPTooManyRequestsLimitAuthFailure // Always return visitor, even when error occurs!
|
||||
}
|
||||
u, err := s.authenticate(r, header)
|
||||
if err != nil {
|
||||
vip.AuthFailed()
|
||||
logr(r).Err(err).Debug("Authentication failed")
|
||||
return vip, errHTTPUnauthorized // Always return visitor, even when error occurs!
|
||||
}
|
||||
// Authentication with user was successful
|
||||
return s.visitor(ip, u), nil
|
||||
}
|
||||
|
||||
// authenticate a user based on basic auth username/password (Authorization: Basic ...), or token auth (Authorization: Bearer ...).
|
||||
// The Authorization header can be passed as a header or the ?auth=... query param. The latter is required only to
|
||||
// support the WebSocket JavaScript class, which does not support passing headers during the initial request. The auth
|
||||
// query param is effectively doubly base64 encoded. Its format is base64(Basic base64(user:pass)).
|
||||
func (s *Server) authenticate(r *http.Request, header string) (user *user.User, err error) {
|
||||
if strings.HasPrefix(header, "Bearer") {
|
||||
return s.authenticateBearerAuth(r, strings.TrimSpace(strings.TrimPrefix(header, "Bearer")))
|
||||
}
|
||||
return s.authenticateBasicAuth(r, header)
|
||||
}
|
||||
|
||||
// readAuthHeader reads the raw value of the Authorization header, either from the actual HTTP header,
|
||||
// or from the ?auth... query parameter
|
||||
func readAuthHeader(r *http.Request) (string, error) {
|
||||
value := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
queryParam := readQueryParam(r, "authorization", "auth")
|
||||
if queryParam != "" {
|
||||
a, err := base64.RawURLEncoding.DecodeString(queryParam)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
value = strings.TrimSpace(string(a))
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// supportedAuthHeader returns true only if the Authorization header value starts
|
||||
// with "Basic" or "Bearer". In particular, an empty value is not supported, and neither
|
||||
// are things like "WebPush", or "vapid" (see #629).
|
||||
func supportedAuthHeader(value string) bool {
|
||||
value = strings.ToLower(value)
|
||||
return strings.HasPrefix(value, "basic ") || strings.HasPrefix(value, "bearer ")
|
||||
}
|
||||
|
||||
func (s *Server) authenticateBasicAuth(r *http.Request, value string) (user *user.User, err error) {
|
||||
r.Header.Set("Authorization", value)
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
return nil, errors.New("invalid basic auth")
|
||||
} else if username == "" {
|
||||
return s.authenticateBearerAuth(r, password) // Treat password as token
|
||||
}
|
||||
return s.userManager.Authenticate(username, password)
|
||||
}
|
||||
|
||||
func (s *Server) authenticateBearerAuth(r *http.Request, token string) (*user.User, error) {
|
||||
u, err := s.userManager.AuthenticateToken(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes)
|
||||
go s.userManager.EnqueueTokenUpdate(token, &user.TokenUpdate{
|
||||
LastAccess: time.Now(),
|
||||
LastOrigin: ip,
|
||||
})
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *Server) visitor(ip netip.Addr, user *user.User) *visitor {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -2381,10 +2089,7 @@ func (s *Server) writeJSON(w http.ResponseWriter, v any) error {
|
||||
func (s *Server) writeJSONWithContentType(w http.ResponseWriter, v any, contentType string) error {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return util.EncodeJSON(w, v)
|
||||
}
|
||||
|
||||
func (s *Server) updateAndWriteStats(messagesCount int64) {
|
||||
|
||||
+285
-59
@@ -15,8 +15,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
syncTopicAccountSyncEvent = "sync"
|
||||
tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much
|
||||
syncTopicAccountSyncEvent = "sync"
|
||||
tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much
|
||||
emailVerificationTokenExpiry = 24 * time.Hour // Magic-link lifetime for email verification
|
||||
passwordResetTokenExpiry = time.Hour // Magic-link lifetime for password reset (higher-privilege -> shorter)
|
||||
)
|
||||
|
||||
func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
@@ -27,14 +29,17 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *
|
||||
} else if u != nil {
|
||||
return errHTTPUnauthorized // Cannot create account from user context
|
||||
}
|
||||
if !v.AccountCreationAllowed() {
|
||||
return errHTTPTooManyRequestsLimitAccountCreation
|
||||
if !v.AccountActionAllowed() {
|
||||
return errHTTPTooManyRequestsLimitAccountActions
|
||||
}
|
||||
}
|
||||
newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if newAccount.Email != "" && !emailAddressRegex.MatchString(newAccount.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
if existingUser, _ := s.userManager.User(newAccount.Username); existingUser != nil {
|
||||
return errHTTPConflictUserExists
|
||||
}
|
||||
@@ -45,7 +50,17 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *
|
||||
}
|
||||
return err
|
||||
}
|
||||
v.AccountCreated()
|
||||
v.AccountActionPerformed()
|
||||
// If an email was provided and email sending is configured, start verification (best-effort).
|
||||
// The address becomes the primary email on verify (the new account has no primary yet); a
|
||||
// failure to send must not fail signup, so we only log it.
|
||||
if newAccount.Email != "" && s.mailer != nil {
|
||||
if u, err := s.userManager.User(newAccount.Username); err != nil {
|
||||
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to load new user for email verification")
|
||||
} else if err := s.enqueueEmailVerification(u.ID, newAccount.Email); err != nil {
|
||||
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to send signup email verification")
|
||||
}
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
@@ -92,6 +107,12 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||
if u.Prefs.Language != nil {
|
||||
response.Language = *u.Prefs.Language
|
||||
}
|
||||
if u.Prefs.DateFormat != nil {
|
||||
response.DateFormat = *u.Prefs.DateFormat
|
||||
}
|
||||
if u.Prefs.TimeFormat != nil {
|
||||
response.TimeFormat = *u.Prefs.TimeFormat
|
||||
}
|
||||
if u.Prefs.Notification != nil {
|
||||
response.Notification = u.Prefs.Notification
|
||||
}
|
||||
@@ -160,13 +181,25 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||
response.PhoneNumbers = phoneNumbers
|
||||
}
|
||||
}
|
||||
if s.mailSender != nil {
|
||||
if s.mailer != nil {
|
||||
emails, err := s.userManager.Emails(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(emails) > 0 {
|
||||
response.Emails = emails
|
||||
pendingEmails, err := s.userManager.PendingEmails(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Combine verified (with primary flag) and pending (unverified) into one list
|
||||
emailInfos := make([]*apiAccountEmailInfo, 0, len(emails)+len(pendingEmails))
|
||||
for _, email := range emails {
|
||||
emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email.Address, Primary: email.Primary})
|
||||
}
|
||||
for _, email := range pendingEmails {
|
||||
emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email, Pending: true})
|
||||
}
|
||||
if len(emailInfos) > 0 {
|
||||
response.Emails = emailInfos
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -346,6 +379,12 @@ func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Requ
|
||||
if newPrefs.Language != nil {
|
||||
prefs.Language = newPrefs.Language
|
||||
}
|
||||
if newPrefs.DateFormat != nil {
|
||||
prefs.DateFormat = newPrefs.DateFormat
|
||||
}
|
||||
if newPrefs.TimeFormat != nil {
|
||||
prefs.TimeFormat = newPrefs.TimeFormat
|
||||
}
|
||||
if newPrefs.Notification != nil {
|
||||
if prefs.Notification == nil {
|
||||
prefs.Notification = &user.NotificationPrefs{}
|
||||
@@ -574,7 +613,7 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
// 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 {
|
||||
if err := s.twilio.verifyPhoneNumber(v, r, req.Number, req.Channel); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
@@ -589,7 +628,7 @@ func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Requ
|
||||
if !phoneNumberRegex.MatchString(req.Number) {
|
||||
return errHTTPBadRequestPhoneNumberInvalid
|
||||
}
|
||||
if err := s.verifyPhoneNumberCheck(v, r, req.Number, req.Code); err != nil {
|
||||
if err := s.twilio.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")
|
||||
@@ -615,83 +654,254 @@ func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.R
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
// handleAccountEmailAdd starts email verification (PUT /v1/account/email): it generates a
|
||||
// magic-link token, stores a pending verification, and emails the link. The address is NOT
|
||||
// added to the verified list until the user clicks the link (handleAccountEmailVerify).
|
||||
func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
u := v.User()
|
||||
req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !emailAddressRegex.MatchString(req.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
// Check user is allowed to add emails
|
||||
if u == nil {
|
||||
return errHTTPUnauthorized
|
||||
} else if u.IsUser() && u.Tier != nil && u.Tier.EmailLimit == 0 {
|
||||
// Check user is allowed to add emails (the tier email limit gates the feature)
|
||||
if u.IsUser() && u.Tier != nil && u.Tier.EmailLimit == 0 {
|
||||
return errHTTPUnauthorized
|
||||
} else if u.IsUser() && u.Tier == nil && s.config.VisitorEmailLimitBurst == 0 {
|
||||
return errHTTPUnauthorized
|
||||
}
|
||||
// Check if email already exists
|
||||
// Reject if already verified on this account (pending re-requests are fine -- they replace)
|
||||
emails, err := s.userManager.Emails(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if util.Contains(emails, req.Email) {
|
||||
} else if emails.Contains(req.Email) {
|
||||
return errHTTPConflictEmailExists
|
||||
}
|
||||
// Check email rate limit (counts against the user's email quota)
|
||||
// Rate limit (counts against the user's email quota)
|
||||
if !v.EmailAllowed() {
|
||||
return errHTTPTooManyRequestsLimitEmails
|
||||
}
|
||||
// Send verification email
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Sending email verification")
|
||||
if err := s.mailSender.SendVerification(req.Email); err != nil {
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Starting email verification")
|
||||
if err := s.enqueueEmailVerification(u.ID, req.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
// handleAccountEmailVerify performs verification from the (unauthenticated) landing page
|
||||
// (POST /v1/account/email/verify): it validates the raw token, adds the address to the user's
|
||||
// verified emails, and -- if the user has no primary yet -- promotes it. No auth is required;
|
||||
// the token binds the action to a user, so the click works from a logged-out mail client.
|
||||
func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if req.Token == "" {
|
||||
return errHTTPBadRequestEmailVerificationLinkInvalid
|
||||
}
|
||||
m, err := s.userManager.VerifyEmail(req.Token)
|
||||
if errors.Is(err, user.ErrMagicLinkNotFound) {
|
||||
return errHTTPBadRequestEmailVerificationLinkInvalid
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Field("email", m.Email).Info("Email verified")
|
||||
// Refresh the verified user's other sessions. The request is unauthenticated (v.User() is
|
||||
// usually nil), so resolve the user from the token row and publish to their sync topic.
|
||||
s.publishSyncEventForUserIDAsync(v, m.UserID)
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// handleAccountEmailDelete removes an email address, whether verified or still pending
|
||||
// (DELETE /v1/account/email). Removing the primary leaves the account with no primary.
|
||||
func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
u := v.User()
|
||||
req, err := readJSONWithLimit[apiAccountEmailAddRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !emailAddressRegex.MatchString(req.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
} else if !s.mailSender.CheckVerification(req.Email, req.Code) {
|
||||
return errHTTPBadRequestEmailVerificationCodeInvalid
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Adding email as verified")
|
||||
if err := s.userManager.AddEmail(u.ID, req.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
u := v.User()
|
||||
req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !emailAddressRegex.MatchString(req.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Deleting verified email")
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Deleting email (verified or pending)")
|
||||
if err := s.userManager.RemoveEmail(u.ID, req.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
// Also drop any pending verification for the address (no-op if there is none)
|
||||
if err := s.userManager.DeleteEmailVerification(u.ID, req.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// convertEmailAddress checks the email address against the user's verified email list.
|
||||
// If smtp-sender-verify is false (default), the email is passed through as-is for
|
||||
// backwards compatibility. If true, the user must be authenticated and the email must be
|
||||
// in their verified list. "yes"/"true"/"1" resolves to the first verified email.
|
||||
func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHTTP) {
|
||||
if !s.config.SMTPSenderVerify {
|
||||
if toBool(email) {
|
||||
return "", errHTTPBadRequestEmailAddressInvalid
|
||||
// handleAccountEmailSetPrimary marks an already-verified email as the user's primary (recovery)
|
||||
// email (POST /v1/account/email/primary).
|
||||
func (s *Server) handleAccountEmailSetPrimary(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
u := v.User()
|
||||
req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !emailAddressRegex.MatchString(req.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Setting primary email")
|
||||
err = s.userManager.SetPrimaryEmail(u.ID, req.Email)
|
||||
if errors.Is(err, user.ErrEmailPrimaryElsewhere) {
|
||||
return errHTTPConflictEmailPrimaryElsewhere
|
||||
} else if errors.Is(err, user.ErrEmailNotFound) {
|
||||
return errHTTPBadRequestEmailAddressNotVerified
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// handleAccountEmailResend re-sends a pending email verification (POST /v1/account/email/resend).
|
||||
func (s *Server) handleAccountEmailResend(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
u := v.User()
|
||||
req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !emailAddressRegex.MatchString(req.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
// Only resend for an address that is actually pending on this account
|
||||
pending, err := s.userManager.PendingEmails(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !util.Contains(pending, req.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
if !v.EmailAllowed() {
|
||||
return errHTTPTooManyRequestsLimitEmails
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Resending email verification")
|
||||
if err := s.enqueueEmailVerification(u.ID, req.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// enqueueEmailVerification generates a magic-link token for the given address, stores the
|
||||
// pending verification (replacing any existing one), and emails the link. Shared by the add,
|
||||
// resend, signup, and Stripe paths. Requires base-url to build an absolute link.
|
||||
func (s *Server) enqueueEmailVerification(userID, email string) error {
|
||||
if s.config.BaseURL == "" {
|
||||
return errHTTPInternalErrorMissingBaseURL
|
||||
}
|
||||
token, err := s.userManager.AddMagicLink(user.MagicLinkKindEmailVerify, userID, email, emailVerificationTokenExpiry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
link := s.config.BaseURL + webAppEmailVerifyPathPrefix + token
|
||||
return s.mailer.SendEmailVerification(email, link)
|
||||
}
|
||||
|
||||
// handleAccountPasswordResetRequest starts a password reset (POST /v1/account/password/reset/request,
|
||||
// unauthenticated). It resolves the identifier (username or primary email) to at most one account
|
||||
// and emails a reset link to that account's primary email. The response is always a uniform 200,
|
||||
// regardless of whether anything matched, so it cannot be used to probe for accounts.
|
||||
func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
req, err := readJSONWithLimit[apiAccountPasswordResetRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Rate limit via the shared per-visitor account-creation bucket (no new limiter/config)
|
||||
if !v.AccountActionAllowed() {
|
||||
return errHTTPTooManyRequestsLimitAccountActions
|
||||
}
|
||||
v.AccountActionPerformed() // Consume a token on every request (including no-match), to throttle probing
|
||||
identifier := strings.TrimSpace(req.Identifier)
|
||||
if identifier != "" && s.config.BaseURL != "" {
|
||||
if userID, email, ok := s.resolveResetPasswordTarget(identifier); ok {
|
||||
token, err := s.userManager.AddMagicLink(user.MagicLinkKindPasswordReset, userID, "", passwordResetTokenExpiry)
|
||||
if err != nil {
|
||||
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to create password reset token")
|
||||
} else {
|
||||
link := s.config.BaseURL + webAppPasswordResetPathPrefix + token
|
||||
logvr(v, r).Tag(tagAccount).Field("user_id", userID).Info("Sending password reset link")
|
||||
if err := s.mailer.SendPasswordReset(email, link); err != nil {
|
||||
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to send password reset email")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logvr(v, r).Tag(tagAccount).Debug("Password reset requested for unknown identifier (uniform response)")
|
||||
}
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// resolveResetPasswordTarget resolves a reset identifier (username or primary email) to a single account
|
||||
// and its primary email. It applies the reset policy on top of the lookup: provisioned users are
|
||||
// excluded, and ok=false is returned unless the account has a verified primary email (reset
|
||||
// requires one, and that is where the link is sent).
|
||||
func (s *Server) resolveResetPasswordTarget(identifier string) (userID string, email string, ok bool) {
|
||||
u, err := s.userManager.UserByEmailOrUsername(identifier)
|
||||
if err != nil || u == nil || u.Provisioned {
|
||||
return "", "", false
|
||||
}
|
||||
primary, err := s.userManager.PrimaryEmail(u.ID)
|
||||
if err != nil || primary == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return u.ID, primary, true
|
||||
}
|
||||
|
||||
// handleAccountPasswordReset performs the reset (POST /v1/account/password/reset, unauthenticated):
|
||||
// it validates the token and sets the new password. Existing access tokens stay valid.
|
||||
func (s *Server) handleAccountPasswordReset(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
req, err := readJSONWithLimit[apiAccountPasswordResetConfirmRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Token == "" {
|
||||
return errHTTPBadRequestResetLinkInvalid
|
||||
} else if req.Password == "" {
|
||||
return errHTTPBadRequest
|
||||
}
|
||||
err = s.userManager.ResetPassword(req.Token, req.Password)
|
||||
if errors.Is(err, user.ErrMagicLinkNotFound) || errors.Is(err, user.ErrProvisionedUserChange) {
|
||||
return errHTTPBadRequestResetLinkInvalid // Generic 400 (provisioned users can't be reset; don't leak that)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Info("Password reset performed")
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// convertEmailAddress resolves the X-Email value to the address ntfy should send to.
|
||||
//
|
||||
// "yes"/"true"/"1" resolves to the user's primary verified address -- or, if no primary is
|
||||
// designated (e.g. a provisioned user), the first verified address (alphabetically). This is
|
||||
// independent of smtp-sender-verify: it only requires an authenticated user with a verified
|
||||
// address, since it means "send to my own email".
|
||||
//
|
||||
// A literal address is sent as-is when smtp-sender-verify is false (the default, backwards
|
||||
// compatible); when true, the address must be one the user has verified.
|
||||
func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHTTP) {
|
||||
if toBool(email) {
|
||||
if u == nil {
|
||||
return "", errHTTPBadRequestAnonymousEmailNotAllowed
|
||||
} else if s.userManager == nil {
|
||||
return "", errHTTPBadRequestEmailAddressNotVerified
|
||||
}
|
||||
primary, err := s.userManager.PrimaryEmail(u.ID)
|
||||
if err != nil {
|
||||
return "", errHTTPInternalError
|
||||
} else if primary != "" {
|
||||
return primary, nil
|
||||
}
|
||||
// No primary designated -> fall back to the first verified address, if any
|
||||
emails, err := s.userManager.Emails(u.ID)
|
||||
if err != nil {
|
||||
return "", errHTTPInternalError
|
||||
} else if len(emails) > 0 {
|
||||
return emails[0].Address, nil
|
||||
}
|
||||
return "", errHTTPBadRequestEmailAddressNotVerified
|
||||
}
|
||||
// A literal address
|
||||
if !s.config.SMTPSenderVerify {
|
||||
return email, nil
|
||||
} else if u == nil {
|
||||
return "", errHTTPBadRequestAnonymousEmailNotAllowed
|
||||
@@ -701,12 +911,7 @@ func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHT
|
||||
emails, err := s.userManager.Emails(u.ID)
|
||||
if err != nil {
|
||||
return "", errHTTPInternalError
|
||||
} else if len(emails) == 0 {
|
||||
return "", errHTTPBadRequestEmailAddressNotVerified
|
||||
}
|
||||
if toBool(email) {
|
||||
return emails[0], nil
|
||||
} else if util.Contains(emails, email) {
|
||||
} else if emails.Contains(email) {
|
||||
return email, nil
|
||||
}
|
||||
return "", errHTTPBadRequestEmailAddressNotVerified
|
||||
@@ -721,9 +926,30 @@ func (s *Server) publishSyncEventAsync(v *visitor) {
|
||||
}()
|
||||
}
|
||||
|
||||
// publishSyncEvent publishes a sync message to the user's sync topic
|
||||
// publishSyncEvent publishes a sync message to the authenticated user's sync topic
|
||||
func (s *Server) publishSyncEvent(v *visitor) error {
|
||||
u := v.User()
|
||||
return s.publishSyncEventForUser(v, v.User())
|
||||
}
|
||||
|
||||
// publishSyncEventForUserIDAsync publishes a sync event to the sync topic of the user with the
|
||||
// given ID, resolving the user first. Used by the unauthenticated email-verify handler, where
|
||||
// the request visitor has no associated user but the token identifies the account to refresh.
|
||||
func (s *Server) publishSyncEventForUserIDAsync(v *visitor, userID string) {
|
||||
go func() {
|
||||
u, err := s.userManager.UserByID(userID)
|
||||
if err != nil {
|
||||
logv(v).Err(err).Trace("Error loading user for sync event")
|
||||
return
|
||||
}
|
||||
if err := s.publishSyncEventForUser(v, u); err != nil {
|
||||
logv(v).Err(err).Trace("Error publishing to user's sync topic")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// publishSyncEventForUser publishes a sync message to the given user's sync topic, using v as
|
||||
// the publishing visitor (for rate-limit accounting). No-op if the user has no sync topic.
|
||||
func (s *Server) publishSyncEventForUser(v *visitor, u *user.User) error {
|
||||
if u == nil || u.SyncTopic == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
// captureMailer is a fake mailer that records the magic links it is asked to send, so tests can
|
||||
// "click" them without a real SMTP server. The notification side is a no-op.
|
||||
type captureMailer struct {
|
||||
verifyLinks map[string]string // email -> verification link
|
||||
resetLinks map[string]string // email -> reset link
|
||||
}
|
||||
|
||||
func newCaptureMailer() *captureMailer {
|
||||
return &captureMailer{verifyLinks: map[string]string{}, resetLinks: map[string]string{}}
|
||||
}
|
||||
|
||||
func (c *captureMailer) SendEmailVerification(to, link string) error {
|
||||
c.verifyLinks[to] = link
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *captureMailer) SendPasswordReset(to, link string) error {
|
||||
c.resetLinks[to] = link
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *captureMailer) SendNotification(to string, m *model.Message, senderIP string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *captureMailer) NotificationCounts() (total int64, success int64, failure int64) {
|
||||
return 0, 0, 0
|
||||
}
|
||||
|
||||
// newEmailTestServer creates a server with email sending "enabled" (SMTP + base-url configured)
|
||||
// and a capturing mailer injected, plus a tier-less user "ben" logged in via basic auth.
|
||||
func newEmailTestServer(t *testing.T, databaseURL string) (*Server, *captureMailer, map[string]string) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderAddr = "localhost:25"
|
||||
conf.SMTPSenderFrom = "noreply@example.com"
|
||||
conf.BaseURL = "https://ntfy.example.com"
|
||||
s := newTestServer(t, conf)
|
||||
mailer := newCaptureMailer()
|
||||
s.mailer = mailer
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
auth := map[string]string{"Authorization": util.BasicAuth("ben", "ben")}
|
||||
return s, mailer, auth
|
||||
}
|
||||
|
||||
func getAccount(t *testing.T, s *Server, auth map[string]string) *apiAccountResponse {
|
||||
rr := request(t, s, "GET", "/v1/account", "", auth)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||
require.Nil(t, err)
|
||||
return account
|
||||
}
|
||||
|
||||
// verifiedAddrs / pendingAddrs / primaryAddr extract the addresses from the structured email
|
||||
// list returned by GET /v1/account, so assertions stay readable.
|
||||
func verifiedAddrs(account *apiAccountResponse) []string {
|
||||
addrs := make([]string, 0)
|
||||
for _, e := range account.Emails {
|
||||
if !e.Pending {
|
||||
addrs = append(addrs, e.Address)
|
||||
}
|
||||
}
|
||||
return addrs
|
||||
}
|
||||
|
||||
func pendingAddrs(account *apiAccountResponse) []string {
|
||||
addrs := make([]string, 0)
|
||||
for _, e := range account.Emails {
|
||||
if e.Pending {
|
||||
addrs = append(addrs, e.Address)
|
||||
}
|
||||
}
|
||||
return addrs
|
||||
}
|
||||
|
||||
func primaryAddr(account *apiAccountResponse) string {
|
||||
for _, e := range account.Emails {
|
||||
if e.Primary {
|
||||
return e.Address
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func tokenFromLink(t *testing.T, link, prefix string) string {
|
||||
require.True(t, strings.HasPrefix(link, prefix), "link %q missing prefix %q", link, prefix)
|
||||
return strings.TrimPrefix(link, prefix)
|
||||
}
|
||||
|
||||
func TestAccount_Email_AddVerifySetsPrimary(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Start verification
|
||||
rr := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Pending, not yet verified, no primary
|
||||
account := getAccount(t, s, auth)
|
||||
require.Equal(t, []string{"ben@example.com"}, pendingAddrs(account))
|
||||
require.Empty(t, verifiedAddrs(account))
|
||||
require.Equal(t, "", primaryAddr(account))
|
||||
|
||||
// "Click" the captured link (unauthenticated POST)
|
||||
token := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/")
|
||||
rr = request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Now verified + primary, no longer pending
|
||||
account = getAccount(t, s, auth)
|
||||
require.Equal(t, []string{"ben@example.com"}, verifiedAddrs(account))
|
||||
require.Equal(t, "ben@example.com", primaryAddr(account))
|
||||
require.Empty(t, pendingAddrs(account))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Email_VerifyInvalidToken(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, _, _ := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
rr := request(t, s, "POST", "/v1/account/email/verify", `{"token":"doesnotexist"}`, nil)
|
||||
require.Equal(t, 400, rr.Code)
|
||||
require.Equal(t, 40051, toHTTPError(t, rr.Body.String()).Code)
|
||||
|
||||
// Empty token also rejected
|
||||
rr = request(t, s, "POST", "/v1/account/email/verify", `{"token":""}`, nil)
|
||||
require.Equal(t, 400, rr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Email_DeletePending(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, _, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
|
||||
require.Equal(t, []string{"ben@example.com"}, pendingAddrs(getAccount(t, s, auth)))
|
||||
|
||||
// Deleting the pending address clears it (no verification ever happened)
|
||||
require.Equal(t, 200, request(t, s, "DELETE", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
|
||||
account := getAccount(t, s, auth)
|
||||
require.Empty(t, pendingAddrs(account))
|
||||
require.Empty(t, verifiedAddrs(account))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Email_Resend(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
|
||||
firstLink := mailer.verifyLinks["ben@example.com"]
|
||||
require.NotEmpty(t, firstLink)
|
||||
|
||||
// Resend issues a fresh link (the old one is replaced)
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/resend", `{"email":"ben@example.com"}`, auth).Code)
|
||||
require.NotEqual(t, firstLink, mailer.verifyLinks["ben@example.com"])
|
||||
|
||||
// The old token no longer verifies; the new one does
|
||||
oldToken := tokenFromLink(t, firstLink, "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 400, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, oldToken), nil).Code)
|
||||
newToken := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, newToken), nil).Code)
|
||||
|
||||
// Resending for a non-pending address is rejected
|
||||
require.Equal(t, 400, request(t, s, "POST", "/v1/account/email/resend", `{"email":"never@example.com"}`, auth).Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Email_SetPrimaryCollision(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// ben verifies shared@ -> becomes his primary
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"shared@example.com"}`, auth).Code)
|
||||
benToken := tokenFromLink(t, mailer.verifyLinks["shared@example.com"], "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, benToken), nil).Code)
|
||||
require.Equal(t, "shared@example.com", primaryAddr(getAccount(t, s, auth)))
|
||||
|
||||
// alice verifies the same address -> allowed as secondary, but it is not her primary
|
||||
require.Nil(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false))
|
||||
aliceAuth := map[string]string{"Authorization": util.BasicAuth("alice", "alice")}
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"shared@example.com"}`, aliceAuth).Code)
|
||||
aliceToken := tokenFromLink(t, mailer.verifyLinks["shared@example.com"], "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, aliceToken), nil).Code)
|
||||
aliceAccount := getAccount(t, s, aliceAuth)
|
||||
require.Equal(t, []string{"shared@example.com"}, verifiedAddrs(aliceAccount))
|
||||
require.Equal(t, "", primaryAddr(aliceAccount))
|
||||
|
||||
// alice trying to promote it to primary collides with ben's
|
||||
rr := request(t, s, "POST", "/v1/account/email/primary", `{"email":"shared@example.com"}`, aliceAuth)
|
||||
require.Equal(t, 409, rr.Code)
|
||||
require.Equal(t, 40908, toHTTPError(t, rr.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
|
||||
// verifyEmailFor runs the full add->click flow so the user ends up with a verified primary email.
|
||||
func verifyEmailFor(t *testing.T, s *Server, mailer *captureMailer, auth map[string]string, email string) {
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", fmt.Sprintf(`{"email":"%s"}`, email), auth).Code)
|
||||
token := tokenFromLink(t, mailer.verifyLinks[email], "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code)
|
||||
}
|
||||
|
||||
// canLogin returns true if username/password authenticates (via the token-create endpoint).
|
||||
func canLogin(t *testing.T, s *Server, username, password string) bool {
|
||||
rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{"Authorization": util.BasicAuth(username, password)})
|
||||
return rr.Code == 200
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_ByUsername(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
verifyEmailFor(t, s, mailer, auth, "ben@example.com")
|
||||
|
||||
// Request reset by username
|
||||
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
token := tokenFromLink(t, mailer.resetLinks["ben@example.com"], "https://ntfy.example.com/account/password/reset/")
|
||||
|
||||
// Confirm with a new password
|
||||
rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
require.True(t, canLogin(t, s, "ben", "brandnew"))
|
||||
require.False(t, canLogin(t, s, "ben", "ben"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_ByEmail(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
verifyEmailFor(t, s, mailer, auth, "ben@example.com")
|
||||
|
||||
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben@example.com"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
token := tokenFromLink(t, mailer.resetLinks["ben@example.com"], "https://ntfy.example.com/account/password/reset/")
|
||||
rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.True(t, canLogin(t, s, "ben", "brandnew"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_EmailLookalikeUsernameDoesNotShadow(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Account A (the email owner): user "ben" with verified primary email "phil@example.com"
|
||||
verifyEmailFor(t, s, mailer, auth, "phil@example.com")
|
||||
|
||||
// Account B (the squatter): a different account whose USERNAME looks like A's email, with
|
||||
// its own, different verified primary email
|
||||
require.Nil(t, s.userManager.AddUser("phil@example.com", "squatterpass", user.RoleUser, false))
|
||||
squatter, err := s.userManager.User("phil@example.com")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.AddEmail(squatter.ID, "squatter@example.com"))
|
||||
require.Nil(t, s.userManager.SetPrimaryEmail(squatter.ID, "squatter@example.com"))
|
||||
|
||||
// Reset by the ambiguous identifier: the verified email must win over the look-alike username
|
||||
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"phil@example.com"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.NotEmpty(t, mailer.resetLinks["phil@example.com"]) // sent to the email owner (account A)
|
||||
require.Empty(t, mailer.resetLinks["squatter@example.com"]) // NOT the username squatter (account B)
|
||||
|
||||
// The token resets account A (ben); the squatter's password is untouched
|
||||
token := tokenFromLink(t, mailer.resetLinks["phil@example.com"], "https://ntfy.example.com/account/password/reset/")
|
||||
rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.True(t, canLogin(t, s, "ben", "brandnew")) // account A was reset
|
||||
require.True(t, canLogin(t, s, "phil@example.com", "squatterpass")) // account B unaffected
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_UnknownIdentifierUniform(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, _ := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Unknown identifier still returns a uniform 200, and no email is sent
|
||||
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ghost"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Empty(t, mailer.resetLinks)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_NoPrimaryEmailNoSend(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, _ := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// ben exists but has no verified primary email -> uniform 200, nothing sent
|
||||
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Empty(t, mailer.resetLinks)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Signup_WithEmail_SendsVerification(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.EnableSignup = true
|
||||
conf.SMTPSenderAddr = "localhost:25"
|
||||
conf.SMTPSenderFrom = "noreply@example.com"
|
||||
conf.BaseURL = "https://ntfy.example.com"
|
||||
s := newTestServer(t, conf)
|
||||
mailer := newCaptureMailer()
|
||||
s.mailer = mailer
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Sign up with an optional email -> account created and a verification link sent
|
||||
rr := request(t, s, "POST", "/v1/account", `{"username":"emma","password":"emmapass","email":"emma@example.com"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
link := mailer.verifyLinks["emma@example.com"]
|
||||
require.NotEmpty(t, link)
|
||||
|
||||
// Verifying the link makes it the (first) primary email
|
||||
token := tokenFromLink(t, link, "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code)
|
||||
account := getAccount(t, s, map[string]string{"Authorization": util.BasicAuth("emma", "emmapass")})
|
||||
require.Equal(t, []string{"emma@example.com"}, verifiedAddrs(account))
|
||||
require.Equal(t, "emma@example.com", primaryAddr(account))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Signup_WithoutEmail_NoSend(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.EnableSignup = true
|
||||
conf.SMTPSenderAddr = "localhost:25"
|
||||
conf.SMTPSenderFrom = "noreply@example.com"
|
||||
conf.BaseURL = "https://ntfy.example.com"
|
||||
s := newTestServer(t, conf)
|
||||
mailer := newCaptureMailer()
|
||||
s.mailer = mailer
|
||||
defer s.closeDatabases()
|
||||
|
||||
// No email -> account created, nothing sent
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account", `{"username":"emma","password":"emmapass"}`, nil).Code)
|
||||
require.Empty(t, mailer.verifyLinks)
|
||||
|
||||
// Invalid email -> rejected
|
||||
rr := request(t, s, "POST", "/v1/account", `{"username":"otto","password":"ottopass","email":"not-an-email"}`, nil)
|
||||
require.Equal(t, 400, rr.Code)
|
||||
require.Equal(t, 40050, toHTTPError(t, rr.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Email_ProvisionedPrimary(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
hash, err := user.HashPassword("provpass", user.DefaultUserPasswordBcryptCost)
|
||||
require.Nil(t, err)
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderAddr = "localhost:25"
|
||||
conf.SMTPSenderFrom = "noreply@example.com"
|
||||
conf.BaseURL = "https://ntfy.example.com"
|
||||
conf.AuthUsers = []*user.User{{Name: "prov", Hash: hash, Role: user.RoleUser}}
|
||||
s := newTestServer(t, conf)
|
||||
mailer := newCaptureMailer()
|
||||
s.mailer = mailer
|
||||
defer s.closeDatabases()
|
||||
auth := map[string]string{"Authorization": util.BasicAuth("prov", "provpass")}
|
||||
|
||||
// A provisioned user's first verified email becomes their primary (used by X-Email: yes;
|
||||
// password reset stays blocked separately for provisioned users)
|
||||
verifyEmailFor(t, s, mailer, auth, "prov@example.com")
|
||||
account := getAccount(t, s, auth)
|
||||
require.Equal(t, []string{"prov@example.com"}, verifiedAddrs(account))
|
||||
require.Equal(t, "prov@example.com", primaryAddr(account))
|
||||
|
||||
// Verify a second address and explicitly set it primary -> allowed, star moves
|
||||
verifyEmailFor(t, s, mailer, auth, "prov2@example.com")
|
||||
rr := request(t, s, "POST", "/v1/account/email/primary", `{"email":"prov2@example.com"}`, auth)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
account = getAccount(t, s, auth)
|
||||
require.Equal(t, "prov2@example.com", primaryAddr(account))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_ProvisionedUserNoSend(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
// Provision a user via config (AuthUsers), with email sending enabled
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderAddr = "localhost:25"
|
||||
conf.SMTPSenderFrom = "noreply@example.com"
|
||||
conf.BaseURL = "https://ntfy.example.com"
|
||||
conf.AuthUsers = []*user.User{
|
||||
{Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: user.RoleUser},
|
||||
}
|
||||
s := newTestServer(t, conf)
|
||||
mailer := newCaptureMailer()
|
||||
s.mailer = mailer
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Give the provisioned user a verified primary email anyway
|
||||
prov, err := s.userManager.User("prov")
|
||||
require.Nil(t, err)
|
||||
require.True(t, prov.Provisioned)
|
||||
require.Nil(t, s.userManager.AddEmail(prov.ID, "prov@example.com"))
|
||||
require.Nil(t, s.userManager.SetPrimaryEmail(prov.ID, "prov@example.com"))
|
||||
|
||||
// Reset request by username and by email -> uniform 200, but no email sent (can't reset)
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"prov"}`, nil).Code)
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"prov@example.com"}`, nil).Code)
|
||||
require.Empty(t, mailer.resetLinks)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_InvalidToken(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, _, _ := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
rr := request(t, s, "POST", "/v1/account/password/reset", `{"token":"nope","password":"brandnew"}`, nil)
|
||||
require.Equal(t, 400, rr.Code)
|
||||
require.Equal(t, 40054, toHTTPError(t, rr.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Email_AddDuplicateVerified(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
|
||||
token := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code)
|
||||
|
||||
// Adding the same already-verified address is a conflict
|
||||
rr := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth)
|
||||
require.Equal(t, 409, rr.Code)
|
||||
require.Equal(t, 40907, toHTTPError(t, rr.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
@@ -78,7 +78,8 @@ func TestAccount_Signup_LimitReached(t *testing.T) {
|
||||
s := newTestServer(t, conf)
|
||||
defer s.closeDatabases()
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
// Burst is DefaultVisitorAccountCreationLimitBurst (shared with password-reset requests)
|
||||
for i := 0; i < 6; i++ {
|
||||
rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
@@ -131,7 +132,8 @@ func TestAccount_Signup_Rate_Limit(t *testing.T) {
|
||||
conf.EnableSignup = true
|
||||
s := newTestServer(t, conf)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
// Burst is DefaultVisitorAccountCreationLimitBurst (shared with password-reset requests)
|
||||
for i := 0; i < 6; i++ {
|
||||
rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil)
|
||||
require.Equal(t, 200, rr.Code, "failed on iteration %d", i)
|
||||
}
|
||||
@@ -149,7 +151,7 @@ func TestAccount_Get_Anonymous(t *testing.T) {
|
||||
conf.VisitorAttachmentTotalSizeLimit = 5123
|
||||
conf.AttachmentFileSizeLimit = 512
|
||||
s := newTestServer(t, conf)
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
defer s.closeDatabases()
|
||||
|
||||
rr := request(t, s, "GET", "/v1/account", "", nil)
|
||||
@@ -205,12 +207,19 @@ func TestAccount_ChangeSettings(t *testing.T) {
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
rr = request(t, s, "PATCH", "/v1/account/settings", `{"date_format": "iso8601", "time_format": "24h"}`, map[string]string{
|
||||
"Authorization": util.BearerAuth(token.Value),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
rr = request(t, s, "GET", "/v1/account", `{"username":"marian", "password":"marian"}`, map[string]string{
|
||||
"Authorization": util.BearerAuth(token.Value),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||
require.Equal(t, "de", account.Language)
|
||||
require.Equal(t, "iso8601", account.DateFormat) // Merged, not overwritten by previous PATCH
|
||||
require.Equal(t, "24h", account.TimeFormat)
|
||||
require.Equal(t, util.Int(86400), account.Notification.DeleteAfter)
|
||||
require.Equal(t, util.String("juntos"), account.Notification.Sound)
|
||||
require.Nil(t, account.Notification.MinPriority) // Not set
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/user"
|
||||
)
|
||||
|
||||
// maybeAuthenticate reads the "Authorization" header and will try to authenticate the user
|
||||
// if it is set.
|
||||
//
|
||||
// - If auth-file is not configured, immediately return an IP-based visitor
|
||||
// - If the header is not set or not supported (anything non-Basic and non-Bearer),
|
||||
// an IP-based visitor is returned
|
||||
// - If the header is set, authenticate will be called to check the username/password (Basic auth),
|
||||
// or the token (Bearer auth), and read the user from the database
|
||||
//
|
||||
// This function will ALWAYS return a visitor, even if an error occurs (e.g. unauthorized), so
|
||||
// that subsequent logging calls still have a visitor context.
|
||||
func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) {
|
||||
// Read the "Authorization" header value and exit out early if it's not set
|
||||
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes)
|
||||
vip := s.visitor(ip, nil)
|
||||
if s.userManager == nil {
|
||||
return vip, nil
|
||||
}
|
||||
header, err := readAuthHeader(r)
|
||||
if err != nil {
|
||||
return vip, err
|
||||
} else if !supportedAuthHeader(header) {
|
||||
return vip, nil
|
||||
}
|
||||
// If we're trying to auth, check the rate limiter first
|
||||
if !vip.AuthAllowed() {
|
||||
return vip, errHTTPTooManyRequestsLimitAuthFailure // Always return visitor, even when error occurs!
|
||||
}
|
||||
u, err := s.authenticate(r, header)
|
||||
if err != nil {
|
||||
vip.AuthFailed()
|
||||
logr(r).Err(err).Debug("Authentication failed")
|
||||
return vip, errHTTPUnauthorized // Always return visitor, even when error occurs!
|
||||
}
|
||||
// Authentication with user was successful
|
||||
return s.visitor(ip, u), nil
|
||||
}
|
||||
|
||||
// authenticate a user based on basic auth username/password (Authorization: Basic ...), or token auth (Authorization: Bearer ...).
|
||||
// The Authorization header can be passed as a header or the ?auth=... query param. The latter is required only to
|
||||
// support the WebSocket JavaScript class, which does not support passing headers during the initial request. The auth
|
||||
// query param is effectively doubly base64 encoded. Its format is base64(Basic base64(user:pass)).
|
||||
func (s *Server) authenticate(r *http.Request, header string) (user *user.User, err error) {
|
||||
if strings.HasPrefix(header, "Bearer") {
|
||||
return s.authenticateBearerAuth(r, strings.TrimSpace(strings.TrimPrefix(header, "Bearer")))
|
||||
}
|
||||
return s.authenticateBasicAuth(r, header)
|
||||
}
|
||||
|
||||
// readAuthHeader reads the raw value of the Authorization header, either from the actual HTTP header,
|
||||
// or from the ?auth... query parameter
|
||||
func readAuthHeader(r *http.Request) (string, error) {
|
||||
value := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
queryParam := readQueryParam(r, "authorization", "auth")
|
||||
if queryParam != "" {
|
||||
a, err := base64.RawURLEncoding.DecodeString(queryParam)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
value = strings.TrimSpace(string(a))
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// supportedAuthHeader returns true only if the Authorization header value starts
|
||||
// with "Basic" or "Bearer". In particular, an empty value is not supported, and neither
|
||||
// are things like "WebPush", or "vapid" (see #629).
|
||||
func supportedAuthHeader(value string) bool {
|
||||
value = strings.ToLower(value)
|
||||
return strings.HasPrefix(value, "basic ") || strings.HasPrefix(value, "bearer ")
|
||||
}
|
||||
|
||||
func (s *Server) authenticateBasicAuth(r *http.Request, value string) (user *user.User, err error) {
|
||||
r.Header.Set("Authorization", value)
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
return nil, errors.New("invalid basic auth")
|
||||
} else if username == "" {
|
||||
return s.authenticateBearerAuth(r, password) // Treat password as token
|
||||
}
|
||||
return s.userManager.Authenticate(username, password)
|
||||
}
|
||||
|
||||
func (s *Server) authenticateBearerAuth(r *http.Request, token string) (*user.User, error) {
|
||||
u, err := s.userManager.AuthenticateToken(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes)
|
||||
go s.userManager.EnqueueTokenUpdate(token, &user.TokenUpdate{
|
||||
LastAccess: time.Now(),
|
||||
LastOrigin: ip,
|
||||
})
|
||||
return u, nil
|
||||
}
|
||||
@@ -54,8 +54,8 @@ func (s *Server) execManager() {
|
||||
receivedMailTotal, receivedMailSuccess, receivedMailFailure = s.smtpServerBackend.Counts()
|
||||
}
|
||||
var sentMailTotal, sentMailSuccess, sentMailFailure int64
|
||||
if s.smtpSender != nil {
|
||||
sentMailTotal, sentMailSuccess, sentMailFailure = s.smtpSender.Counts()
|
||||
if s.mailer != nil {
|
||||
sentMailTotal, sentMailSuccess, sentMailFailure = s.mailer.NotificationCounts()
|
||||
}
|
||||
|
||||
// Users
|
||||
|
||||
@@ -165,8 +165,5 @@ func writeMatrixResponse(w http.ResponseWriter, rejectedPushKey string) error {
|
||||
Rejected: rejected,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return util.EncodeJSON(w, response)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
@@ -105,7 +106,7 @@ func (s *Server) ensureCallsEnabled(next handleFunc) handleFunc {
|
||||
|
||||
func (s *Server) ensureEmailsEnabled(next handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if s.mailSender == nil || s.userManager == nil {
|
||||
if s.mailer == nil || s.userManager == nil {
|
||||
return errHTTPNotFound
|
||||
}
|
||||
return next(w, r, v)
|
||||
@@ -139,3 +140,31 @@ func (s *Server) withAccountSync(next handleFunc) handleFunc {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) authorizeTopicWrite(next handleFunc) handleFunc {
|
||||
return s.authorizeTopic(next, user.PermissionWrite)
|
||||
}
|
||||
|
||||
func (s *Server) authorizeTopicRead(next handleFunc) handleFunc {
|
||||
return s.authorizeTopic(next, user.PermissionRead)
|
||||
}
|
||||
|
||||
func (s *Server) authorizeTopic(next handleFunc, perm user.Permission) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if s.userManager == nil {
|
||||
return next(w, r, v)
|
||||
}
|
||||
topics, _, err := s.topicsFromPath(v, r.URL.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u := v.User()
|
||||
for _, t := range topics {
|
||||
if err := s.userManager.Authorize(u, t.ID, perm); err != nil {
|
||||
logvr(v, r).With(t).Err(err).Debug("Access to topic %s not authorized", t.ID)
|
||||
return errHTTPForbidden.With(t)
|
||||
}
|
||||
}
|
||||
return next(w, r, v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,10 +237,41 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
|
||||
if err := s.updateSubscriptionAndTier(r, v, u, tier, sess.Customer.ID, sub.ID, string(sub.Status), string(interval), sub.CurrentPeriodEnd, sub.CancelAt); err != nil {
|
||||
return err
|
||||
}
|
||||
// Offer email recovery: auto-send a verification link to the billing email (best-effort).
|
||||
// Provisioned users can't reset their password, so recovery setup doesn't apply to them.
|
||||
if sess.CustomerDetails != nil && !u.Provisioned {
|
||||
s.maybeEnqueueBillingEmailVerification(r, v, u.ID, sess.CustomerDetails.Email)
|
||||
}
|
||||
http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
|
||||
// maybeEnqueueBillingEmailVerification sends an email-verification link to a paying user's
|
||||
// billing email, so they can use it for password recovery -- but only if they have no verified
|
||||
// email yet and the billing email is not already the recovery email on another account. On a
|
||||
// collision (or any other skip), the generic "no recovery email set" warning on the account page
|
||||
// nudges the user to add one. This is best-effort: failures are logged, never surfaced.
|
||||
func (s *Server) maybeEnqueueBillingEmailVerification(r *http.Request, v *visitor, userID, billingEmail string) {
|
||||
if s.mailer == nil || s.config.BaseURL == "" || billingEmail == "" || !emailAddressRegex.MatchString(billingEmail) {
|
||||
return
|
||||
}
|
||||
emails, err := s.userManager.Emails(userID)
|
||||
if err != nil {
|
||||
logvr(v, r).Tag(tagStripe).Err(err).Warn("Failed to load emails for billing verification")
|
||||
return
|
||||
} else if len(emails) > 0 {
|
||||
return // User already has a verified email -- don't nag
|
||||
}
|
||||
if _, err := s.userManager.UserIDByPrimaryEmail(billingEmail); err == nil {
|
||||
logvr(v, r).Tag(tagStripe).Debug("Billing email is primary on another account, skipping auto-verification")
|
||||
return // Collision: skip + let the generic no-recovery-email warning nudge instead
|
||||
}
|
||||
logvr(v, r).Tag(tagStripe).Field("email", billingEmail).Info("Sending verification link to billing email")
|
||||
if err := s.enqueueEmailVerification(userID, billingEmail); err != nil {
|
||||
logvr(v, r).Tag(tagStripe).Err(err).Warn("Failed to enqueue billing email verification")
|
||||
}
|
||||
}
|
||||
|
||||
// handleAccountBillingSubscriptionUpdate updates an existing Stripe subscription to a new price, and updates
|
||||
// a user's tier accordingly. This endpoint only works if there is an existing subscription.
|
||||
func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//go:build !nopayments
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stripe/stripe-go/v74"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
)
|
||||
|
||||
// stripeCheckoutMock wires up a testStripeAPI for a successful checkout of user u, with the given
|
||||
// billing email on the session's CustomerDetails.
|
||||
func stripeCheckoutMock(u *user.User, billingEmail string) *testStripeAPI {
|
||||
m := &testStripeAPI{}
|
||||
m.On("GetSession", "SOMETOKEN").Return(&stripe.CheckoutSession{
|
||||
ClientReferenceID: u.ID,
|
||||
Customer: &stripe.Customer{ID: "acct_5555"},
|
||||
Subscription: &stripe.Subscription{ID: "sub_1234"},
|
||||
CustomerDetails: &stripe.CheckoutSessionCustomerDetails{Email: billingEmail},
|
||||
}, nil)
|
||||
m.On("GetSubscription", "sub_1234").Return(&stripe.Subscription{
|
||||
ID: "sub_1234",
|
||||
Status: stripe.SubscriptionStatusActive,
|
||||
CurrentPeriodEnd: 123456789,
|
||||
Items: &stripe.SubscriptionItemList{
|
||||
Data: []*stripe.SubscriptionItem{
|
||||
{Price: &stripe.Price{ID: "price_1234", Recurring: &stripe.PriceRecurring{Interval: stripe.PriceRecurringIntervalMonth}}},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
m.On("UpdateCustomer", "acct_5555", mock.Anything).Return(&stripe.Customer{}, nil)
|
||||
return m
|
||||
}
|
||||
|
||||
func newCheckoutEmailTestServer(t *testing.T, databaseURL string) (*Server, *captureMailer, *user.User) {
|
||||
c := newTestConfigWithAuthFile(t, databaseURL)
|
||||
c.StripeSecretKey = "secret key"
|
||||
c.BaseURL = "https://ntfy.example.com"
|
||||
c.SMTPSenderAddr = "localhost:25"
|
||||
c.SMTPSenderFrom = "noreply@example.com"
|
||||
s := newTestServer(t, c)
|
||||
mailer := newCaptureMailer()
|
||||
s.mailer = mailer
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
ID: "ti_123", Code: "starter", StripeMonthlyPriceID: "price_1234", MessageLimit: 100, MessageExpiryDuration: time.Hour,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
return s, mailer, u
|
||||
}
|
||||
|
||||
func TestPayments_Checkout_SendsBillingEmailVerification(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, u := newCheckoutEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
s.stripe = stripeCheckoutMock(u, "billing@example.com")
|
||||
|
||||
rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil)
|
||||
require.Equal(t, 303, rr.Code)
|
||||
|
||||
// A verification link was auto-sent to the billing email; clicking it verifies + sets primary
|
||||
link := mailer.verifyLinks["billing@example.com"]
|
||||
require.NotEmpty(t, link)
|
||||
token := tokenFromLink(t, link, "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code)
|
||||
|
||||
emails, err := s.userManager.Emails(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, []string{"billing@example.com"}, emails.Strings())
|
||||
primary, err := s.userManager.PrimaryEmail(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "billing@example.com", primary)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPayments_Checkout_SkipsBillingEmailWhenAlreadyVerified(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, u := newCheckoutEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
s.stripe = stripeCheckoutMock(u, "billing@example.com")
|
||||
|
||||
// User already has a verified email -> no auto-send on checkout
|
||||
require.Nil(t, s.userManager.AddEmail(u.ID, "existing@example.com"))
|
||||
|
||||
rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil)
|
||||
require.Equal(t, 303, rr.Code)
|
||||
require.Empty(t, mailer.verifyLinks)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPayments_Checkout_SkipsBillingEmailWhenPrimaryElsewhere(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, u := newCheckoutEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
s.stripe = stripeCheckoutMock(u, "billing@example.com")
|
||||
|
||||
// The billing email is already the recovery email on another account -> skip
|
||||
require.Nil(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false))
|
||||
alice, err := s.userManager.User("alice")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.AddEmail(alice.ID, "billing@example.com"))
|
||||
require.Nil(t, s.userManager.SetPrimaryEmail(alice.ID, "billing@example.com"))
|
||||
|
||||
rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil)
|
||||
require.Equal(t, 303, rr.Code)
|
||||
require.Empty(t, mailer.verifyLinks)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template/parse"
|
||||
|
||||
"gopkg.in/yaml.v2"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/template/gotext"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"heckel.io/ntfy/v2/util/sprig"
|
||||
)
|
||||
|
||||
func (s *Server) handleBodyAsTemplatedTextMessage(ctx context.Context, m *model.Message, template templateMode, body *util.PeekedReadCloser, priorityStr string) error {
|
||||
body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
|
||||
if err != nil {
|
||||
return err
|
||||
} else if body.LimitReached {
|
||||
return errHTTPEntityTooLargeJSONBody
|
||||
}
|
||||
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
|
||||
if template.FileMode() {
|
||||
if err := s.renderTemplateFromFile(ctx, m, template.FileName(), peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := s.renderTemplateFromParams(ctx, m, peekedBody, priorityStr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(m.Title) > s.config.MessageSizeLimit || len(m.Message) > s.config.MessageSizeLimit {
|
||||
return errHTTPBadRequestTemplateMessageTooLarge
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderTemplateFromFile transforms the JSON message body according to a template from the filesystem.
|
||||
// The template file must be in the templates directory, or in the configured template directory.
|
||||
func (s *Server) renderTemplateFromFile(ctx context.Context, m *model.Message, templateName, peekedBody string) error {
|
||||
if !templateNameRegex.MatchString(templateName) {
|
||||
return errHTTPBadRequestTemplateFileNotFound
|
||||
}
|
||||
templateContent, _ := templatesFs.ReadFile(filepath.Join(templatesDir, templateName+templateFileExtension)) // Read from the embedded filesystem first
|
||||
if s.config.TemplateDir != "" {
|
||||
if b, _ := os.ReadFile(filepath.Join(s.config.TemplateDir, templateName+templateFileExtension)); len(b) > 0 {
|
||||
templateContent = b
|
||||
}
|
||||
}
|
||||
if len(templateContent) == 0 {
|
||||
return errHTTPBadRequestTemplateFileNotFound
|
||||
}
|
||||
var tpl templateFile
|
||||
if err := yaml.Unmarshal(templateContent, &tpl); err != nil {
|
||||
return errHTTPBadRequestTemplateFileInvalid
|
||||
}
|
||||
var err error
|
||||
if tpl.Message != nil {
|
||||
if m.Message, err = s.renderTemplate(ctx, templateName+" (message)", *tpl.Message, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tpl.Title != nil {
|
||||
if m.Title, err = s.renderTemplate(ctx, templateName+" (title)", *tpl.Title, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tpl.Priority != nil {
|
||||
renderedPriority, err := s.renderTemplate(ctx, templateName+" (priority)", *tpl.Priority, peekedBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Priority, err = util.ParsePriority(renderedPriority); err != nil {
|
||||
return errHTTPBadRequestPriorityInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderTemplateFromParams transforms the JSON message body according to the inline template in the
|
||||
// message, title, and priority parameters.
|
||||
func (s *Server) renderTemplateFromParams(ctx context.Context, m *model.Message, peekedBody string, priorityStr string) error {
|
||||
var err error
|
||||
if m.Message, err = s.renderTemplate(ctx, "priority query parameter", m.Message, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Title, err = s.renderTemplate(ctx, "title query parameter", m.Title, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
if priorityStr != "" {
|
||||
renderedPriority, err := s.renderTemplate(ctx, "priority query parameter", priorityStr, peekedBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Priority, err = util.ParsePriority(renderedPriority); err != nil {
|
||||
return errHTTPBadRequestPriorityInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderTemplate renders a template with the given JSON source data.
|
||||
func (s *Server) renderTemplate(ctx context.Context, name, tpl, source string) (string, error) {
|
||||
var data any
|
||||
if err := json.Unmarshal([]byte(source), &data); err != nil {
|
||||
return "", errHTTPBadRequestTemplateMessageNotJSON
|
||||
}
|
||||
t, err := gotext.New("").Funcs(sprig.TxtFuncMap()).Parse(tpl)
|
||||
if err != nil {
|
||||
return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error())
|
||||
}
|
||||
if templateUsesDisallowedFeatures(t) {
|
||||
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
|
||||
}
|
||||
// Bail out of runaway templates (GHSA-rhwf-xgc9-m9fp). The deadline starts here, after the body
|
||||
// has already been read, so a slow upload is not counted against it. Deriving from the request
|
||||
// context means a client disconnect aborts the render too.
|
||||
execCtx, cancel := context.WithTimeout(ctx, templateMaxExecutionTime)
|
||||
defer cancel()
|
||||
var buf bytes.Buffer
|
||||
limitWriter := util.NewLimitWriter(&buf, util.NewFixedLimiter(templateMaxOutputBytes))
|
||||
if err := t.ExecuteContext(execCtx, limitWriter, data); err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "", errHTTPBadRequestTemplateExecutionTimeout
|
||||
}
|
||||
return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("template %s: %s", name, err.Error())
|
||||
}
|
||||
return strings.TrimSpace(strings.ReplaceAll(buf.String(), "\\n", "\n")), nil // replace any remaining "\n" (those outside of template curly braces) with newlines
|
||||
}
|
||||
|
||||
// templateUsesDisallowedFeatures reports whether the parsed template defines or invokes a
|
||||
// sub-template ({{define}}/{{block}}/{{template}}) or uses the {{call}} builtin. None are useful for
|
||||
// ntfy's JSON-data templates. Checking the parse tree (rather than the raw string) catches every
|
||||
// syntactic form -- e.g. {{if call .x}} or {{$y := call .x}} -- that a regex would miss.
|
||||
func templateUsesDisallowedFeatures(t *gotext.Template) bool {
|
||||
if len(t.Templates()) > 1 { // {{define}}/{{block}} create additional associated templates
|
||||
return true
|
||||
}
|
||||
return treeContainsDisallowedNode(t.Root)
|
||||
}
|
||||
|
||||
// treeContainsDisallowedNode reports whether the parse tree contains a {{template}}/{{block}}
|
||||
// invocation or a {{call}} builtin, descending into pipes and command arguments (where {{call}} can
|
||||
// appear anywhere a function is allowed).
|
||||
func treeContainsDisallowedNode(node parse.Node) bool {
|
||||
switch n := node.(type) {
|
||||
case *parse.ListNode:
|
||||
if n == nil {
|
||||
return false
|
||||
}
|
||||
for _, child := range n.Nodes {
|
||||
if treeContainsDisallowedNode(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case *parse.ActionNode:
|
||||
return treeContainsDisallowedNode(n.Pipe)
|
||||
case *parse.RangeNode:
|
||||
return treeContainsDisallowedNode(n.Pipe) || treeContainsDisallowedNode(n.List) || treeContainsDisallowedNode(n.ElseList)
|
||||
case *parse.IfNode:
|
||||
return treeContainsDisallowedNode(n.Pipe) || treeContainsDisallowedNode(n.List) || treeContainsDisallowedNode(n.ElseList)
|
||||
case *parse.WithNode:
|
||||
return treeContainsDisallowedNode(n.Pipe) || treeContainsDisallowedNode(n.List) || treeContainsDisallowedNode(n.ElseList)
|
||||
case *parse.TemplateNode: // {{template}} or {{block}} invocation
|
||||
return true
|
||||
case *parse.PipeNode:
|
||||
if n == nil {
|
||||
return false
|
||||
}
|
||||
for _, cmd := range n.Cmds {
|
||||
if treeContainsDisallowedNode(cmd) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case *parse.CommandNode:
|
||||
for _, arg := range n.Args {
|
||||
if treeContainsDisallowedNode(arg) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case *parse.IdentifierNode: // a function name; {{call}} is the disallowed builtin
|
||||
return n.Ident == "call"
|
||||
}
|
||||
return false
|
||||
}
|
||||
+421
-27
@@ -264,6 +264,27 @@ func TestServer_StaticSites(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_WebApp_MagicLinkLandingPagesNoIndexHeaders(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
|
||||
// Magic-link landing pages carry a one-time token in the path, so the response must not
|
||||
// leak the token via the Referer header and must not be indexed
|
||||
for _, path := range []string{"/account/email/verify/sometoken", "/account/password/reset/sometoken"} {
|
||||
rr := request(t, s, "GET", path, "", nil)
|
||||
require.Equal(t, 200, rr.Code, path)
|
||||
require.Equal(t, "no-referrer", rr.Header().Get("Referrer-Policy"), path)
|
||||
require.Equal(t, "noindex", rr.Header().Get("X-Robots-Tag"), path)
|
||||
}
|
||||
|
||||
// Ordinary web app routes do not set these headers
|
||||
rr := request(t, s, "GET", "/", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Empty(t, rr.Header().Get("Referrer-Policy"))
|
||||
require.Empty(t, rr.Header().Get("X-Robots-Tag"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_WebEnabled(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
conf := newTestConfig(t, databaseURL)
|
||||
@@ -740,7 +761,7 @@ func TestServer_PublishMessageInHeaderWithNewlines(t *testing.T) {
|
||||
func TestServer_PublishInvalidTopic(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
response := request(t, s, "PUT", "/docs", "fail", nil)
|
||||
require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code)
|
||||
})
|
||||
@@ -1231,7 +1252,7 @@ func TestServer_StatsResetter_MessageLimiter_EmailsLimiter(t *testing.T) {
|
||||
|
||||
c := newTestConfigWithAuthFile(t, databaseURL)
|
||||
s := newTestServer(t, c)
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
|
||||
// Publish some messages, and check stats
|
||||
for i := 0; i < 3; i++ {
|
||||
@@ -1315,18 +1336,20 @@ func TestServer_DailyMessageQuotaFromDatabase(t *testing.T) {
|
||||
}
|
||||
|
||||
type testMailer struct {
|
||||
count int
|
||||
mu sync.Mutex
|
||||
count int
|
||||
lastTo string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (t *testMailer) Send(v *visitor, m *model.Message, to string) error {
|
||||
func (t *testMailer) SendNotification(to string, m *model.Message, senderIP string) error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.count++
|
||||
t.lastTo = to
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *testMailer) Counts() (total int64, success int64, failure int64) {
|
||||
func (t *testMailer) NotificationCounts() (total int64, success int64, failure int64) {
|
||||
return 0, 0, 0
|
||||
}
|
||||
|
||||
@@ -1336,6 +1359,16 @@ func (t *testMailer) Count() int {
|
||||
return t.count
|
||||
}
|
||||
|
||||
func (t *testMailer) LastTo() string {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.lastTo
|
||||
}
|
||||
|
||||
func (t *testMailer) SendEmailVerification(to, link string) error { return nil }
|
||||
|
||||
func (t *testMailer) SendPasswordReset(to, link string) error { return nil }
|
||||
|
||||
func TestServer_PublishTooManyRequests_Defaults(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
@@ -1461,7 +1494,7 @@ func TestServer_PublishTooManyRequests_ShortReplenish(t *testing.T) {
|
||||
func TestServer_PublishTooManyEmails_Defaults(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
for i := 0; i < 16; i++ {
|
||||
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
|
||||
"E-Mail": "test@example.com",
|
||||
@@ -1481,7 +1514,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
|
||||
c := newTestConfig(t, databaseURL)
|
||||
c.VisitorEmailLimitReplenish = 500 * time.Millisecond
|
||||
s := newTestServer(t, c)
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
for i := 0; i < 16; i++ {
|
||||
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
|
||||
"E-Mail": "test@example.com",
|
||||
@@ -1509,7 +1542,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
|
||||
func TestServer_PublishDelayedEmail_Fail(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{
|
||||
"E-Mail": "test@example.com",
|
||||
"Delay": "20 min",
|
||||
@@ -1546,7 +1579,7 @@ func TestServer_PublishEmailNoMailer_Fail(t *testing.T) {
|
||||
func TestServer_PublishEmailAddressInvalid(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
addresses := []string{
|
||||
"test@example.com, other@example.com",
|
||||
"invalidaddress",
|
||||
@@ -1572,7 +1605,7 @@ func TestServer_PublishEmailVerify_VerifiedAddress(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderVerify = true
|
||||
s := newTestServer(t, conf)
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
@@ -1602,7 +1635,7 @@ func TestServer_PublishEmailVerify_BoolValue(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderVerify = true
|
||||
s := newTestServer(t, conf)
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
@@ -1628,17 +1661,97 @@ func TestServer_PublishEmailVerify_BoolValue(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_PublishEmailVerify_BoolValue_NoVerify(t *testing.T) {
|
||||
func TestServer_PublishEmailVerify_BoolValueUsesPrimary(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderVerify = true
|
||||
s := newTestServer(t, conf)
|
||||
mailer := &testMailer{}
|
||||
s.mailer = mailer
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
// Two verified emails; the primary is NOT the alphabetically-first one
|
||||
require.Nil(t, s.userManager.AddEmail(u.ID, "aaa@example.com"))
|
||||
require.Nil(t, s.userManager.AddEmail(u.ID, "zzz@example.com"))
|
||||
require.Nil(t, s.userManager.SetPrimaryEmail(u.ID, "zzz@example.com"))
|
||||
|
||||
// "yes" must resolve to the primary email, not emails[0] (alphabetically first)
|
||||
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
|
||||
"Email": "yes",
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, "zzz@example.com", mailer.LastTo())
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_PublishEmailVerify_BoolValueNoVerifyUsesPrimary(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
// smtp-sender-verify intentionally left false (the default)
|
||||
s := newTestServer(t, conf)
|
||||
mailer := &testMailer{}
|
||||
s.mailer = mailer
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.AddEmail(u.ID, "aaa@example.com"))
|
||||
require.Nil(t, s.userManager.AddEmail(u.ID, "zzz@example.com"))
|
||||
require.Nil(t, s.userManager.SetPrimaryEmail(u.ID, "zzz@example.com"))
|
||||
|
||||
// Even with smtp-sender-verify off, "yes" resolves to the user's primary verified address
|
||||
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
|
||||
"Email": "yes",
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, "zzz@example.com", mailer.LastTo())
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_PublishEmailVerify_BoolValueAnonymousRejected(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
|
||||
// "yes" without smtp-sender-verify should fail with invalid address
|
||||
// "yes" requires an authenticated user (it means "my primary"); anonymous is rejected
|
||||
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
|
||||
"Email": "yes",
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40050, toHTTPError(t, response.Body.String()).Code)
|
||||
require.Equal(t, 40053, toHTTPError(t, response.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_PublishEmailVerify_BoolValueProvisionedUsesPrimary(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
hash, err := user.HashPassword("provpass", user.DefaultUserPasswordBcryptCost)
|
||||
require.Nil(t, err)
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.AuthUsers = []*user.User{{Name: "prov", Hash: hash, Role: user.RoleUser}}
|
||||
s := newTestServer(t, conf)
|
||||
mailer := &testMailer{}
|
||||
s.mailer = mailer
|
||||
defer s.closeDatabases()
|
||||
|
||||
prov, err := s.userManager.User("prov")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.AddEmail(prov.ID, "aaa@example.com"))
|
||||
require.Nil(t, s.userManager.AddEmail(prov.ID, "zzz@example.com"))
|
||||
require.Nil(t, s.userManager.SetPrimaryEmail(prov.ID, "zzz@example.com"))
|
||||
|
||||
// A provisioned user's "yes" resolves to their chosen primary, not the alphabetically-first
|
||||
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
|
||||
"Email": "yes",
|
||||
"Authorization": util.BasicAuth("prov", "provpass"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, "zzz@example.com", mailer.LastTo())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1647,7 +1760,7 @@ func TestServer_PublishEmailVerify_Anonymous(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderVerify = true
|
||||
s := newTestServer(t, conf)
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Anonymous user should be rejected
|
||||
@@ -1664,7 +1777,7 @@ func TestServer_PublishEmailVerify_NoVerifiedEmails(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderVerify = true
|
||||
s := newTestServer(t, conf)
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
@@ -1682,7 +1795,7 @@ func TestServer_PublishEmailVerify_NoVerifiedEmails(t *testing.T) {
|
||||
func TestServer_PublishEmailVerify_Disabled_Backwards_Compatible(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
|
||||
// Without smtp-sender-verify, any email address should work (backwards compatible)
|
||||
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
|
||||
@@ -1706,11 +1819,11 @@ func TestServer_AccountEmailVerify_UserWithoutTier(t *testing.T) {
|
||||
// Create a user without a tier
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
|
||||
// Verify email request should NOT return 401
|
||||
response := request(t, s, "PUT", "/v1/account/email/verify", `{"email":"ben@example.com"}`, map[string]string{
|
||||
// Starting email verification should NOT return 401
|
||||
response := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
// The request will fail (SMTP not available), but it must NOT be a 401
|
||||
// The request may fail (SMTP not available), but it must NOT be a 401
|
||||
require.NotEqual(t, 401, response.Code)
|
||||
})
|
||||
}
|
||||
@@ -1731,7 +1844,7 @@ func TestServer_AccountEmailVerify_UserWithoutTier_EmailLimitZero(t *testing.T)
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
|
||||
// Should be rejected with 401 since email sending is disabled
|
||||
response := request(t, s, "PUT", "/v1/account/email/verify", `{"email":"ben@example.com"}`, map[string]string{
|
||||
response := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 401, response.Code)
|
||||
@@ -2139,7 +2252,7 @@ func TestServer_PublishAsJSON_WithEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
mailer := &testMailer{}
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
s.smtpSender = mailer
|
||||
s.mailer = mailer
|
||||
body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}`
|
||||
response := request(t, s, "PUT", "/", body, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
@@ -3522,6 +3635,184 @@ func TestServer_MessageTemplate_Range(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_ExecutionTimeout(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
// Nested range over a 1000-element JSON field with a no-output body: no Write ever happens,
|
||||
// so the write-triggered TimeoutWriter never fires. Must be bounded by the executor's
|
||||
// wall-clock deadline instead (GHSA-rhwf-xgc9-m9fp).
|
||||
elems := make([]string, 1000)
|
||||
for i := range elems {
|
||||
elems[i] = "0"
|
||||
}
|
||||
jsonBody := `{"a":[` + strings.Join(elems, ",") + `]}`
|
||||
msg := `{{range .a}}{{range $.a}}` + strings.Repeat(`{{$x := .}}`, 100) + `{{end}}{{end}}done`
|
||||
start := time.Now()
|
||||
response := request(t, s, "POST", "/mytopic", jsonBody, map[string]string{
|
||||
"X-Message": msg,
|
||||
"X-Template": "1",
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40055, toHTTPError(t, response.Body.String()).Code)
|
||||
require.Less(t, elapsed, 500*time.Millisecond, "template must be interrupted by the deadline, not run to completion (took %s)", elapsed)
|
||||
})
|
||||
}
|
||||
|
||||
// TestServer_MessageTemplate_DataDrivenNestedRange_TimesOut is the regression for the exact hole the
|
||||
// old write-triggered TimeoutWriter missed: a nested {{range}} over a JSON array field with a
|
||||
// no-output body calls no function, so only the executor's wall-clock deadline can stop it
|
||||
// (GHSA-rhwf-xgc9-m9fp).
|
||||
func TestServer_MessageTemplate_DataDrivenNestedRange_TimesOut(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
elems := make([]string, 1000)
|
||||
for i := range elems {
|
||||
elems[i] = "0"
|
||||
}
|
||||
jsonBody := `{"a":[` + strings.Join(elems, ",") + `]}`
|
||||
msg := `{{range .a}}{{range $.a}}{{range $.a}}{{$x := .}}{{end}}{{end}}{{end}}done`
|
||||
start := time.Now()
|
||||
response := request(t, s, "POST", "/mytopic", jsonBody, map[string]string{
|
||||
"X-Message": msg,
|
||||
"X-Template": "1",
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40055, toHTTPError(t, response.Body.String()).Code)
|
||||
require.Less(t, elapsed, 500*time.Millisecond, "data-driven nested range should be cut off by the deadline (took %s)", elapsed)
|
||||
})
|
||||
}
|
||||
|
||||
// TestServer_MessageTemplate_ExpensiveFunctionLoop_TimesOut ensures the deadline also bounds loops
|
||||
// whose body calls an expensive function (hashing a large string), where a single call between
|
||||
// deadline checks could otherwise overshoot (GHSA-rhwf-xgc9-m9fp).
|
||||
func TestServer_MessageTemplate_ExpensiveFunctionLoop_TimesOut(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
msg := `{{$big := repeat 990 "0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789"}}{{range until 1000}}{{range until 1000}}{{$h := sha512sum $big}}{{end}}{{end}}`
|
||||
start := time.Now()
|
||||
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
|
||||
"X-Message": msg,
|
||||
"X-Template": "1",
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40055, toHTTPError(t, response.Body.String()).Code)
|
||||
require.Less(t, elapsed, 1500*time.Millisecond, "expensive-function loop should be cut off by the deadline (took %s)", elapsed)
|
||||
})
|
||||
}
|
||||
|
||||
// TestServer_MessageTemplate_NestedLoopPoC_TimesOut is the exact proof-of-concept from the advisory:
|
||||
// a range over a runtime-computed slice, nested, must be bounded by the deadline (GHSA-rhwf-xgc9-m9fp).
|
||||
func TestServer_MessageTemplate_NestedLoopPoC_TimesOut(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
start := time.Now()
|
||||
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
|
||||
"X-Message": `{{$x := until 10000}}{{range $x}}{{range $x}}{{end}}{{end}}done`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40055, toHTTPError(t, response.Body.String()).Code)
|
||||
require.Less(t, elapsed, 500*time.Millisecond, "advisory PoC should be cut off by the deadline (took %s)", elapsed)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_GenuineError_NotTimeout(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
// A real runtime error (len of an int) must map to execute-failed, not the timeout code.
|
||||
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
|
||||
"X-Message": `{{ len 5 }}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
|
||||
// slowBody delivers its data after a delay, simulating a slow client upload of the request body.
|
||||
type slowBody struct {
|
||||
data []byte
|
||||
delay time.Duration
|
||||
done bool
|
||||
}
|
||||
|
||||
func (b *slowBody) Read(p []byte) (int, error) {
|
||||
if b.done {
|
||||
return 0, io.EOF
|
||||
}
|
||||
time.Sleep(b.delay)
|
||||
n := copy(p, b.data)
|
||||
b.done = true
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (b *slowBody) Close() error { return nil }
|
||||
|
||||
// TestServer_MessageTemplate_SlowUpload_NotCountedAgainstDeadline verifies that a slow request-body
|
||||
// upload does not consume the template execution deadline: the body is fully read (util.Peek)
|
||||
// before the deadline starts, so a trivial template still renders even when the upload alone took
|
||||
// longer than the deadline (GHSA-rhwf-xgc9-m9fp).
|
||||
func TestServer_MessageTemplate_SlowUpload_NotCountedAgainstDeadline(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t, ""))
|
||||
start := time.Now()
|
||||
// The template runs in ~1ms, far under the deadline, so on correct code it renders fine; the
|
||||
// point is that the deadline starts at execution, not when the (slow) upload began.
|
||||
response := request(t, s, "POST", "/mytopic", `{"foo":"bar"}`, map[string]string{
|
||||
"Template": "yes",
|
||||
"X-Message": `{{range until 5000}}{{$x := .}}{{end}}hello {{.foo}}`,
|
||||
}, func(r *http.Request) {
|
||||
r.Body = &slowBody{data: []byte(`{"foo":"bar"}`), delay: 3 * templateMaxExecutionTime}
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
require.Greater(t, elapsed, templateMaxExecutionTime, "the slow upload must outlast the exec deadline for this test to be meaningful")
|
||||
require.Equal(t, 200, response.Code) // Would be 40055 if upload time counted against the deadline
|
||||
m := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "hello bar", m.Message)
|
||||
}
|
||||
|
||||
// TestServer_MessageTemplate_ClientDisconnect_CancelsRender verifies that canceling the request
|
||||
// context (e.g. the client disconnecting) aborts an in-progress template render. The execution
|
||||
// deadline is raised well above the cancel delay for this test so that cancellation -- not the
|
||||
// deadline -- is what stops the render: a runaway template is canceled 500ms in and must abort
|
||||
// shortly after (well under the raised deadline), yielding the generic execute-failed code (40045),
|
||||
// not the timeout code (40055).
|
||||
//
|
||||
// Not parallel: it temporarily raises the package-global templateMaxExecutionTime. Non-parallel
|
||||
// tests run in their own phase (parallel tests are paused), so the override is race-free.
|
||||
func TestServer_MessageTemplate_ClientDisconnect_CancelsRender(t *testing.T) {
|
||||
origDeadline := templateMaxExecutionTime
|
||||
templateMaxExecutionTime = 30 * time.Second // large enough that only the cancel can stop the render
|
||||
defer func() { templateMaxExecutionTime = origDeadline }()
|
||||
|
||||
s := newTestServer(t, newTestConfig(t, ""))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
start := time.Now()
|
||||
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
|
||||
"X-Message": `{{$x := until 10000}}{{range $x}}{{range $x}}{{end}}{{end}}done`,
|
||||
"X-Template": "1",
|
||||
}, func(r *http.Request) {
|
||||
*r = *r.WithContext(ctx)
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code, "a canceled render should map to execute-failed, not the timeout code 40055")
|
||||
require.Greater(t, elapsed, 500*time.Millisecond, "render must still be running when the cancel fires (took %s)", elapsed)
|
||||
require.Less(t, elapsed, 700*time.Millisecond, "request-context cancel should abort the render promptly after firing (took %s)", elapsed)
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
@@ -3616,11 +3907,18 @@ func TestServer_MessageTemplate_DisallowedCalls(t *testing.T) {
|
||||
`{{- template ""}}`,
|
||||
`{{-
|
||||
template ""}}`,
|
||||
`{{ call abc}}`,
|
||||
`{{ define "aa"}}`,
|
||||
`We cannot {{define "aa"}}`,
|
||||
`{{ call "aa"}}`,
|
||||
`{{define "aa"}}hi{{end}}`,
|
||||
`We cannot {{define "aa"}}hi{{end}}`,
|
||||
`We cannot {{ call "aa"}}`,
|
||||
`We cannot {{- template "aa"}}`,
|
||||
`{{block "aa" .}}hi{{end}}`,
|
||||
`We cannot {{- block "aa" .}}hi{{end}}`,
|
||||
// call is a function, not a keyword, so it can hide in non-leading positions that a
|
||||
// raw-string regex misses -- the parse-tree walk catches all of them.
|
||||
`{{if call .x}}x{{end}}`,
|
||||
`{{$y := call .x}}`,
|
||||
`{{index (call .x) 0}}`,
|
||||
}
|
||||
for _, disallowedTemplate := range disallowedTemplates {
|
||||
messageTemplate := disallowedTemplate
|
||||
@@ -4026,6 +4324,40 @@ func TestServer_DeleteMessage(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_DeleteMessage_GET(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
|
||||
// Publish a message with a sequence ID
|
||||
response := request(t, s, "PUT", "/mytopic/seq123", "original message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "seq123", msg.SequenceID)
|
||||
require.Equal(t, "message", msg.Event)
|
||||
|
||||
// Delete the message using GET method (/topic/seq/delete)
|
||||
response = request(t, s, "GET", "/mytopic/seq123/delete", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
deleteMsg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "seq123", deleteMsg.SequenceID)
|
||||
require.Equal(t, "message_delete", deleteMsg.Event)
|
||||
|
||||
// Poll and verify both messages are returned
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n")
|
||||
require.Equal(t, 2, len(lines))
|
||||
|
||||
msg1 := toMessage(t, lines[0])
|
||||
msg2 := toMessage(t, lines[1])
|
||||
require.Equal(t, "message", msg1.Event)
|
||||
require.Equal(t, "message_delete", msg2.Event)
|
||||
require.Equal(t, "seq123", msg1.SequenceID)
|
||||
require.Equal(t, "seq123", msg2.SequenceID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_ClearMessage(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
@@ -4079,6 +4411,33 @@ func TestServer_ClearMessage_ReadEndpoint(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_ClearMessage_GET(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
|
||||
// 1. Test GET /topic/seq-id/clear
|
||||
response := request(t, s, "PUT", "/mytopic/seq456", "original message 1", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
response = request(t, s, "GET", "/mytopic/seq456/clear", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
clearMsg1 := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "seq456", clearMsg1.SequenceID)
|
||||
require.Equal(t, "message_clear", clearMsg1.Event)
|
||||
|
||||
// 2. Test GET /topic/seq-id/read
|
||||
response = request(t, s, "PUT", "/mytopic/seq789", "original message 2", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
response = request(t, s, "GET", "/mytopic/seq789/read", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
clearMsg2 := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "seq789", clearMsg2.SequenceID)
|
||||
require.Equal(t, "message_clear", clearMsg2.Event)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_UpdateMessage(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
@@ -4286,6 +4645,41 @@ func TestServer_DeleteScheduledMessage(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_DeleteScheduledMessage_GET(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
|
||||
// Publish a scheduled message (future delivery)
|
||||
response := request(t, s, "PUT", "/mytopic/delete-sched-seq?delay=1h", "scheduled message to delete", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "delete-sched-seq", msg.SequenceID)
|
||||
|
||||
// Verify scheduled message exists
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
messages := toMessages(t, response.Body.String())
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "scheduled message to delete", messages[0].Message)
|
||||
|
||||
// Delete the scheduled message using GET method (/topic/seq/delete)
|
||||
response = request(t, s, "GET", "/mytopic/delete-sched-seq/delete", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
deleteMsg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "delete-sched-seq", deleteMsg.SequenceID)
|
||||
require.Equal(t, "message_delete", deleteMsg.Event)
|
||||
|
||||
// Verify scheduled message was deleted, only delete event remains
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
messages = toMessages(t, response.Body.String())
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "message_delete", messages[0].Event)
|
||||
require.Equal(t, "delete-sched-seq", messages[0].SequenceID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_UpdateScheduledMessage_TopicScoped(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
|
||||
+34
-24
@@ -16,6 +16,21 @@ import (
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
// twilioClient talks to the Twilio API to make phone calls (for the "Call" feature) and to verify
|
||||
// phone numbers. It holds the Twilio configuration and the user manager (used to look up a user's
|
||||
// verified phone numbers), so that this functionality is decoupled from the main Server.
|
||||
type twilioClient struct {
|
||||
config *Config
|
||||
userManager *user.Manager // May be nil!
|
||||
}
|
||||
|
||||
func newTwilioClient(conf *Config, userManager *user.Manager) *twilioClient {
|
||||
return &twilioClient{
|
||||
config: conf,
|
||||
userManager: userManager,
|
||||
}
|
||||
}
|
||||
|
||||
// defaultTwilioCallFormatTemplate is the default TwiML template used for Twilio calls.
|
||||
// It can be overridden in the server configuration's twilio-call-format field.
|
||||
//
|
||||
@@ -52,11 +67,11 @@ type twilioCallData struct {
|
||||
// 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) {
|
||||
func (c *twilioClient) convertPhoneNumber(u *user.User, phoneNumber string) (string, *errHTTP) {
|
||||
if u == nil {
|
||||
return "", errHTTPBadRequestAnonymousCallsNotAllowed
|
||||
}
|
||||
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
|
||||
phoneNumbers, err := c.userManager.PhoneNumbers(u.ID)
|
||||
if err != nil {
|
||||
return "", errHTTPInternalError
|
||||
} else if len(phoneNumbers) == 0 {
|
||||
@@ -67,24 +82,19 @@ func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, *
|
||||
} 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 *model.Message, to string) {
|
||||
func (c *twilioClient) callPhone(v *visitor, r *http.Request, m *model.Message, to string) {
|
||||
u, sender := v.User(), m.Sender.String()
|
||||
if u != nil {
|
||||
sender = u.Name
|
||||
}
|
||||
tmpl := defaultTwilioCallFormatTemplate
|
||||
if s.config.TwilioCallFormat != nil {
|
||||
tmpl = s.config.TwilioCallFormat
|
||||
if c.config.TwilioCallFormat != nil {
|
||||
tmpl = c.config.TwilioCallFormat
|
||||
}
|
||||
tags := make([]string, len(m.Tags))
|
||||
for i, tag := range m.Tags {
|
||||
@@ -106,11 +116,11 @@ func (s *Server) callPhone(v *visitor, r *http.Request, m *model.Message, to str
|
||||
}
|
||||
body := bodyBuf.String()
|
||||
data := url.Values{}
|
||||
data.Set("From", s.config.TwilioPhoneNumber)
|
||||
data.Set("From", c.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)
|
||||
response, err := c.callPhoneInternal(data)
|
||||
if err != nil {
|
||||
ev.Field("twilio_response", response).Err(err).Warn("Error sending Twilio request")
|
||||
minc(metricCallsMadeFailure)
|
||||
@@ -120,15 +130,15 @@ func (s *Server) callPhone(v *visitor, r *http.Request, m *model.Message, to str
|
||||
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)
|
||||
func (c *twilioClient) callPhoneInternal(data url.Values) (string, error) {
|
||||
requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/Calls.json", c.config.TwilioCallsBaseURL, c.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.BuildVersion)
|
||||
req.Header.Set("User-Agent", "ntfy/"+c.config.BuildVersion)
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
||||
req.Header.Set("Authorization", util.BasicAuth(c.config.TwilioAccount, c.config.TwilioAuthToken))
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -140,19 +150,19 @@ func (s *Server) callPhoneInternal(data url.Values) (string, error) {
|
||||
return string(response), nil
|
||||
}
|
||||
|
||||
func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, channel string) error {
|
||||
func (c *twilioClient) 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)
|
||||
requestURL := fmt.Sprintf("%s/v2/Services/%s/Verifications", c.config.TwilioVerifyBaseURL, c.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.BuildVersion)
|
||||
req.Header.Set("User-Agent", "ntfy/"+c.config.BuildVersion)
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
||||
req.Header.Set("Authorization", util.BasicAuth(c.config.TwilioAccount, c.config.TwilioAuthToken))
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -166,19 +176,19 @@ func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, cha
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber, code string) error {
|
||||
func (c *twilioClient) 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)
|
||||
requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", c.config.TwilioVerifyBaseURL, c.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.BuildVersion)
|
||||
req.Header.Set("User-Agent", "ntfy/"+c.config.BuildVersion)
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
||||
req.Header.Set("Authorization", util.BasicAuth(c.config.TwilioAccount, c.config.TwilioAuthToken))
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
// handleWebApp serves the embedded web app's index for client-side (SPA) routes that the
|
||||
// browser router resolves, so the app shell loads and the client-side router takes over.
|
||||
func (s *Server) handleWebApp(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
r.URL.Path = webAppIndex
|
||||
return s.handleStatic(w, r, v)
|
||||
}
|
||||
|
||||
// handleWebAppNoIndex serves the web app index for the magic-link landing pages, whose path
|
||||
// carries a one-time token. The response is marked no-referrer (so the token can't leak to third
|
||||
// parties via the Referer header) and noindex (so it never gets indexed).
|
||||
func (s *Server) handleWebAppNoIndex(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("X-Robots-Tag", "noindex")
|
||||
return s.handleWebApp(w, r, v)
|
||||
}
|
||||
|
||||
func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
return s.writeJSON(w, s.configResponse())
|
||||
}
|
||||
|
||||
func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
b, err := json.MarshalIndent(s.configResponse(), "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/javascript")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
_, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b)))
|
||||
return err
|
||||
}
|
||||
|
||||
// handleWebManifest serves the web app manifest for the progressive web app (PWA)
|
||||
func (s *Server) handleWebManifest(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
response := &webManifestResponse{
|
||||
Name: "ntfy",
|
||||
Description: "ntfy lets you send push notifications via scripts from any computer or phone",
|
||||
ShortName: "ntfy",
|
||||
Scope: "/",
|
||||
StartURL: s.config.WebRoot,
|
||||
Display: "standalone",
|
||||
BackgroundColor: "#ffffff",
|
||||
ThemeColor: "#317f6f",
|
||||
Icons: []*webManifestIcon{
|
||||
{SRC: "/static/images/pwa-192x192.png", Sizes: "192x192", Type: "image/png"},
|
||||
{SRC: "/static/images/pwa-512x512.png", Sizes: "512x512", Type: "image/png"},
|
||||
},
|
||||
}
|
||||
return s.writeJSONWithContentType(w, response, "application/manifest+json")
|
||||
}
|
||||
|
||||
// handleStatic returns all static resources (excluding the docs), including the web app
|
||||
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||
r.URL.Path = webSiteDir + r.URL.Path
|
||||
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDocs returns static resources related to the docs
|
||||
func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||
util.Gzip(http.FileServer(http.FS(docsStaticCached))).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) configResponse() *apiConfigResponse {
|
||||
return &apiConfigResponse{
|
||||
BaseURL: "", // Will translate to window.location.origin
|
||||
AppRoot: s.config.WebRoot,
|
||||
EnableLogin: s.config.EnableLogin,
|
||||
RequireLogin: s.config.RequireLogin,
|
||||
EnableSignup: s.config.EnableSignup,
|
||||
EnablePayments: s.config.StripeSecretKey != "",
|
||||
EnableCalls: s.config.TwilioAccount != "",
|
||||
EnableEmails: s.config.SMTPSenderFrom != "",
|
||||
EnableResetPassword: s.config.SMTPSenderFrom != "" && s.config.BaseURL != "", // Reset links need SMTP + an absolute base-url
|
||||
EnableReservations: s.config.EnableReservations,
|
||||
EnableWebPush: s.config.WebPushPublicKey != "",
|
||||
BillingContact: s.config.BillingContact,
|
||||
WebPushPublicKey: s.config.WebPushPublicKey,
|
||||
DisallowedTopics: s.config.DisallowedTopics,
|
||||
ConfigHash: s.config.Hash(),
|
||||
}
|
||||
}
|
||||
+48
-20
@@ -185,6 +185,7 @@ type apiAccessResetRequest struct {
|
||||
type apiAccountCreateRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"` // Optional; if set (and SMTP configured), a verification link is sent
|
||||
}
|
||||
|
||||
type apiAccountPasswordChangeRequest struct {
|
||||
@@ -226,13 +227,29 @@ type apiAccountPhoneNumberAddRequest struct {
|
||||
Code string `json:"code"` // Only set when adding a phone number
|
||||
}
|
||||
|
||||
type apiAccountEmailVerifyRequest struct {
|
||||
// apiAccountEmailRequest carries an email address for the add/delete/set-primary/resend
|
||||
// endpoints (all of which identify an email by address in the JSON body).
|
||||
type apiAccountEmailRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type apiAccountEmailAddRequest struct {
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
// apiAccountEmailVerifyRequest carries the raw magic-link token submitted (unauthenticated)
|
||||
// from the verification landing page.
|
||||
type apiAccountEmailVerifyRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// apiAccountPasswordResetRequest is the body of the (unauthenticated) reset-request endpoint.
|
||||
// The identifier is a username or a primary email address.
|
||||
type apiAccountPasswordResetRequest struct {
|
||||
Identifier string `json:"identifier"`
|
||||
}
|
||||
|
||||
// apiAccountPasswordResetConfirmRequest is the body of the (unauthenticated) reset-confirm
|
||||
// endpoint, submitted from the set-new-password landing page.
|
||||
type apiAccountPasswordResetConfirmRequest struct {
|
||||
Token string `json:"token"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type apiAccountTier struct {
|
||||
@@ -271,6 +288,15 @@ type apiAccountReservation struct {
|
||||
Everyone string `json:"everyone"`
|
||||
}
|
||||
|
||||
// apiAccountEmailInfo describes one email address on the account, as returned by GET /v1/account.
|
||||
// Verified addresses have pending=false; exactly one verified address may be primary (the
|
||||
// recovery email). Pending addresses are awaiting a magic-link click and are never primary.
|
||||
type apiAccountEmailInfo struct {
|
||||
Address string `json:"address"`
|
||||
Primary bool `json:"primary,omitempty"`
|
||||
Pending bool `json:"pending,omitempty"`
|
||||
}
|
||||
|
||||
type apiAccountBilling struct {
|
||||
Customer bool `json:"customer"`
|
||||
Subscription bool `json:"subscription"`
|
||||
@@ -286,12 +312,14 @@ type apiAccountResponse struct {
|
||||
SyncTopic string `json:"sync_topic,omitempty"`
|
||||
Provisioned bool `json:"provisioned,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
DateFormat string `json:"date_format,omitempty"`
|
||||
TimeFormat string `json:"time_format,omitempty"`
|
||||
Notification *user.NotificationPrefs `json:"notification,omitempty"`
|
||||
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
|
||||
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
|
||||
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
|
||||
PhoneNumbers []string `json:"phone_numbers,omitempty"`
|
||||
Emails []string `json:"emails,omitempty"`
|
||||
Emails []*apiAccountEmailInfo `json:"emails,omitempty"`
|
||||
Tier *apiAccountTier `json:"tier,omitempty"`
|
||||
Limits *apiAccountLimits `json:"limits,omitempty"`
|
||||
Stats *apiAccountStats `json:"stats,omitempty"`
|
||||
@@ -304,21 +332,21 @@ type apiAccountReservationRequest struct {
|
||||
}
|
||||
|
||||
type apiConfigResponse struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
AppRoot string `json:"app_root"`
|
||||
EnableLogin bool `json:"enable_login"`
|
||||
RequireLogin bool `json:"require_login"`
|
||||
EnableSignup bool `json:"enable_signup"`
|
||||
EnablePayments bool `json:"enable_payments"`
|
||||
EnableCalls bool `json:"enable_calls"`
|
||||
EnableEmails bool `json:"enable_emails"`
|
||||
EnableEmailVerify bool `json:"enable_email_verify"`
|
||||
EnableReservations bool `json:"enable_reservations"`
|
||||
EnableWebPush bool `json:"enable_web_push"`
|
||||
BillingContact string `json:"billing_contact"`
|
||||
WebPushPublicKey string `json:"web_push_public_key"`
|
||||
DisallowedTopics []string `json:"disallowed_topics"`
|
||||
ConfigHash string `json:"config_hash"`
|
||||
BaseURL string `json:"base_url"`
|
||||
AppRoot string `json:"app_root"`
|
||||
EnableLogin bool `json:"enable_login"`
|
||||
RequireLogin bool `json:"require_login"`
|
||||
EnableSignup bool `json:"enable_signup"`
|
||||
EnablePayments bool `json:"enable_payments"`
|
||||
EnableCalls bool `json:"enable_calls"`
|
||||
EnableEmails bool `json:"enable_emails"`
|
||||
EnableResetPassword bool `json:"enable_reset_password"`
|
||||
EnableReservations bool `json:"enable_reservations"`
|
||||
EnableWebPush bool `json:"enable_web_push"`
|
||||
BillingContact string `json:"billing_contact"`
|
||||
WebPushPublicKey string `json:"web_push_public_key"`
|
||||
DisallowedTopics []string `json:"disallowed_topics"`
|
||||
ConfigHash string `json:"config_hash"`
|
||||
}
|
||||
|
||||
type apiAccountBillingPrices struct {
|
||||
|
||||
+7
-5
@@ -66,7 +66,7 @@ type visitor struct {
|
||||
subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections)
|
||||
topicCreationLimiter *rate.Limiter // Rate limiter for inserting new topics into the in-memory topic map
|
||||
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 actions (signup, password-reset requests), may be nil
|
||||
authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil
|
||||
firebase time.Time // Next allowed Firebase message
|
||||
seen time.Time // Last seen time of this visitor (needed for removal of stale visitors)
|
||||
@@ -280,8 +280,9 @@ func (v *visitor) AuthFailed() {
|
||||
}
|
||||
}
|
||||
|
||||
// AccountCreationAllowed returns true if a new account can be created
|
||||
func (v *visitor) AccountCreationAllowed() bool {
|
||||
// AccountActionAllowed returns true if a rate-limited account action (signup or password-reset
|
||||
// request) is currently allowed for this visitor
|
||||
func (v *visitor) AccountActionAllowed() bool {
|
||||
v.mu.RLock() // limiters could be replaced!
|
||||
defer v.mu.RUnlock()
|
||||
if v.accountLimiter == nil || (v.accountLimiter != nil && v.accountLimiter.Tokens() < 1) {
|
||||
@@ -290,8 +291,9 @@ func (v *visitor) AccountCreationAllowed() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// AccountCreated decreases the account limiter. This is to be called after an account was created.
|
||||
func (v *visitor) AccountCreated() {
|
||||
// AccountActionPerformed decreases the account limiter. This is to be called after a rate-limited
|
||||
// account action (signup or password-reset request).
|
||||
func (v *visitor) AccountActionPerformed() {
|
||||
v.mu.RLock() // limiters could be replaced!
|
||||
defer v.mu.RUnlock()
|
||||
if v.accountLimiter != nil {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
go1.26.5
|
||||
@@ -0,0 +1,109 @@
|
||||
# `template/gotext/` -- vendored `text/template` with context cancellation
|
||||
|
||||
This directory is a **verbatim copy of Go's standard-library `text/template` package**, plus one
|
||||
small patch that adds context-aware execution (`ExecuteContext`). It exists for exactly one reason:
|
||||
to stop **user-supplied** message templates (`Template: yes`, see the [templating docs](https://ntfy.sh/docs/publish/#message-templating))
|
||||
from burning CPU.
|
||||
|
||||
- **Source:** Go stdlib `text/template` (+ `internal/fmtsort`), `$(go env GOROOT)/src`
|
||||
- **Version:** pinned in the repo-root [`.go-version`](../../.go-version); recorded in `GENERATED_FROM`
|
||||
- **Local modifications:** `patches/` (see [The patch](#the-patch))
|
||||
- **Update mechanism:** Manual -- `make update-template`, then commit (never autorolled; see [Updating](#updating-when-bumping-the-go-toolchain))
|
||||
|
||||
## Why this exists
|
||||
|
||||
ntfy lets users send a Go template that is rendered against a JSON body. Go's `text/template`
|
||||
**cannot be interrupted mid-execution** -- there is no context, no deadline, no cancellation
|
||||
([golang/go#31107](https://github.com/golang/go/issues/31107) proposed `ExecuteContext` but was
|
||||
declined, over a bundled context-*values* feature, not cancellation itself). So a crafted template
|
||||
with a tight or nested `{{range}}` (e.g. ranging over a large JSON array with a big loop body that
|
||||
writes no output) can run for tens of seconds on a single request. That is a CPU denial of service
|
||||
(GHSA-rhwf-xgc9-m9fp).
|
||||
|
||||
There is no way to add an interrupt from the outside -- the executor's per-node `walk` loop is
|
||||
unexported. The only robust fix is to patch the executor itself. Rather than reach for fragile
|
||||
heuristics (guessing iteration counts, wrapping every function, etc.), we vendor the package and add
|
||||
the cancellation half of #31107 as a patch: `ExecuteContext(ctx, ...)` that aborts with `ctx.Err()`
|
||||
when `ctx` is canceled or its deadline passes. The check is a **single poll inside `walk`** of an
|
||||
atomic flag that a `context.AfterFunc` watcher flips -- so it bounds CPU for *any* template shape
|
||||
(cheap loops and expensive functions alike), it is exact (observed within one node), and it adds no
|
||||
measurable overhead. If #31107's cancellation half ever lands upstream, this fork can be deleted and
|
||||
the call site keeps compiling unchanged.
|
||||
|
||||
The one user-facing execution site (`server/server_template.go` `renderTemplate`) wraps execution in
|
||||
`context.WithTimeout` and calls `ExecuteContext`, mapping `context.DeadlineExceeded` to a `400`.
|
||||
Trusted templates (operator config: Twilio, `cmd/serve.go`) keep using the standard library -- they
|
||||
are not user-supplied.
|
||||
|
||||
## What's here
|
||||
|
||||
| File | Origin |
|
||||
|------|--------|
|
||||
| `*.go` (`exec.go`, `funcs.go`, `template.go`, `option.go`, `helper.go`, `doc.go`) | verbatim from `$(go env GOROOT)/src/text/template/`, enumerated with `go list` so files added/removed upstream are picked up automatically |
|
||||
| `fmtsort/sort.go` | verbatim from `$(go env GOROOT)/src/internal/fmtsort/` -- `exec.go` needs it, and `internal/...` packages can't be imported from outside GOROOT, so it comes along |
|
||||
| `patches/0001-exec-context.patch` | our only real change (see below) |
|
||||
| `GENERATED_FROM` | the exact Go version `make update-template` last regenerated this copy from; provenance, written by that target |
|
||||
|
||||
The Go toolchain version this copy is pinned to lives in the repo-root [`.go-version`](../../.go-version)
|
||||
file (the single source of truth, also consumed by CI and the `make` targets below). `GENERATED_FROM`
|
||||
must equal it -- `make check` fails otherwise (see below).
|
||||
|
||||
We do **not** vendor `text/template/parse` -- it's a normal importable stdlib package and stays a
|
||||
plain import.
|
||||
|
||||
## The patch
|
||||
|
||||
`patches/` is a quilt-style ordered series (apply `0001-*`, then `0002-*`, ...). Today there is just
|
||||
`0001-exec-context.patch` -- small, purely additive, and touching only `exec.go`:
|
||||
|
||||
- adds `ctx context.Context` and a shared `cancelled *atomic.Bool` to the executor `state`
|
||||
- adds `ExecuteContext` / `ExecuteTemplateContext`; `Execute` / `ExecuteTemplate` become
|
||||
`context.Background()` wrappers, so their behavior and cost are unchanged
|
||||
- when `ctx.Done() != nil`, arms one `context.AfterFunc` watcher that flips the flag; `walk` polls it
|
||||
per node and aborts via a `cancelError` that `errRecover` strips to the bare `ctx.Err()`
|
||||
(`errors.Is(err, context.DeadlineExceeded)`)
|
||||
|
||||
The flag is a `*atomic.Bool` (not a value) because `walkTemplate` copies `state` for nested
|
||||
`{{template}}` invocations; a shared pointer keeps one flag across all copies and avoids `go vet`
|
||||
copylocks. `template.go` is unchanged -- the context is per-call, not stored on the `Template`.
|
||||
|
||||
Two *mechanical* transforms are applied by `make update-template` with `sed`, **not** the patch --
|
||||
renaming the package to `gotext`, and rewriting the `internal/fmtsort` import to
|
||||
`heckel.io/ntfy/v2/template/gotext/fmtsort`. Keeping them out of the patch means they apply to
|
||||
whatever files `go list` returns, so they survive upstream files being added or removed. (These two
|
||||
transforms are also the only difference between our patch and the upstream `text/template` diff.)
|
||||
|
||||
Keeping the patch tiny (cancellation only, on one stable file) is deliberate: it makes re-basing
|
||||
onto a new Go release cheap.
|
||||
|
||||
## Updating (when bumping the Go toolchain)
|
||||
|
||||
The copy is **pinned to the Go version in the root `.go-version`**, so it's not frozen -- re-syncing
|
||||
on a Go bump pulls in all upstream fixes for free. `.go-version` is authoritative and hand-edited; to
|
||||
bump the toolchain: edit `.go-version`, install that toolchain
|
||||
(`go install golang.org/dl/<version>@latest && <version> download`), then re-sync:
|
||||
|
||||
```
|
||||
make update-template # copies the files from your GOROOT and re-applies patches/*.patch
|
||||
```
|
||||
|
||||
`make update-template` **errors** unless your local Go matches `.go-version` -- it validates against
|
||||
the pin, it never writes it. If the patch hunks no longer apply against the new release, refresh the
|
||||
patch as part of the bump.
|
||||
|
||||
`make template-check` (wired into `make check`) has two layers:
|
||||
|
||||
1. **Marker check (ungated, runs on any toolchain):** fails if `GENERATED_FROM` != `.go-version`, i.e.
|
||||
someone bumped the pin but forgot `make update-template` (or vice versa). This catches the common
|
||||
mistake locally, on any developer's Go.
|
||||
2. **Content check (gated to the pinned Go):** re-derives the copy from `GOROOT + patches` and diffs it
|
||||
against what's committed, catching hand-edits and patch problems. It no-ops on a non-pinned
|
||||
toolchain so it never fails spuriously.
|
||||
|
||||
CI installs exactly `.go-version` (`go-version-file`), so both layers run there. `make release`
|
||||
additionally refuses to run off the pinned Go, so the content check is never skipped for a release.
|
||||
|
||||
## License
|
||||
|
||||
These files are copyright The Go Authors, under the BSD-3-Clause license (headers preserved in each
|
||||
file). That is compatible with ntfy's Apache-2.0 / GPLv2 licensing.
|
||||
@@ -0,0 +1,502 @@
|
||||
// Copyright 2011 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
/*
|
||||
Package template implements data-driven templates for generating textual output.
|
||||
|
||||
To generate HTML output, see [html/template], which has the same interface
|
||||
as this package but automatically secures HTML output against certain attacks.
|
||||
|
||||
Templates are executed by applying them to a data structure. Annotations in the
|
||||
template refer to elements of the data structure (typically a field of a struct
|
||||
or a key in a map) to control execution and derive values to be displayed.
|
||||
Execution of the template walks the structure and sets the cursor, represented
|
||||
by a period '.' and called "dot", to the value at the current location in the
|
||||
structure as execution proceeds.
|
||||
|
||||
The security model used by this package assumes that template authors are
|
||||
trusted. The package does not auto-escape output, so injecting code into
|
||||
a template can lead to arbitrary code execution if the template is executed
|
||||
by an untrusted source.
|
||||
|
||||
The input text for a template is UTF-8-encoded text in any format.
|
||||
"Actions"--data evaluations or control structures--are delimited by
|
||||
"{{" and "}}"; all text outside actions is copied to the output unchanged.
|
||||
|
||||
Once parsed, a template may be executed safely in parallel, although if parallel
|
||||
executions share a Writer the output may be interleaved.
|
||||
|
||||
Here is a trivial example that prints "17 items are made of wool".
|
||||
|
||||
type Inventory struct {
|
||||
Material string
|
||||
Count uint
|
||||
}
|
||||
sweaters := Inventory{"wool", 17}
|
||||
tmpl, err := template.New("test").Parse("{{.Count}} items are made of {{.Material}}")
|
||||
if err != nil { panic(err) }
|
||||
err = tmpl.Execute(os.Stdout, sweaters)
|
||||
if err != nil { panic(err) }
|
||||
|
||||
More intricate examples appear below.
|
||||
|
||||
Text and spaces
|
||||
|
||||
By default, all text between actions is copied verbatim when the template is
|
||||
executed. For example, the string " items are made of " in the example above
|
||||
appears on standard output when the program is run.
|
||||
|
||||
However, to aid in formatting template source code, if an action's left
|
||||
delimiter (by default "{{") is followed immediately by a minus sign and white
|
||||
space, all trailing white space is trimmed from the immediately preceding text.
|
||||
Similarly, if the right delimiter ("}}") is preceded by white space and a minus
|
||||
sign, all leading white space is trimmed from the immediately following text.
|
||||
In these trim markers, the white space must be present:
|
||||
"{{- 3}}" is like "{{3}}" but trims the immediately preceding text, while
|
||||
"{{-3}}" parses as an action containing the number -3.
|
||||
|
||||
For instance, when executing the template whose source is
|
||||
|
||||
"{{23 -}} < {{- 45}}"
|
||||
|
||||
the generated output would be
|
||||
|
||||
"23<45"
|
||||
|
||||
For this trimming, the definition of white space characters is the same as in Go:
|
||||
space, horizontal tab, carriage return, and newline.
|
||||
|
||||
Actions
|
||||
|
||||
Here is the list of actions. "Arguments" and "pipelines" are evaluations of
|
||||
data, defined in detail in the corresponding sections that follow.
|
||||
|
||||
*/
|
||||
// {{/* a comment */}}
|
||||
// {{- /* a comment with white space trimmed from preceding and following text */ -}}
|
||||
// A comment; discarded. May contain newlines.
|
||||
// Comments do not nest and must start and end at the
|
||||
// delimiters, as shown here.
|
||||
/*
|
||||
|
||||
{{pipeline}}
|
||||
The default textual representation (the same as would be
|
||||
printed by fmt.Print) of the value of the pipeline is copied
|
||||
to the output.
|
||||
|
||||
{{if pipeline}} T1 {{end}}
|
||||
If the value of the pipeline is empty, no output is generated;
|
||||
otherwise, T1 is executed. The empty values are false, 0, any
|
||||
nil pointer or interface value, and any array, slice, map, or
|
||||
string of length zero.
|
||||
Dot is unaffected.
|
||||
|
||||
{{if pipeline}} T1 {{else}} T0 {{end}}
|
||||
If the value of the pipeline is empty, T0 is executed;
|
||||
otherwise, T1 is executed. Dot is unaffected.
|
||||
|
||||
{{if pipeline}} T1 {{else if pipeline}} T0 {{end}}
|
||||
To simplify the appearance of if-else chains, the else action
|
||||
of an if may include another if directly; the effect is exactly
|
||||
the same as writing
|
||||
{{if pipeline}} T1 {{else}}{{if pipeline}} T0 {{end}}{{end}}
|
||||
|
||||
{{range pipeline}} T1 {{end}}
|
||||
The value of the pipeline must be an array, slice, map, iter.Seq,
|
||||
iter.Seq2, integer or channel.
|
||||
If the value of the pipeline has length zero, nothing is output;
|
||||
otherwise, dot is set to the successive elements of the array,
|
||||
slice, or map and T1 is executed. If the value is a map and the
|
||||
keys are of basic type with a defined order, the elements will be
|
||||
visited in sorted key order.
|
||||
|
||||
{{range pipeline}} T1 {{else}} T0 {{end}}
|
||||
The value of the pipeline must be an array, slice, map, iter.Seq,
|
||||
iter.Seq2, integer or channel.
|
||||
If the value of the pipeline has length zero, dot is unaffected and
|
||||
T0 is executed; otherwise, dot is set to the successive elements
|
||||
of the array, slice, or map and T1 is executed.
|
||||
|
||||
{{break}}
|
||||
The innermost {{range pipeline}} loop is ended early, stopping the
|
||||
current iteration and bypassing all remaining iterations.
|
||||
|
||||
{{continue}}
|
||||
The current iteration of the innermost {{range pipeline}} loop is
|
||||
stopped, and the loop starts the next iteration.
|
||||
|
||||
{{template "name"}}
|
||||
The template with the specified name is executed with nil data.
|
||||
|
||||
{{template "name" pipeline}}
|
||||
The template with the specified name is executed with dot set
|
||||
to the value of the pipeline.
|
||||
|
||||
{{block "name" pipeline}} T1 {{end}}
|
||||
A block is shorthand for defining a template
|
||||
{{define "name"}} T1 {{end}}
|
||||
and then executing it in place
|
||||
{{template "name" pipeline}}
|
||||
The typical use is to define a set of root templates that are
|
||||
then customized by redefining the block templates within.
|
||||
|
||||
{{with pipeline}} T1 {{end}}
|
||||
If the value of the pipeline is empty, no output is generated;
|
||||
otherwise, dot is set to the value of the pipeline and T1 is
|
||||
executed.
|
||||
|
||||
{{with pipeline}} T1 {{else}} T0 {{end}}
|
||||
If the value of the pipeline is empty, dot is unaffected and T0
|
||||
is executed; otherwise, dot is set to the value of the pipeline
|
||||
and T1 is executed.
|
||||
|
||||
{{with pipeline}} T1 {{else with pipeline}} T0 {{end}}
|
||||
To simplify the appearance of with-else chains, the else action
|
||||
of a with may include another with directly; the effect is exactly
|
||||
the same as writing
|
||||
{{with pipeline}} T1 {{else}}{{with pipeline}} T0 {{end}}{{end}}
|
||||
|
||||
|
||||
Arguments
|
||||
|
||||
An argument is a simple value, denoted by one of the following.
|
||||
|
||||
- A boolean, string, character, integer, floating-point, imaginary
|
||||
or complex constant in Go syntax. These behave like Go's untyped
|
||||
constants. Note that, as in Go, whether a large integer constant
|
||||
overflows when assigned or passed to a function can depend on whether
|
||||
the host machine's ints are 32 or 64 bits.
|
||||
- The keyword nil, representing an untyped Go nil.
|
||||
- The character '.' (period):
|
||||
|
||||
.
|
||||
|
||||
The result is the value of dot.
|
||||
- A variable name, which is a (possibly empty) alphanumeric string
|
||||
preceded by a dollar sign, such as
|
||||
|
||||
$piOver2
|
||||
|
||||
or
|
||||
|
||||
$
|
||||
|
||||
The result is the value of the variable.
|
||||
Variables are described below.
|
||||
- The name of a field of the data, which must be a struct, preceded
|
||||
by a period, such as
|
||||
|
||||
.Field
|
||||
|
||||
The result is the value of the field. Field invocations may be
|
||||
chained:
|
||||
|
||||
.Field1.Field2
|
||||
|
||||
Fields can also be evaluated on variables, including chaining:
|
||||
|
||||
$x.Field1.Field2
|
||||
- The name of a key of the data, which must be a map, preceded
|
||||
by a period, such as
|
||||
|
||||
.Key
|
||||
|
||||
The result is the map element value indexed by the key.
|
||||
Key invocations may be chained and combined with fields to any
|
||||
depth:
|
||||
|
||||
.Field1.Key1.Field2.Key2
|
||||
|
||||
Although the key must be an alphanumeric identifier, unlike with
|
||||
field names they do not need to start with an upper case letter.
|
||||
Keys can also be evaluated on variables, including chaining:
|
||||
|
||||
$x.key1.key2
|
||||
- The name of a niladic method of the data, preceded by a period,
|
||||
such as
|
||||
|
||||
.Method
|
||||
|
||||
The result is the value of invoking the method with dot as the
|
||||
receiver, dot.Method(). Such a method must have one return value (of
|
||||
any type) or two return values, the second of which is an error.
|
||||
If it has two and the returned error is non-nil, execution terminates
|
||||
and an error is returned to the caller as the value of Execute.
|
||||
Method invocations may be chained and combined with fields and keys
|
||||
to any depth:
|
||||
|
||||
.Field1.Key1.Method1.Field2.Key2.Method2
|
||||
|
||||
Methods can also be evaluated on variables, including chaining:
|
||||
|
||||
$x.Method1.Field
|
||||
- The name of a niladic function, such as
|
||||
|
||||
fun
|
||||
|
||||
The result is the value of invoking the function, fun(). The return
|
||||
types and values behave as in methods. Functions and function
|
||||
names are described below.
|
||||
- A parenthesized instance of one the above, for grouping. The result
|
||||
may be accessed by a field or map key invocation.
|
||||
|
||||
print (.F1 arg1) (.F2 arg2)
|
||||
(.StructValuedMethod "arg").Field
|
||||
|
||||
Arguments may evaluate to any type; if they are pointers the implementation
|
||||
automatically indirects to the base type when required.
|
||||
If an evaluation yields a function value, such as a function-valued
|
||||
field of a struct, the function is not invoked automatically, but it
|
||||
can be used as a truth value for an if action and the like. To invoke
|
||||
it, use the call function, defined below.
|
||||
|
||||
Pipelines
|
||||
|
||||
A pipeline is a possibly chained sequence of "commands". A command is a simple
|
||||
value (argument) or a function or method call, possibly with multiple arguments:
|
||||
|
||||
Argument
|
||||
The result is the value of evaluating the argument.
|
||||
.Method [Argument...]
|
||||
The method can be alone or the last element of a chain but,
|
||||
unlike methods in the middle of a chain, it can take arguments.
|
||||
The result is the value of calling the method with the
|
||||
arguments:
|
||||
dot.Method(Argument1, etc.)
|
||||
functionName [Argument...]
|
||||
The result is the value of calling the function associated
|
||||
with the name:
|
||||
function(Argument1, etc.)
|
||||
Functions and function names are described below.
|
||||
|
||||
A pipeline may be "chained" by separating a sequence of commands with pipeline
|
||||
characters '|'. In a chained pipeline, the result of each command is
|
||||
passed as the last argument of the following command. The output of the final
|
||||
command in the pipeline is the value of the pipeline.
|
||||
|
||||
The output of a command will be either one value or two values, the second of
|
||||
which has type error. If that second value is present and evaluates to
|
||||
non-nil, execution terminates and the error is returned to the caller of
|
||||
Execute.
|
||||
|
||||
Variables
|
||||
|
||||
A pipeline inside an action may initialize a variable to capture the result.
|
||||
The initialization has syntax
|
||||
|
||||
$variable := pipeline
|
||||
|
||||
where $variable is the name of the variable. An action that declares a
|
||||
variable produces no output.
|
||||
|
||||
Variables previously declared can also be assigned, using the syntax
|
||||
|
||||
$variable = pipeline
|
||||
|
||||
If a "range" action initializes a variable, the variable is set to the
|
||||
successive elements of the iteration. Also, a "range" may declare two
|
||||
variables, separated by a comma:
|
||||
|
||||
range $index, $element := pipeline
|
||||
|
||||
in which case $index and $element are set to the successive values of the
|
||||
array/slice index or map key and element, respectively. Note that if there is
|
||||
only one variable, it is assigned the element; this is opposite to the
|
||||
convention in Go range clauses.
|
||||
|
||||
A variable's scope extends to the "end" action of the control structure ("if",
|
||||
"with", or "range") in which it is declared, or to the end of the template if
|
||||
there is no such control structure. A template invocation does not inherit
|
||||
variables from the point of its invocation.
|
||||
|
||||
When execution begins, $ is set to the data argument passed to Execute, that is,
|
||||
to the starting value of dot.
|
||||
|
||||
Examples
|
||||
|
||||
Here are some example one-line templates demonstrating pipelines and variables.
|
||||
All produce the quoted word "output":
|
||||
|
||||
{{"\"output\""}}
|
||||
A string constant.
|
||||
{{`"output"`}}
|
||||
A raw string constant.
|
||||
{{printf "%q" "output"}}
|
||||
A function call.
|
||||
{{"output" | printf "%q"}}
|
||||
A function call whose final argument comes from the previous
|
||||
command.
|
||||
{{printf "%q" (print "out" "put")}}
|
||||
A parenthesized argument.
|
||||
{{"put" | printf "%s%s" "out" | printf "%q"}}
|
||||
A more elaborate call.
|
||||
{{"output" | printf "%s" | printf "%q"}}
|
||||
A longer chain.
|
||||
{{with "output"}}{{printf "%q" .}}{{end}}
|
||||
A with action using dot.
|
||||
{{with $x := "output" | printf "%q"}}{{$x}}{{end}}
|
||||
A with action that creates and uses a variable.
|
||||
{{with $x := "output"}}{{printf "%q" $x}}{{end}}
|
||||
A with action that uses the variable in another action.
|
||||
{{with $x := "output"}}{{$x | printf "%q"}}{{end}}
|
||||
The same, but pipelined.
|
||||
|
||||
Functions
|
||||
|
||||
During execution functions are found in two function maps: first in the
|
||||
template, then in the global function map. By default, no functions are defined
|
||||
in the template but the Funcs method can be used to add them.
|
||||
|
||||
Predefined global functions are named as follows.
|
||||
|
||||
and
|
||||
Returns the boolean AND of its arguments by returning the
|
||||
first empty argument or the last argument. That is,
|
||||
"and x y" behaves as "if x then y else x."
|
||||
Evaluation proceeds through the arguments left to right
|
||||
and returns when the result is determined.
|
||||
call
|
||||
Returns the result of calling the first argument, which
|
||||
must be a function, with the remaining arguments as parameters.
|
||||
Thus "call .X.Y 1 2" is, in Go notation, dot.X.Y(1, 2) where
|
||||
Y is a func-valued field, map entry, or the like.
|
||||
The first argument must be the result of an evaluation
|
||||
that yields a value of function type (as distinct from
|
||||
a predefined function such as print). The function must
|
||||
return either one or two result values, the second of which
|
||||
is of type error. If the arguments don't match the function
|
||||
or the returned error value is non-nil, execution stops.
|
||||
html
|
||||
Returns the escaped HTML equivalent of the textual
|
||||
representation of its arguments. This function is unavailable
|
||||
in html/template, with a few exceptions.
|
||||
index
|
||||
Returns the result of indexing its first argument by the
|
||||
following arguments. Thus "index x 1 2 3" is, in Go syntax,
|
||||
x[1][2][3]. Each indexed item must be a map, slice, or array.
|
||||
slice
|
||||
slice returns the result of slicing its first argument by the
|
||||
remaining arguments. Thus "slice x 1 2" is, in Go syntax, x[1:2],
|
||||
while "slice x" is x[:], "slice x 1" is x[1:], and "slice x 1 2 3"
|
||||
is x[1:2:3]. The first argument must be a string, slice, or array.
|
||||
js
|
||||
Returns the escaped JavaScript equivalent of the textual
|
||||
representation of its arguments.
|
||||
len
|
||||
Returns the integer length of its argument.
|
||||
not
|
||||
Returns the boolean negation of its single argument.
|
||||
or
|
||||
Returns the boolean OR of its arguments by returning the
|
||||
first non-empty argument or the last argument, that is,
|
||||
"or x y" behaves as "if x then x else y".
|
||||
Evaluation proceeds through the arguments left to right
|
||||
and returns when the result is determined.
|
||||
print
|
||||
An alias for fmt.Sprint
|
||||
printf
|
||||
An alias for fmt.Sprintf
|
||||
println
|
||||
An alias for fmt.Sprintln
|
||||
urlquery
|
||||
Returns the escaped value of the textual representation of
|
||||
its arguments in a form suitable for embedding in a URL query.
|
||||
This function is unavailable in html/template, with a few
|
||||
exceptions.
|
||||
|
||||
The boolean functions take any zero value to be false and a non-zero
|
||||
value to be true.
|
||||
|
||||
There is also a set of binary comparison operators defined as
|
||||
functions:
|
||||
|
||||
eq
|
||||
Returns the boolean truth of arg1 == arg2
|
||||
ne
|
||||
Returns the boolean truth of arg1 != arg2
|
||||
lt
|
||||
Returns the boolean truth of arg1 < arg2
|
||||
le
|
||||
Returns the boolean truth of arg1 <= arg2
|
||||
gt
|
||||
Returns the boolean truth of arg1 > arg2
|
||||
ge
|
||||
Returns the boolean truth of arg1 >= arg2
|
||||
|
||||
For simpler multi-way equality tests, eq (only) accepts two or more
|
||||
arguments and compares the second and subsequent to the first,
|
||||
returning in effect
|
||||
|
||||
arg1==arg2 || arg1==arg3 || arg1==arg4 ...
|
||||
|
||||
(Unlike with || in Go, however, eq is a function call and all the
|
||||
arguments will be evaluated.)
|
||||
|
||||
The comparison functions work on any values whose type Go defines as
|
||||
comparable. For basic types such as integers, the rules are relaxed:
|
||||
size and exact type are ignored, so any integer value, signed or unsigned,
|
||||
may be compared with any other integer value. (The arithmetic value is compared,
|
||||
not the bit pattern, so all negative integers are less than all unsigned integers.)
|
||||
However, as usual, one may not compare an int with a float32 and so on.
|
||||
|
||||
Associated templates
|
||||
|
||||
Each template is named by a string specified when it is created. Also, each
|
||||
template is associated with zero or more other templates that it may invoke by
|
||||
name; such associations are transitive and form a name space of templates.
|
||||
|
||||
A template may use a template invocation to instantiate another associated
|
||||
template; see the explanation of the "template" action above. The name must be
|
||||
that of a template associated with the template that contains the invocation.
|
||||
|
||||
Nested template definitions
|
||||
|
||||
When parsing a template, another template may be defined and associated with the
|
||||
template being parsed. Template definitions must appear at the top level of the
|
||||
template, much like global variables in a Go program.
|
||||
|
||||
The syntax of such definitions is to surround each template declaration with a
|
||||
"define" and "end" action.
|
||||
|
||||
The define action names the template being created by providing a string
|
||||
constant. Here is a simple example:
|
||||
|
||||
{{define "T1"}}ONE{{end}}
|
||||
{{define "T2"}}TWO{{end}}
|
||||
{{define "T3"}}{{template "T1"}} {{template "T2"}}{{end}}
|
||||
{{template "T3"}}
|
||||
|
||||
This defines two templates, T1 and T2, and a third T3 that invokes the other two
|
||||
when it is executed. Finally it invokes T3. If executed this template will
|
||||
produce the text
|
||||
|
||||
ONE TWO
|
||||
|
||||
By construction, a template may reside in only one association. If it's
|
||||
necessary to have a template addressable from multiple associations, the
|
||||
template definition must be parsed multiple times to create distinct *Template
|
||||
values, or must be copied with [Template.Clone] or [Template.AddParseTree].
|
||||
|
||||
Parse may be called multiple times to assemble the various associated templates;
|
||||
see [ParseFiles], [ParseGlob], [Template.ParseFiles] and [Template.ParseGlob]
|
||||
for simple ways to parse related templates stored in files.
|
||||
|
||||
A template may be executed directly or through [Template.ExecuteTemplate], which executes
|
||||
an associated template identified by name. To invoke our example above, we
|
||||
might write,
|
||||
|
||||
err := tmpl.Execute(os.Stdout, "no data needed")
|
||||
if err != nil {
|
||||
log.Fatalf("execution failed: %s", err)
|
||||
}
|
||||
|
||||
or to invoke a particular template explicitly by name,
|
||||
|
||||
err := tmpl.ExecuteTemplate(os.Stdout, "T2", "no data needed")
|
||||
if err != nil {
|
||||
log.Fatalf("execution failed: %s", err)
|
||||
}
|
||||
|
||||
*/
|
||||
package gotext
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,154 @@
|
||||
// Copyright 2018 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package fmtsort provides a general stable ordering mechanism
|
||||
// for maps, on behalf of the fmt and text/template packages.
|
||||
// It is not guaranteed to be efficient and works only for types
|
||||
// that are valid map keys.
|
||||
package fmtsort
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"reflect"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// Note: Throughout this package we avoid calling reflect.Value.Interface as
|
||||
// it is not always legal to do so and it's easier to avoid the issue than to face it.
|
||||
|
||||
// SortedMap is a slice of KeyValue pairs that simplifies sorting
|
||||
// and iterating over map entries.
|
||||
//
|
||||
// Each KeyValue pair contains a map key and its corresponding value.
|
||||
type SortedMap []KeyValue
|
||||
|
||||
// KeyValue holds a single key and value pair found in a map.
|
||||
type KeyValue struct {
|
||||
Key, Value reflect.Value
|
||||
}
|
||||
|
||||
// Sort accepts a map and returns a SortedMap that has the same keys and
|
||||
// values but in a stable sorted order according to the keys, modulo issues
|
||||
// raised by unorderable key values such as NaNs.
|
||||
//
|
||||
// The ordering rules are more general than with Go's < operator:
|
||||
//
|
||||
// - when applicable, nil compares low
|
||||
// - ints, floats, and strings order by <
|
||||
// - NaN compares less than non-NaN floats
|
||||
// - bool compares false before true
|
||||
// - complex compares real, then imag
|
||||
// - pointers compare by machine address
|
||||
// - channel values compare by machine address
|
||||
// - structs compare each field in turn
|
||||
// - arrays compare each element in turn.
|
||||
// Otherwise identical arrays compare by length.
|
||||
// - interface values compare first by reflect.Type describing the concrete type
|
||||
// and then by concrete value as described in the previous rules.
|
||||
func Sort(mapValue reflect.Value) SortedMap {
|
||||
if mapValue.Type().Kind() != reflect.Map {
|
||||
return nil
|
||||
}
|
||||
// Note: this code is arranged to not panic even in the presence
|
||||
// of a concurrent map update. The runtime is responsible for
|
||||
// yelling loudly if that happens. See issue 33275.
|
||||
n := mapValue.Len()
|
||||
sorted := make(SortedMap, 0, n)
|
||||
iter := mapValue.MapRange()
|
||||
for iter.Next() {
|
||||
sorted = append(sorted, KeyValue{iter.Key(), iter.Value()})
|
||||
}
|
||||
slices.SortStableFunc(sorted, func(a, b KeyValue) int {
|
||||
return compare(a.Key, b.Key)
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
// compare compares two values of the same type. It returns -1, 0, 1
|
||||
// according to whether a > b (1), a == b (0), or a < b (-1).
|
||||
// If the types differ, it returns -1.
|
||||
// See the comment on Sort for the comparison rules.
|
||||
func compare(aVal, bVal reflect.Value) int {
|
||||
aType, bType := aVal.Type(), bVal.Type()
|
||||
if aType != bType {
|
||||
return -1 // No good answer possible, but don't return 0: they're not equal.
|
||||
}
|
||||
switch aVal.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return cmp.Compare(aVal.Int(), bVal.Int())
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||
return cmp.Compare(aVal.Uint(), bVal.Uint())
|
||||
case reflect.String:
|
||||
return cmp.Compare(aVal.String(), bVal.String())
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return cmp.Compare(aVal.Float(), bVal.Float())
|
||||
case reflect.Complex64, reflect.Complex128:
|
||||
a, b := aVal.Complex(), bVal.Complex()
|
||||
if c := cmp.Compare(real(a), real(b)); c != 0 {
|
||||
return c
|
||||
}
|
||||
return cmp.Compare(imag(a), imag(b))
|
||||
case reflect.Bool:
|
||||
a, b := aVal.Bool(), bVal.Bool()
|
||||
switch {
|
||||
case a == b:
|
||||
return 0
|
||||
case a:
|
||||
return 1
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
case reflect.Pointer, reflect.UnsafePointer:
|
||||
return cmp.Compare(aVal.Pointer(), bVal.Pointer())
|
||||
case reflect.Chan:
|
||||
if c, ok := nilCompare(aVal, bVal); ok {
|
||||
return c
|
||||
}
|
||||
return cmp.Compare(aVal.Pointer(), bVal.Pointer())
|
||||
case reflect.Struct:
|
||||
for i := 0; i < aVal.NumField(); i++ {
|
||||
if c := compare(aVal.Field(i), bVal.Field(i)); c != 0 {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return 0
|
||||
case reflect.Array:
|
||||
for i := 0; i < aVal.Len(); i++ {
|
||||
if c := compare(aVal.Index(i), bVal.Index(i)); c != 0 {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return 0
|
||||
case reflect.Interface:
|
||||
if c, ok := nilCompare(aVal, bVal); ok {
|
||||
return c
|
||||
}
|
||||
c := compare(reflect.ValueOf(aVal.Elem().Type()), reflect.ValueOf(bVal.Elem().Type()))
|
||||
if c != 0 {
|
||||
return c
|
||||
}
|
||||
return compare(aVal.Elem(), bVal.Elem())
|
||||
default:
|
||||
// Certain types cannot appear as keys (maps, funcs, slices), but be explicit.
|
||||
panic("bad type in compare: " + aType.String())
|
||||
}
|
||||
}
|
||||
|
||||
// nilCompare checks whether either value is nil. If not, the boolean is false.
|
||||
// If either value is nil, the boolean is true and the integer is the comparison
|
||||
// value. The comparison is defined to be 0 if both are nil, otherwise the one
|
||||
// nil value compares low. Both arguments must represent a chan, func,
|
||||
// interface, map, pointer, or slice.
|
||||
func nilCompare(aVal, bVal reflect.Value) (int, bool) {
|
||||
if aVal.IsNil() {
|
||||
if bVal.IsNil() {
|
||||
return 0, true
|
||||
}
|
||||
return -1, true
|
||||
}
|
||||
if bVal.IsNil() {
|
||||
return 1, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
// Copyright 2011 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package gotext
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// FuncMap is the type of the map defining the mapping from names to functions.
|
||||
// Each function must have either a single return value, or two return values of
|
||||
// which the second has type error. In that case, if the second (error)
|
||||
// return value evaluates to non-nil during execution, execution terminates and
|
||||
// Execute returns that error.
|
||||
//
|
||||
// Errors returned by Execute wrap the underlying error; call [errors.AsType] to
|
||||
// unwrap them.
|
||||
//
|
||||
// When template execution invokes a function with an argument list, that list
|
||||
// must be assignable to the function's parameter types. Functions meant to
|
||||
// apply to arguments of arbitrary type can use parameters of type interface{} or
|
||||
// of type [reflect.Value]. Similarly, functions meant to return a result of arbitrary
|
||||
// type can return interface{} or [reflect.Value].
|
||||
type FuncMap map[string]any
|
||||
|
||||
// builtins returns the FuncMap.
|
||||
// It is not a global variable so the linker can dead code eliminate
|
||||
// more when this isn't called. See golang.org/issue/36021.
|
||||
// TODO: revert this back to a global map once golang.org/issue/2559 is fixed.
|
||||
func builtins() FuncMap {
|
||||
return FuncMap{
|
||||
"and": and,
|
||||
"call": emptyCall,
|
||||
"html": HTMLEscaper,
|
||||
"index": index,
|
||||
"slice": slice,
|
||||
"js": JSEscaper,
|
||||
"len": length,
|
||||
"not": not,
|
||||
"or": or,
|
||||
"print": fmt.Sprint,
|
||||
"printf": fmt.Sprintf,
|
||||
"println": fmt.Sprintln,
|
||||
"urlquery": URLQueryEscaper,
|
||||
|
||||
// Comparisons
|
||||
"eq": eq, // ==
|
||||
"ge": ge, // >=
|
||||
"gt": gt, // >
|
||||
"le": le, // <=
|
||||
"lt": lt, // <
|
||||
"ne": ne, // !=
|
||||
}
|
||||
}
|
||||
|
||||
// builtinFuncs lazily computes & caches the builtinFuncs map.
|
||||
var builtinFuncs = sync.OnceValue(func() map[string]reflect.Value {
|
||||
funcMap := builtins()
|
||||
m := make(map[string]reflect.Value, len(funcMap))
|
||||
addValueFuncs(m, funcMap)
|
||||
return m
|
||||
})
|
||||
|
||||
// addValueFuncs adds to values the functions in funcs, converting them to reflect.Values.
|
||||
func addValueFuncs(out map[string]reflect.Value, in FuncMap) {
|
||||
for name, fn := range in {
|
||||
if !goodName(name) {
|
||||
panic(fmt.Errorf("function name %q is not a valid identifier", name))
|
||||
}
|
||||
v := reflect.ValueOf(fn)
|
||||
if v.Kind() != reflect.Func {
|
||||
panic("value for " + name + " not a function")
|
||||
}
|
||||
if err := goodFunc(name, v.Type()); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
out[name] = v
|
||||
}
|
||||
}
|
||||
|
||||
// addFuncs adds to values the functions in funcs. It does no checking of the input -
|
||||
// call addValueFuncs first.
|
||||
func addFuncs(out, in FuncMap) {
|
||||
for name, fn := range in {
|
||||
out[name] = fn
|
||||
}
|
||||
}
|
||||
|
||||
// goodFunc reports whether the function or method has the right result signature.
|
||||
func goodFunc(name string, typ reflect.Type) error {
|
||||
// We allow functions with 1 result or 2 results where the second is an error.
|
||||
switch numOut := typ.NumOut(); {
|
||||
case numOut == 1:
|
||||
return nil
|
||||
case numOut == 2 && typ.Out(1) == errorType:
|
||||
return nil
|
||||
case numOut == 2:
|
||||
return fmt.Errorf("invalid function signature for %s: second return value should be error; is %s", name, typ.Out(1))
|
||||
default:
|
||||
return fmt.Errorf("function %s has %d return values; should be 1 or 2", name, typ.NumOut())
|
||||
}
|
||||
}
|
||||
|
||||
// goodName reports whether the function name is a valid identifier.
|
||||
func goodName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for i, r := range name {
|
||||
switch {
|
||||
case r == '_':
|
||||
case i == 0 && !unicode.IsLetter(r):
|
||||
return false
|
||||
case !unicode.IsLetter(r) && !unicode.IsDigit(r):
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// findFunction looks for a function in the template, and global map.
|
||||
func findFunction(name string, tmpl *Template) (v reflect.Value, isBuiltin, ok bool) {
|
||||
if tmpl != nil && tmpl.common != nil {
|
||||
tmpl.muFuncs.RLock()
|
||||
defer tmpl.muFuncs.RUnlock()
|
||||
if fn := tmpl.execFuncs[name]; fn.IsValid() {
|
||||
return fn, false, true
|
||||
}
|
||||
}
|
||||
if fn := builtinFuncs()[name]; fn.IsValid() {
|
||||
return fn, true, true
|
||||
}
|
||||
return reflect.Value{}, false, false
|
||||
}
|
||||
|
||||
// prepareArg checks if value can be used as an argument of type argType, and
|
||||
// converts an invalid value to appropriate zero if possible.
|
||||
func prepareArg(value reflect.Value, argType reflect.Type) (reflect.Value, error) {
|
||||
if !value.IsValid() {
|
||||
if !canBeNil(argType) {
|
||||
return reflect.Value{}, fmt.Errorf("value is nil; should be of type %s", argType)
|
||||
}
|
||||
value = reflect.Zero(argType)
|
||||
}
|
||||
if value.Type().AssignableTo(argType) {
|
||||
return value, nil
|
||||
}
|
||||
if intLike(value.Kind()) && intLike(argType.Kind()) && value.Type().ConvertibleTo(argType) {
|
||||
value = value.Convert(argType)
|
||||
return value, nil
|
||||
}
|
||||
return reflect.Value{}, fmt.Errorf("value has type %s; should be %s", value.Type(), argType)
|
||||
}
|
||||
|
||||
func intLike(typ reflect.Kind) bool {
|
||||
switch typ {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return true
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// indexArg checks if a reflect.Value can be used as an index, and converts it to int if possible.
|
||||
func indexArg(index reflect.Value, cap int) (int, error) {
|
||||
var x int64
|
||||
switch index.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
x = index.Int()
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||
x = int64(index.Uint())
|
||||
case reflect.Invalid:
|
||||
return 0, fmt.Errorf("cannot index slice/array with nil")
|
||||
default:
|
||||
return 0, fmt.Errorf("cannot index slice/array with type %s", index.Type())
|
||||
}
|
||||
if x < 0 || int(x) < 0 || int(x) > cap {
|
||||
return 0, fmt.Errorf("index out of range: %d", x)
|
||||
}
|
||||
return int(x), nil
|
||||
}
|
||||
|
||||
// Indexing.
|
||||
|
||||
// index returns the result of indexing its first argument by the following
|
||||
// arguments. Thus "index x 1 2 3" is, in Go syntax, x[1][2][3]. Each
|
||||
// indexed item must be a map, slice, or array.
|
||||
func index(item reflect.Value, indexes ...reflect.Value) (reflect.Value, error) {
|
||||
item = indirectInterface(item)
|
||||
if !item.IsValid() {
|
||||
return reflect.Value{}, fmt.Errorf("index of untyped nil")
|
||||
}
|
||||
for _, index := range indexes {
|
||||
index = indirectInterface(index)
|
||||
var isNil bool
|
||||
if item, isNil = indirect(item); isNil {
|
||||
return reflect.Value{}, fmt.Errorf("index of nil pointer")
|
||||
}
|
||||
switch item.Kind() {
|
||||
case reflect.Array, reflect.Slice, reflect.String:
|
||||
x, err := indexArg(index, item.Len())
|
||||
if err != nil {
|
||||
return reflect.Value{}, err
|
||||
}
|
||||
item = item.Index(x)
|
||||
case reflect.Map:
|
||||
index, err := prepareArg(index, item.Type().Key())
|
||||
if err != nil {
|
||||
return reflect.Value{}, err
|
||||
}
|
||||
if x := item.MapIndex(index); x.IsValid() {
|
||||
item = x
|
||||
} else {
|
||||
item = reflect.Zero(item.Type().Elem())
|
||||
}
|
||||
case reflect.Invalid:
|
||||
// the loop holds invariant: item.IsValid()
|
||||
panic("unreachable")
|
||||
default:
|
||||
return reflect.Value{}, fmt.Errorf("can't index item of type %s", item.Type())
|
||||
}
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// Slicing.
|
||||
|
||||
// slice returns the result of slicing its first argument by the remaining
|
||||
// arguments. Thus "slice x 1 2" is, in Go syntax, x[1:2], while "slice x"
|
||||
// is x[:], "slice x 1" is x[1:], and "slice x 1 2 3" is x[1:2:3]. The first
|
||||
// argument must be a string, slice, or array.
|
||||
func slice(item reflect.Value, indexes ...reflect.Value) (reflect.Value, error) {
|
||||
item = indirectInterface(item)
|
||||
if !item.IsValid() {
|
||||
return reflect.Value{}, fmt.Errorf("slice of untyped nil")
|
||||
}
|
||||
var isNil bool
|
||||
if item, isNil = indirect(item); isNil {
|
||||
return reflect.Value{}, fmt.Errorf("slice of nil pointer")
|
||||
}
|
||||
if len(indexes) > 3 {
|
||||
return reflect.Value{}, fmt.Errorf("too many slice indexes: %d", len(indexes))
|
||||
}
|
||||
var cap int
|
||||
switch item.Kind() {
|
||||
case reflect.String:
|
||||
if len(indexes) == 3 {
|
||||
return reflect.Value{}, fmt.Errorf("cannot 3-index slice a string")
|
||||
}
|
||||
cap = item.Len()
|
||||
case reflect.Array, reflect.Slice:
|
||||
cap = item.Cap()
|
||||
default:
|
||||
return reflect.Value{}, fmt.Errorf("can't slice item of type %s", item.Type())
|
||||
}
|
||||
// set default values for cases item[:], item[i:].
|
||||
idx := [3]int{0, item.Len()}
|
||||
for i, index := range indexes {
|
||||
x, err := indexArg(index, cap)
|
||||
if err != nil {
|
||||
return reflect.Value{}, err
|
||||
}
|
||||
idx[i] = x
|
||||
}
|
||||
// given item[i:j], make sure i <= j.
|
||||
if idx[0] > idx[1] {
|
||||
return reflect.Value{}, fmt.Errorf("invalid slice index: %d > %d", idx[0], idx[1])
|
||||
}
|
||||
if len(indexes) < 3 {
|
||||
return item.Slice(idx[0], idx[1]), nil
|
||||
}
|
||||
// given item[i:j:k], make sure i <= j <= k.
|
||||
if idx[1] > idx[2] {
|
||||
return reflect.Value{}, fmt.Errorf("invalid slice index: %d > %d", idx[1], idx[2])
|
||||
}
|
||||
return item.Slice3(idx[0], idx[1], idx[2]), nil
|
||||
}
|
||||
|
||||
// Length
|
||||
|
||||
// length returns the length of the item, with an error if it has no defined length.
|
||||
func length(item reflect.Value) (int, error) {
|
||||
item, isNil := indirect(item)
|
||||
if isNil {
|
||||
return 0, fmt.Errorf("len of nil pointer")
|
||||
}
|
||||
switch item.Kind() {
|
||||
case reflect.Array, reflect.Chan, reflect.Map, reflect.Slice, reflect.String:
|
||||
return item.Len(), nil
|
||||
}
|
||||
return 0, fmt.Errorf("len of type %s", item.Type())
|
||||
}
|
||||
|
||||
// Function invocation
|
||||
|
||||
func emptyCall(fn reflect.Value, args ...reflect.Value) reflect.Value {
|
||||
panic("unreachable") // implemented as a special case in evalCall
|
||||
}
|
||||
|
||||
// call returns the result of evaluating the first argument as a function.
|
||||
// The function must return 1 result, or 2 results, the second of which is an error.
|
||||
func call(name string, fn reflect.Value, args ...reflect.Value) (reflect.Value, error) {
|
||||
fn = indirectInterface(fn)
|
||||
if !fn.IsValid() {
|
||||
return reflect.Value{}, fmt.Errorf("call of nil")
|
||||
}
|
||||
typ := fn.Type()
|
||||
if typ.Kind() != reflect.Func {
|
||||
return reflect.Value{}, fmt.Errorf("non-function %s of type %s", name, typ)
|
||||
}
|
||||
|
||||
if err := goodFunc(name, typ); err != nil {
|
||||
return reflect.Value{}, err
|
||||
}
|
||||
numIn := typ.NumIn()
|
||||
var dddType reflect.Type
|
||||
if typ.IsVariadic() {
|
||||
if len(args) < numIn-1 {
|
||||
return reflect.Value{}, fmt.Errorf("wrong number of args for %s: got %d want at least %d", name, len(args), numIn-1)
|
||||
}
|
||||
dddType = typ.In(numIn - 1).Elem()
|
||||
} else {
|
||||
if len(args) != numIn {
|
||||
return reflect.Value{}, fmt.Errorf("wrong number of args for %s: got %d want %d", name, len(args), numIn)
|
||||
}
|
||||
}
|
||||
argv := make([]reflect.Value, len(args))
|
||||
for i, arg := range args {
|
||||
arg = indirectInterface(arg)
|
||||
// Compute the expected type. Clumsy because of variadics.
|
||||
argType := dddType
|
||||
if !typ.IsVariadic() || i < numIn-1 {
|
||||
argType = typ.In(i)
|
||||
}
|
||||
|
||||
var err error
|
||||
if argv[i], err = prepareArg(arg, argType); err != nil {
|
||||
return reflect.Value{}, fmt.Errorf("arg %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
return safeCall(fn, argv)
|
||||
}
|
||||
|
||||
// safeCall runs fun.Call(args), and returns the resulting value and error, if
|
||||
// any. If the call panics, the panic value is returned as an error.
|
||||
func safeCall(fun reflect.Value, args []reflect.Value) (val reflect.Value, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if e, ok := r.(error); ok {
|
||||
err = e
|
||||
} else {
|
||||
err = fmt.Errorf("%v", r)
|
||||
}
|
||||
}
|
||||
}()
|
||||
ret := fun.Call(args)
|
||||
if len(ret) == 2 && !ret[1].IsNil() {
|
||||
return ret[0], ret[1].Interface().(error)
|
||||
}
|
||||
return ret[0], nil
|
||||
}
|
||||
|
||||
// Boolean logic.
|
||||
|
||||
func truth(arg reflect.Value) bool {
|
||||
t, _ := isTrue(indirectInterface(arg))
|
||||
return t
|
||||
}
|
||||
|
||||
// and computes the Boolean AND of its arguments, returning
|
||||
// the first false argument it encounters, or the last argument.
|
||||
func and(arg0 reflect.Value, args ...reflect.Value) reflect.Value {
|
||||
panic("unreachable") // implemented as a special case in evalCall
|
||||
}
|
||||
|
||||
// or computes the Boolean OR of its arguments, returning
|
||||
// the first true argument it encounters, or the last argument.
|
||||
func or(arg0 reflect.Value, args ...reflect.Value) reflect.Value {
|
||||
panic("unreachable") // implemented as a special case in evalCall
|
||||
}
|
||||
|
||||
// not returns the Boolean negation of its argument.
|
||||
func not(arg reflect.Value) bool {
|
||||
return !truth(arg)
|
||||
}
|
||||
|
||||
// Comparison.
|
||||
|
||||
// TODO: Perhaps allow comparison between signed and unsigned integers.
|
||||
|
||||
var (
|
||||
errBadComparisonType = errors.New("invalid type for comparison")
|
||||
errNoComparison = errors.New("missing argument for comparison")
|
||||
)
|
||||
|
||||
type kind int
|
||||
|
||||
const (
|
||||
invalidKind kind = iota
|
||||
boolKind
|
||||
complexKind
|
||||
intKind
|
||||
floatKind
|
||||
stringKind
|
||||
uintKind
|
||||
)
|
||||
|
||||
func basicKind(v reflect.Value) (kind, error) {
|
||||
switch v.Kind() {
|
||||
case reflect.Bool:
|
||||
return boolKind, nil
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return intKind, nil
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||
return uintKind, nil
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return floatKind, nil
|
||||
case reflect.Complex64, reflect.Complex128:
|
||||
return complexKind, nil
|
||||
case reflect.String:
|
||||
return stringKind, nil
|
||||
}
|
||||
return invalidKind, errBadComparisonType
|
||||
}
|
||||
|
||||
// isNil returns true if v is the zero reflect.Value, or nil of its type.
|
||||
func isNil(v reflect.Value) bool {
|
||||
if !v.IsValid() {
|
||||
return true
|
||||
}
|
||||
switch v.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||
return v.IsNil()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// canCompare reports whether v1 and v2 are both the same kind, or one is nil.
|
||||
// Called only when dealing with nillable types, or there's about to be an error.
|
||||
func canCompare(v1, v2 reflect.Value) bool {
|
||||
k1 := v1.Kind()
|
||||
k2 := v2.Kind()
|
||||
if k1 == k2 {
|
||||
return true
|
||||
}
|
||||
// We know the type can be compared to nil.
|
||||
return k1 == reflect.Invalid || k2 == reflect.Invalid
|
||||
}
|
||||
|
||||
// eq evaluates the comparison a == b || a == c || ...
|
||||
func eq(arg1 reflect.Value, arg2 ...reflect.Value) (bool, error) {
|
||||
arg1 = indirectInterface(arg1)
|
||||
if len(arg2) == 0 {
|
||||
return false, errNoComparison
|
||||
}
|
||||
k1, _ := basicKind(arg1)
|
||||
for _, arg := range arg2 {
|
||||
arg = indirectInterface(arg)
|
||||
k2, _ := basicKind(arg)
|
||||
truth := false
|
||||
if k1 != k2 {
|
||||
// Special case: Can compare integer values regardless of type's sign.
|
||||
switch {
|
||||
case k1 == intKind && k2 == uintKind:
|
||||
truth = arg1.Int() >= 0 && uint64(arg1.Int()) == arg.Uint()
|
||||
case k1 == uintKind && k2 == intKind:
|
||||
truth = arg.Int() >= 0 && arg1.Uint() == uint64(arg.Int())
|
||||
default:
|
||||
if arg1.IsValid() && arg.IsValid() {
|
||||
return false, fmt.Errorf("incompatible types for comparison: %v and %v", arg1.Type(), arg.Type())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
switch k1 {
|
||||
case boolKind:
|
||||
truth = arg1.Bool() == arg.Bool()
|
||||
case complexKind:
|
||||
truth = arg1.Complex() == arg.Complex()
|
||||
case floatKind:
|
||||
truth = arg1.Float() == arg.Float()
|
||||
case intKind:
|
||||
truth = arg1.Int() == arg.Int()
|
||||
case stringKind:
|
||||
truth = arg1.String() == arg.String()
|
||||
case uintKind:
|
||||
truth = arg1.Uint() == arg.Uint()
|
||||
default:
|
||||
if !canCompare(arg1, arg) {
|
||||
return false, fmt.Errorf("non-comparable types %s: %v, %s: %v", arg1, arg1.Type(), arg.Type(), arg)
|
||||
}
|
||||
if isNil(arg1) || isNil(arg) {
|
||||
truth = isNil(arg) == isNil(arg1)
|
||||
} else {
|
||||
if !arg.Type().Comparable() {
|
||||
return false, fmt.Errorf("non-comparable type %s: %v", arg, arg.Type())
|
||||
}
|
||||
truth = arg1.Interface() == arg.Interface()
|
||||
}
|
||||
}
|
||||
}
|
||||
if truth {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// ne evaluates the comparison a != b.
|
||||
func ne(arg1, arg2 reflect.Value) (bool, error) {
|
||||
// != is the inverse of ==.
|
||||
equal, err := eq(arg1, arg2)
|
||||
return !equal, err
|
||||
}
|
||||
|
||||
// lt evaluates the comparison a < b.
|
||||
func lt(arg1, arg2 reflect.Value) (bool, error) {
|
||||
arg1 = indirectInterface(arg1)
|
||||
k1, err := basicKind(arg1)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
arg2 = indirectInterface(arg2)
|
||||
k2, err := basicKind(arg2)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
truth := false
|
||||
if k1 != k2 {
|
||||
// Special case: Can compare integer values regardless of type's sign.
|
||||
switch {
|
||||
case k1 == intKind && k2 == uintKind:
|
||||
truth = arg1.Int() < 0 || uint64(arg1.Int()) < arg2.Uint()
|
||||
case k1 == uintKind && k2 == intKind:
|
||||
truth = arg2.Int() >= 0 && arg1.Uint() < uint64(arg2.Int())
|
||||
default:
|
||||
return false, fmt.Errorf("incompatible types for comparison: %v and %v", arg1.Type(), arg2.Type())
|
||||
}
|
||||
} else {
|
||||
switch k1 {
|
||||
case boolKind, complexKind:
|
||||
return false, errBadComparisonType
|
||||
case floatKind:
|
||||
truth = arg1.Float() < arg2.Float()
|
||||
case intKind:
|
||||
truth = arg1.Int() < arg2.Int()
|
||||
case stringKind:
|
||||
truth = arg1.String() < arg2.String()
|
||||
case uintKind:
|
||||
truth = arg1.Uint() < arg2.Uint()
|
||||
default:
|
||||
panic("invalid kind")
|
||||
}
|
||||
}
|
||||
return truth, nil
|
||||
}
|
||||
|
||||
// le evaluates the comparison <= b.
|
||||
func le(arg1, arg2 reflect.Value) (bool, error) {
|
||||
// <= is < or ==.
|
||||
lessThan, err := lt(arg1, arg2)
|
||||
if lessThan || err != nil {
|
||||
return lessThan, err
|
||||
}
|
||||
return eq(arg1, arg2)
|
||||
}
|
||||
|
||||
// gt evaluates the comparison a > b.
|
||||
func gt(arg1, arg2 reflect.Value) (bool, error) {
|
||||
// > is the inverse of <=.
|
||||
lessOrEqual, err := le(arg1, arg2)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return !lessOrEqual, nil
|
||||
}
|
||||
|
||||
// ge evaluates the comparison a >= b.
|
||||
func ge(arg1, arg2 reflect.Value) (bool, error) {
|
||||
// >= is the inverse of <.
|
||||
lessThan, err := lt(arg1, arg2)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return !lessThan, nil
|
||||
}
|
||||
|
||||
// HTML escaping.
|
||||
|
||||
var (
|
||||
htmlQuot = []byte(""") // shorter than """
|
||||
htmlApos = []byte("'") // shorter than "'" and apos was not in HTML until HTML5
|
||||
htmlAmp = []byte("&")
|
||||
htmlLt = []byte("<")
|
||||
htmlGt = []byte(">")
|
||||
htmlNull = []byte("\uFFFD")
|
||||
)
|
||||
|
||||
// HTMLEscape writes to w the escaped HTML equivalent of the plain text data b.
|
||||
func HTMLEscape(w io.Writer, b []byte) {
|
||||
last := 0
|
||||
for i, c := range b {
|
||||
var html []byte
|
||||
switch c {
|
||||
case '\000':
|
||||
html = htmlNull
|
||||
case '"':
|
||||
html = htmlQuot
|
||||
case '\'':
|
||||
html = htmlApos
|
||||
case '&':
|
||||
html = htmlAmp
|
||||
case '<':
|
||||
html = htmlLt
|
||||
case '>':
|
||||
html = htmlGt
|
||||
default:
|
||||
continue
|
||||
}
|
||||
w.Write(b[last:i])
|
||||
w.Write(html)
|
||||
last = i + 1
|
||||
}
|
||||
w.Write(b[last:])
|
||||
}
|
||||
|
||||
// HTMLEscapeString returns the escaped HTML equivalent of the plain text data s.
|
||||
func HTMLEscapeString(s string) string {
|
||||
// Avoid allocation if we can.
|
||||
if !strings.ContainsAny(s, "'\"&<>\000") {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
HTMLEscape(&b, []byte(s))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// HTMLEscaper returns the escaped HTML equivalent of the textual
|
||||
// representation of its arguments.
|
||||
func HTMLEscaper(args ...any) string {
|
||||
return HTMLEscapeString(evalArgs(args))
|
||||
}
|
||||
|
||||
// JavaScript escaping.
|
||||
|
||||
var (
|
||||
jsLowUni = []byte(`\u00`)
|
||||
hex = []byte("0123456789ABCDEF")
|
||||
|
||||
jsBackslash = []byte(`\\`)
|
||||
jsApos = []byte(`\'`)
|
||||
jsQuot = []byte(`\"`)
|
||||
jsLt = []byte(`\u003C`)
|
||||
jsGt = []byte(`\u003E`)
|
||||
jsAmp = []byte(`\u0026`)
|
||||
jsEq = []byte(`\u003D`)
|
||||
)
|
||||
|
||||
// JSEscape writes to w the escaped JavaScript equivalent of the plain text data b.
|
||||
func JSEscape(w io.Writer, b []byte) {
|
||||
last := 0
|
||||
for i := 0; i < len(b); i++ {
|
||||
c := b[i]
|
||||
|
||||
if !jsIsSpecial(rune(c)) {
|
||||
// fast path: nothing to do
|
||||
continue
|
||||
}
|
||||
w.Write(b[last:i])
|
||||
|
||||
if c < utf8.RuneSelf {
|
||||
// Quotes, slashes and angle brackets get quoted.
|
||||
// Control characters get written as \u00XX.
|
||||
switch c {
|
||||
case '\\':
|
||||
w.Write(jsBackslash)
|
||||
case '\'':
|
||||
w.Write(jsApos)
|
||||
case '"':
|
||||
w.Write(jsQuot)
|
||||
case '<':
|
||||
w.Write(jsLt)
|
||||
case '>':
|
||||
w.Write(jsGt)
|
||||
case '&':
|
||||
w.Write(jsAmp)
|
||||
case '=':
|
||||
w.Write(jsEq)
|
||||
default:
|
||||
w.Write(jsLowUni)
|
||||
t, b := c>>4, c&0x0f
|
||||
w.Write(hex[t : t+1])
|
||||
w.Write(hex[b : b+1])
|
||||
}
|
||||
} else {
|
||||
// Unicode rune.
|
||||
r, size := utf8.DecodeRune(b[i:])
|
||||
if unicode.IsPrint(r) {
|
||||
w.Write(b[i : i+size])
|
||||
} else {
|
||||
fmt.Fprintf(w, "\\u%04X", r)
|
||||
}
|
||||
i += size - 1
|
||||
}
|
||||
last = i + 1
|
||||
}
|
||||
w.Write(b[last:])
|
||||
}
|
||||
|
||||
// JSEscapeString returns the escaped JavaScript equivalent of the plain text data s.
|
||||
func JSEscapeString(s string) string {
|
||||
// Avoid allocation if we can.
|
||||
if strings.IndexFunc(s, jsIsSpecial) < 0 {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
JSEscape(&b, []byte(s))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func jsIsSpecial(r rune) bool {
|
||||
switch r {
|
||||
case '\\', '\'', '"', '<', '>', '&', '=':
|
||||
return true
|
||||
}
|
||||
return r < ' ' || utf8.RuneSelf <= r
|
||||
}
|
||||
|
||||
// JSEscaper returns the escaped JavaScript equivalent of the textual
|
||||
// representation of its arguments.
|
||||
func JSEscaper(args ...any) string {
|
||||
return JSEscapeString(evalArgs(args))
|
||||
}
|
||||
|
||||
// URLQueryEscaper returns the escaped value of the textual representation of
|
||||
// its arguments in a form suitable for embedding in a URL query.
|
||||
func URLQueryEscaper(args ...any) string {
|
||||
return url.QueryEscape(evalArgs(args))
|
||||
}
|
||||
|
||||
// evalArgs formats the list of arguments into a string. It is therefore equivalent to
|
||||
//
|
||||
// fmt.Sprint(args...)
|
||||
//
|
||||
// except that each argument is indirected (if a pointer), as required,
|
||||
// using the same rules as the default string evaluation during template
|
||||
// execution.
|
||||
func evalArgs(args []any) string {
|
||||
ok := false
|
||||
var s string
|
||||
// Fast path for simple common case.
|
||||
if len(args) == 1 {
|
||||
s, ok = args[0].(string)
|
||||
}
|
||||
if !ok {
|
||||
for i, arg := range args {
|
||||
a, ok := printableValue(reflect.ValueOf(arg))
|
||||
if ok {
|
||||
args[i] = a
|
||||
} // else let fmt do its thing
|
||||
}
|
||||
s = fmt.Sprint(args...)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Copyright 2011 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Helper functions to make constructing templates easier.
|
||||
|
||||
package gotext
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Functions and methods to parse templates.
|
||||
|
||||
// Must is a helper that wraps a call to a function returning ([*Template], error)
|
||||
// and panics if the error is non-nil. It is intended for use in variable
|
||||
// initializations such as
|
||||
//
|
||||
// var t = template.Must(template.New("name").Parse("text"))
|
||||
func Must(t *Template, err error) *Template {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// ParseFiles creates a new [Template] and parses the template definitions from
|
||||
// the named files. The returned template's name will have the base name and
|
||||
// parsed contents of the first file. There must be at least one file.
|
||||
// If an error occurs, parsing stops and the returned *Template is nil.
|
||||
//
|
||||
// When parsing multiple files with the same name in different directories,
|
||||
// the last one mentioned will be the one that results.
|
||||
// For instance, ParseFiles("a/foo", "b/foo") stores "b/foo" as the template
|
||||
// named "foo", while "a/foo" is unavailable.
|
||||
func ParseFiles(filenames ...string) (*Template, error) {
|
||||
return parseFiles(nil, readFileOS, filenames...)
|
||||
}
|
||||
|
||||
// ParseFiles parses the named files and associates the resulting templates with
|
||||
// t. If an error occurs, parsing stops and the returned template is nil;
|
||||
// otherwise it is t. There must be at least one file.
|
||||
// Since the templates created by ParseFiles are named by the base
|
||||
// (see [filepath.Base]) names of the argument files, t should usually have the
|
||||
// name of one of the (base) names of the files. If it does not, depending on
|
||||
// t's contents before calling ParseFiles, t.Execute may fail. In that
|
||||
// case use t.ExecuteTemplate to execute a valid template.
|
||||
//
|
||||
// When parsing multiple files with the same name in different directories,
|
||||
// the last one mentioned will be the one that results.
|
||||
func (t *Template) ParseFiles(filenames ...string) (*Template, error) {
|
||||
t.init()
|
||||
return parseFiles(t, readFileOS, filenames...)
|
||||
}
|
||||
|
||||
// parseFiles is the helper for the method and function. If the argument
|
||||
// template is nil, it is created from the first file.
|
||||
func parseFiles(t *Template, readFile func(string) (string, []byte, error), filenames ...string) (*Template, error) {
|
||||
if len(filenames) == 0 {
|
||||
// Not really a problem, but be consistent.
|
||||
return nil, fmt.Errorf("template: no files named in call to ParseFiles")
|
||||
}
|
||||
for _, filename := range filenames {
|
||||
name, b, err := readFile(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := string(b)
|
||||
// First template becomes return value if not already defined,
|
||||
// and we use that one for subsequent New calls to associate
|
||||
// all the templates together. Also, if this file has the same name
|
||||
// as t, this file becomes the contents of t, so
|
||||
// t, err := New(name).Funcs(xxx).ParseFiles(name)
|
||||
// works. Otherwise we create a new template associated with t.
|
||||
var tmpl *Template
|
||||
if t == nil {
|
||||
t = New(name)
|
||||
}
|
||||
if name == t.Name() {
|
||||
tmpl = t
|
||||
} else {
|
||||
tmpl = t.New(name)
|
||||
}
|
||||
_, err = tmpl.Parse(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// ParseGlob creates a new [Template] and parses the template definitions from
|
||||
// the files identified by the pattern. The files are matched according to the
|
||||
// semantics of [filepath.Match], and the pattern must match at least one file.
|
||||
// The returned template will have the [filepath.Base] name and (parsed)
|
||||
// contents of the first file matched by the pattern. ParseGlob is equivalent to
|
||||
// calling [ParseFiles] with the list of files matched by the pattern.
|
||||
//
|
||||
// When parsing multiple files with the same name in different directories,
|
||||
// the last one mentioned will be the one that results.
|
||||
func ParseGlob(pattern string) (*Template, error) {
|
||||
return parseGlob(nil, pattern)
|
||||
}
|
||||
|
||||
// ParseGlob parses the template definitions in the files identified by the
|
||||
// pattern and associates the resulting templates with t. The files are matched
|
||||
// according to the semantics of [filepath.Match], and the pattern must match at
|
||||
// least one file. ParseGlob is equivalent to calling [Template.ParseFiles] with
|
||||
// the list of files matched by the pattern.
|
||||
//
|
||||
// When parsing multiple files with the same name in different directories,
|
||||
// the last one mentioned will be the one that results.
|
||||
func (t *Template) ParseGlob(pattern string) (*Template, error) {
|
||||
t.init()
|
||||
return parseGlob(t, pattern)
|
||||
}
|
||||
|
||||
// parseGlob is the implementation of the function and method ParseGlob.
|
||||
func parseGlob(t *Template, pattern string) (*Template, error) {
|
||||
filenames, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(filenames) == 0 {
|
||||
return nil, fmt.Errorf("template: pattern matches no files: %#q", pattern)
|
||||
}
|
||||
return parseFiles(t, readFileOS, filenames...)
|
||||
}
|
||||
|
||||
// ParseFS is like [Template.ParseFiles] or [Template.ParseGlob] but reads from the file system fsys
|
||||
// instead of the host operating system's file system.
|
||||
// It accepts a list of glob patterns (see [path.Match]).
|
||||
// (Note that most file names serve as glob patterns matching only themselves.)
|
||||
func ParseFS(fsys fs.FS, patterns ...string) (*Template, error) {
|
||||
return parseFS(nil, fsys, patterns)
|
||||
}
|
||||
|
||||
// ParseFS is like [Template.ParseFiles] or [Template.ParseGlob] but reads from the file system fsys
|
||||
// instead of the host operating system's file system.
|
||||
// It accepts a list of glob patterns (see [path.Match]).
|
||||
// (Note that most file names serve as glob patterns matching only themselves.)
|
||||
func (t *Template) ParseFS(fsys fs.FS, patterns ...string) (*Template, error) {
|
||||
t.init()
|
||||
return parseFS(t, fsys, patterns)
|
||||
}
|
||||
|
||||
func parseFS(t *Template, fsys fs.FS, patterns []string) (*Template, error) {
|
||||
var filenames []string
|
||||
for _, pattern := range patterns {
|
||||
list, err := fs.Glob(fsys, pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return nil, fmt.Errorf("template: pattern matches no files: %#q", pattern)
|
||||
}
|
||||
filenames = append(filenames, list...)
|
||||
}
|
||||
return parseFiles(t, readFileFS(fsys), filenames...)
|
||||
}
|
||||
|
||||
func readFileOS(file string) (name string, b []byte, err error) {
|
||||
name = filepath.Base(file)
|
||||
b, err = os.ReadFile(file)
|
||||
return
|
||||
}
|
||||
|
||||
func readFileFS(fsys fs.FS) func(string) (string, []byte, error) {
|
||||
return func(file string) (name string, b []byte, err error) {
|
||||
name = path.Base(file)
|
||||
b, err = fs.ReadFile(fsys, file)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright 2015 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This file contains the code to handle template options.
|
||||
|
||||
package gotext
|
||||
|
||||
import "strings"
|
||||
|
||||
// missingKeyAction defines how to respond to indexing a map with a key that is not present.
|
||||
type missingKeyAction int
|
||||
|
||||
const (
|
||||
mapInvalid missingKeyAction = iota // Return an invalid reflect.Value.
|
||||
mapZeroValue // Return the zero value for the map element.
|
||||
mapError // Error out
|
||||
)
|
||||
|
||||
type option struct {
|
||||
missingKey missingKeyAction
|
||||
}
|
||||
|
||||
// Option sets options for the template. Options are described by
|
||||
// strings, either a simple string or "key=value". There can be at
|
||||
// most one equals sign in an option string. If the option string
|
||||
// is unrecognized or otherwise invalid, Option panics.
|
||||
//
|
||||
// Known options:
|
||||
//
|
||||
// missingkey: Control the behavior during execution if a map is
|
||||
// indexed with a key that is not present in the map.
|
||||
//
|
||||
// "missingkey=default" or "missingkey=invalid"
|
||||
// The default behavior: Do nothing and continue execution.
|
||||
// If printed, the result of the index operation is the string
|
||||
// "<no value>".
|
||||
// "missingkey=zero"
|
||||
// The operation returns the zero value for the map type's element.
|
||||
// "missingkey=error"
|
||||
// Execution stops immediately with an error.
|
||||
func (t *Template) Option(opt ...string) *Template {
|
||||
t.init()
|
||||
for _, s := range opt {
|
||||
t.setOption(s)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *Template) setOption(opt string) {
|
||||
if opt == "" {
|
||||
panic("empty option string")
|
||||
}
|
||||
// key=value
|
||||
if key, value, ok := strings.Cut(opt, "="); ok {
|
||||
switch key {
|
||||
case "missingkey":
|
||||
switch value {
|
||||
case "invalid", "default":
|
||||
t.option.missingKey = mapInvalid
|
||||
return
|
||||
case "zero":
|
||||
t.option.missingKey = mapZeroValue
|
||||
return
|
||||
case "error":
|
||||
t.option.missingKey = mapError
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
panic("unrecognized option: " + opt)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
--- a/exec.go 2026-07-10 01:31:35.188129862 +0200
|
||||
+++ b/exec.go 2026-07-10 01:31:35.189129894 +0200
|
||||
@@ -5,14 +5,17 @@
|
||||
package gotext
|
||||
|
||||
import (
|
||||
+ "context"
|
||||
"errors"
|
||||
"fmt"
|
||||
- "heckel.io/ntfy/v2/template/gotext/fmtsort"
|
||||
"io"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
+ "sync/atomic"
|
||||
"text/template/parse"
|
||||
+
|
||||
+ "heckel.io/ntfy/v2/template/gotext/fmtsort"
|
||||
)
|
||||
|
||||
// maxExecDepth specifies the maximum stack depth of templates within
|
||||
@@ -32,11 +35,13 @@
|
||||
// template so that multiple executions of the same template
|
||||
// can execute in parallel.
|
||||
type state struct {
|
||||
- tmpl *Template
|
||||
- wr io.Writer
|
||||
- node parse.Node // current node, for errors
|
||||
- vars []variable // push-down stack of variable values.
|
||||
- depth int // the height of the stack of executing templates.
|
||||
+ tmpl *Template
|
||||
+ ctx context.Context // ctx-ex: execution context; Execute uses context.Background.
|
||||
+ wr io.Writer
|
||||
+ node parse.Node // current node, for errors
|
||||
+ vars []variable // push-down stack of variable values.
|
||||
+ depth int // the height of the stack of executing templates.
|
||||
+ cancelled *atomic.Bool // ctx-ex: shared flag set by the context.AfterFunc watcher; nil if ctx cannot be canceled
|
||||
}
|
||||
|
||||
// variable holds the dynamic value of a variable such as $, $x etc.
|
||||
@@ -160,6 +165,14 @@
|
||||
})
|
||||
}
|
||||
|
||||
+// cancelError is the wrapper type used internally when execution is aborted
|
||||
+// because the context is done. Like writeError, it is stripped in errRecover
|
||||
+// so the caller receives the original ctx.Err(). It is not an implementation
|
||||
+// of error, so it cannot escape from the package as an error value.
|
||||
+type cancelError struct {
|
||||
+ Err error // Original context error.
|
||||
+}
|
||||
+
|
||||
// errRecover is the handler that turns panics into returns from the top
|
||||
// level of Parse.
|
||||
func errRecover(errp *error) {
|
||||
@@ -170,6 +183,8 @@
|
||||
panic(e)
|
||||
case writeError:
|
||||
*errp = err.Err // Strip the wrapper.
|
||||
+ case cancelError:
|
||||
+ *errp = err.Err // Strip the wrapper; return the context error.
|
||||
case ExecError:
|
||||
*errp = err // Keep the wrapper.
|
||||
default:
|
||||
@@ -186,11 +201,19 @@
|
||||
// A template may be executed safely in parallel, although if parallel
|
||||
// executions share a Writer the output may be interleaved.
|
||||
func (t *Template) ExecuteTemplate(wr io.Writer, name string, data any) error {
|
||||
+ return t.ExecuteTemplateContext(context.Background(), wr, name, data)
|
||||
+}
|
||||
+
|
||||
+// ExecuteTemplateContext is like [Template.ExecuteTemplate], but aborts and
|
||||
+// returns ctx.Err() if ctx is canceled or its deadline is exceeded before
|
||||
+// execution completes. See [Template.ExecuteContext] for the cancellation
|
||||
+// semantics.
|
||||
+func (t *Template) ExecuteTemplateContext(ctx context.Context, wr io.Writer, name string, data any) error {
|
||||
tmpl := t.Lookup(name)
|
||||
if tmpl == nil {
|
||||
return fmt.Errorf("template: no template %q associated with template %q", name, t.name)
|
||||
}
|
||||
- return tmpl.Execute(wr, data)
|
||||
+ return tmpl.ExecuteContext(ctx, wr, data)
|
||||
}
|
||||
|
||||
// Execute applies a parsed template to the specified data object,
|
||||
@@ -204,10 +227,27 @@
|
||||
// If data is a [reflect.Value], the template applies to the concrete
|
||||
// value that the reflect.Value holds, as in [fmt.Print].
|
||||
func (t *Template) Execute(wr io.Writer, data any) error {
|
||||
- return t.execute(wr, data)
|
||||
+ return t.executeContext(context.Background(), wr, data)
|
||||
}
|
||||
|
||||
-func (t *Template) execute(wr io.Writer, data any) (err error) {
|
||||
+// ExecuteContext is like [Template.Execute], but aborts and returns ctx.Err()
|
||||
+// (either [context.Canceled] or [context.DeadlineExceeded], retrievable with
|
||||
+// [errors.Is]) if ctx is canceled or its deadline is exceeded before execution
|
||||
+// completes.
|
||||
+//
|
||||
+// Cancellation is observed between node evaluations as the template is walked,
|
||||
+// so long-running renders -- including tight or nested {{range}} loops that
|
||||
+// write no output -- are aborted promptly. A template blocked inside a single
|
||||
+// function call is not interrupted until that call returns. Partial results may
|
||||
+// already have been written to wr.
|
||||
+func (t *Template) ExecuteContext(ctx context.Context, wr io.Writer, data any) error {
|
||||
+ if err := ctx.Err(); err != nil {
|
||||
+ return err
|
||||
+ }
|
||||
+ return t.executeContext(ctx, wr, data)
|
||||
+}
|
||||
+
|
||||
+func (t *Template) executeContext(ctx context.Context, wr io.Writer, data any) (err error) {
|
||||
defer errRecover(&err)
|
||||
value, ok := data.(reflect.Value)
|
||||
if !ok {
|
||||
@@ -215,9 +255,20 @@
|
||||
}
|
||||
state := &state{
|
||||
tmpl: t,
|
||||
+ ctx: ctx,
|
||||
wr: wr,
|
||||
vars: []variable{{"$", value}},
|
||||
}
|
||||
+ // If the context can be canceled, watch it with a single context.AfterFunc
|
||||
+ // callback that flips an atomic flag; walk polls that flag per node (a cheap
|
||||
+ // monomorphic atomic load) instead of calling ctx.Err() every node.
|
||||
+ // Contexts that can never be canceled (Background, TODO) have a nil Done
|
||||
+ // channel, so the default Execute path installs nothing and pays nothing.
|
||||
+ if ctx.Done() != nil {
|
||||
+ state.cancelled = new(atomic.Bool)
|
||||
+ stop := context.AfterFunc(ctx, func() { state.cancelled.Store(true) })
|
||||
+ defer stop()
|
||||
+ }
|
||||
if t.Tree == nil || t.Root == nil {
|
||||
state.errorf("%q is an incomplete or empty template", t.Name())
|
||||
}
|
||||
@@ -260,6 +311,12 @@
|
||||
// generating output as they go.
|
||||
func (s *state) walk(dot reflect.Value, node parse.Node) {
|
||||
s.at(node)
|
||||
+ // Abort if the context has been canceled or its deadline has passed. The
|
||||
+ // flag is set by the watcher installed in executeContext; observing it here
|
||||
+ // interrupts any template shape, including loops that write no output.
|
||||
+ if s.cancelled != nil && s.cancelled.Load() {
|
||||
+ panic(cancelError{s.ctx.Err()})
|
||||
+ }
|
||||
switch node := node.(type) {
|
||||
case *parse.ActionNode:
|
||||
// Do not pop variables so they persist until next end.
|
||||
@@ -0,0 +1,236 @@
|
||||
// Copyright 2011 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package gotext
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"reflect"
|
||||
"sync"
|
||||
"text/template/parse"
|
||||
)
|
||||
|
||||
// common holds the information shared by related templates.
|
||||
type common struct {
|
||||
tmpl map[string]*Template // Map from name to defined templates.
|
||||
muTmpl sync.RWMutex // protects tmpl
|
||||
option option
|
||||
// We use two maps, one for parsing and one for execution.
|
||||
// This separation makes the API cleaner since it doesn't
|
||||
// expose reflection to the client.
|
||||
muFuncs sync.RWMutex // protects parseFuncs and execFuncs
|
||||
parseFuncs FuncMap
|
||||
execFuncs map[string]reflect.Value
|
||||
}
|
||||
|
||||
// Template is the representation of a parsed template. The *parse.Tree
|
||||
// field is exported only for use by [html/template] and should be treated
|
||||
// as unexported by all other clients.
|
||||
type Template struct {
|
||||
name string
|
||||
*parse.Tree
|
||||
*common
|
||||
leftDelim string
|
||||
rightDelim string
|
||||
}
|
||||
|
||||
// New allocates a new, undefined template with the given name.
|
||||
func New(name string) *Template {
|
||||
t := &Template{
|
||||
name: name,
|
||||
}
|
||||
t.init()
|
||||
return t
|
||||
}
|
||||
|
||||
// Name returns the name of the template.
|
||||
func (t *Template) Name() string {
|
||||
return t.name
|
||||
}
|
||||
|
||||
// New allocates a new, undefined template associated with the given one and with the same
|
||||
// delimiters. The association, which is transitive, allows one template to
|
||||
// invoke another with a {{template}} action.
|
||||
//
|
||||
// Because associated templates share underlying data, template construction
|
||||
// cannot be done safely in parallel. Once the templates are constructed, they
|
||||
// can be executed in parallel.
|
||||
func (t *Template) New(name string) *Template {
|
||||
t.init()
|
||||
nt := &Template{
|
||||
name: name,
|
||||
common: t.common,
|
||||
leftDelim: t.leftDelim,
|
||||
rightDelim: t.rightDelim,
|
||||
}
|
||||
return nt
|
||||
}
|
||||
|
||||
// init guarantees that t has a valid common structure.
|
||||
func (t *Template) init() {
|
||||
if t.common == nil {
|
||||
c := new(common)
|
||||
c.tmpl = make(map[string]*Template)
|
||||
c.parseFuncs = make(FuncMap)
|
||||
c.execFuncs = make(map[string]reflect.Value)
|
||||
t.common = c
|
||||
}
|
||||
}
|
||||
|
||||
// Clone returns a duplicate of the template, including all associated
|
||||
// templates. The actual representation is not copied, but the name space of
|
||||
// associated templates is, so further calls to [Template.Parse] in the copy will add
|
||||
// templates to the copy but not to the original. Clone can be used to prepare
|
||||
// common templates and use them with variant definitions for other templates
|
||||
// by adding the variants after the clone is made.
|
||||
func (t *Template) Clone() (*Template, error) {
|
||||
nt := t.copy(nil)
|
||||
nt.init()
|
||||
if t.common == nil {
|
||||
return nt, nil
|
||||
}
|
||||
nt.option = t.option
|
||||
t.muTmpl.RLock()
|
||||
defer t.muTmpl.RUnlock()
|
||||
for k, v := range t.tmpl {
|
||||
if k == t.name {
|
||||
nt.tmpl[t.name] = nt
|
||||
continue
|
||||
}
|
||||
// The associated templates share nt's common structure.
|
||||
tmpl := v.copy(nt.common)
|
||||
nt.tmpl[k] = tmpl
|
||||
}
|
||||
t.muFuncs.RLock()
|
||||
defer t.muFuncs.RUnlock()
|
||||
maps.Copy(nt.parseFuncs, t.parseFuncs)
|
||||
maps.Copy(nt.execFuncs, t.execFuncs)
|
||||
return nt, nil
|
||||
}
|
||||
|
||||
// copy returns a shallow copy of t, with common set to the argument.
|
||||
func (t *Template) copy(c *common) *Template {
|
||||
return &Template{
|
||||
name: t.name,
|
||||
Tree: t.Tree,
|
||||
common: c,
|
||||
leftDelim: t.leftDelim,
|
||||
rightDelim: t.rightDelim,
|
||||
}
|
||||
}
|
||||
|
||||
// AddParseTree associates the argument parse tree with the template t, giving
|
||||
// it the specified name. If the template has not been defined, this tree becomes
|
||||
// its definition. If it has been defined and already has that name, the existing
|
||||
// definition is replaced; otherwise a new template is created, defined, and returned.
|
||||
func (t *Template) AddParseTree(name string, tree *parse.Tree) (*Template, error) {
|
||||
t.init()
|
||||
t.muTmpl.Lock()
|
||||
defer t.muTmpl.Unlock()
|
||||
nt := t
|
||||
if name != t.name {
|
||||
nt = t.New(name)
|
||||
}
|
||||
// Even if nt == t, we need to install it in the common.tmpl map.
|
||||
if t.associate(nt, tree) || nt.Tree == nil {
|
||||
nt.Tree = tree
|
||||
}
|
||||
return nt, nil
|
||||
}
|
||||
|
||||
// Templates returns a slice of defined templates associated with t.
|
||||
func (t *Template) Templates() []*Template {
|
||||
if t.common == nil {
|
||||
return nil
|
||||
}
|
||||
// Return a slice so we don't expose the map.
|
||||
t.muTmpl.RLock()
|
||||
defer t.muTmpl.RUnlock()
|
||||
m := make([]*Template, 0, len(t.tmpl))
|
||||
for _, v := range t.tmpl {
|
||||
m = append(m, v)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Delims sets the action delimiters to the specified strings, to be used in
|
||||
// subsequent calls to [Template.Parse], [Template.ParseFiles], or [Template.ParseGlob]. Nested template
|
||||
// definitions will inherit the settings. An empty delimiter stands for the
|
||||
// corresponding default: {{ or }}.
|
||||
// The return value is the template, so calls can be chained.
|
||||
func (t *Template) Delims(left, right string) *Template {
|
||||
t.init()
|
||||
t.leftDelim = left
|
||||
t.rightDelim = right
|
||||
return t
|
||||
}
|
||||
|
||||
// Funcs adds the elements of the argument map to the template's function map.
|
||||
// It must be called before the template is parsed.
|
||||
// It panics if a value in the map is not a function with appropriate return
|
||||
// type or if the name cannot be used syntactically as a function in a template.
|
||||
// It is legal to overwrite elements of the map. The return value is the template,
|
||||
// so calls can be chained.
|
||||
func (t *Template) Funcs(funcMap FuncMap) *Template {
|
||||
t.init()
|
||||
t.muFuncs.Lock()
|
||||
defer t.muFuncs.Unlock()
|
||||
addValueFuncs(t.execFuncs, funcMap)
|
||||
addFuncs(t.parseFuncs, funcMap)
|
||||
return t
|
||||
}
|
||||
|
||||
// Lookup returns the template with the given name that is associated with t.
|
||||
// It returns nil if there is no such template or the template has no definition.
|
||||
func (t *Template) Lookup(name string) *Template {
|
||||
if t.common == nil {
|
||||
return nil
|
||||
}
|
||||
t.muTmpl.RLock()
|
||||
defer t.muTmpl.RUnlock()
|
||||
return t.tmpl[name]
|
||||
}
|
||||
|
||||
// Parse parses text as a template body for t.
|
||||
// Named template definitions ({{define ...}} or {{block ...}} statements) in text
|
||||
// define additional templates associated with t and are removed from the
|
||||
// definition of t itself.
|
||||
//
|
||||
// Templates can be redefined in successive calls to Parse.
|
||||
// A template definition with a body containing only white space and comments
|
||||
// is considered empty and will not replace an existing template's body.
|
||||
// This allows using Parse to add new named template definitions without
|
||||
// overwriting the main template body.
|
||||
func (t *Template) Parse(text string) (*Template, error) {
|
||||
t.init()
|
||||
t.muFuncs.RLock()
|
||||
trees, err := parse.Parse(t.name, text, t.leftDelim, t.rightDelim, t.parseFuncs, builtins())
|
||||
t.muFuncs.RUnlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Add the newly parsed trees, including the one for t, into our common structure.
|
||||
for name, tree := range trees {
|
||||
if _, err := t.AddParseTree(name, tree); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// associate installs the new template into the group of templates associated
|
||||
// with t. The two are already known to share the common structure.
|
||||
// The boolean return value reports whether to store this tree as t.Tree.
|
||||
func (t *Template) associate(new *Template, tree *parse.Tree) bool {
|
||||
if new.common != t.common {
|
||||
panic("internal error: associate not common")
|
||||
}
|
||||
if old := t.tmpl[new.name]; old != nil && parse.IsEmptyTree(tree.Root) && old.Tree != nil {
|
||||
// If a template by that name exists,
|
||||
// don't replace it with an empty template.
|
||||
return false
|
||||
}
|
||||
t.tmpl[new.name] = new
|
||||
return true
|
||||
}
|
||||
+325
-16
@@ -2,6 +2,7 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -40,6 +41,7 @@ const (
|
||||
DefaultUserPasswordBcryptCost = 10
|
||||
DefaultAccessCacheEnabled = false
|
||||
DefaultAccessCacheReloadInterval = 87 * time.Second
|
||||
DefaultExpiredMagicLinkReapInterval = time.Hour // How often expired email-verify/password-reset links are swept
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -72,6 +74,9 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) {
|
||||
if config.AccessCacheReloadInterval <= 0 {
|
||||
config.AccessCacheReloadInterval = DefaultAccessCacheReloadInterval
|
||||
}
|
||||
if config.ExpiredMagicLinkReapInterval <= 0 {
|
||||
config.ExpiredMagicLinkReapInterval = DefaultExpiredMagicLinkReapInterval
|
||||
}
|
||||
manager := &Manager{
|
||||
config: config,
|
||||
db: d,
|
||||
@@ -91,6 +96,7 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) {
|
||||
go manager.asyncAccessCacheReloadLoop(manager.config.AccessCacheReloadInterval)
|
||||
}
|
||||
go manager.asyncQueueWriteLoop(manager.config.QueueWriterInterval)
|
||||
go manager.asyncExpiredMagicLinkReapLoop(manager.config.ExpiredMagicLinkReapInterval)
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
@@ -128,6 +134,25 @@ func (a *Manager) asyncAccessCacheReloadLoop(interval time.Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
// asyncExpiredMagicLinkReapLoop periodically deletes expired email-verification and
|
||||
// password-reset links so the user_magic_link table does not accumulate dead rows. Expiry is
|
||||
// already enforced on read, so this is housekeeping only; it replaces the old in-memory
|
||||
// expireLoop that lived in mail.Sender.
|
||||
func (a *Manager) asyncExpiredMagicLinkReapLoop(interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-a.quit:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := a.deleteExpiredMagicLinks(); err != nil {
|
||||
log.Tag(tag).Err(err).Warn("Reaping expired magic links failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate checks username and password and returns a User if correct, and the user has not been
|
||||
// marked as deleted. The method returns in constant-ish time, regardless of whether the user exists or
|
||||
// the password is correct or incorrect.
|
||||
@@ -494,6 +519,19 @@ func (a *Manager) UserByID(id string) (*User, error) {
|
||||
return a.readUser(rows)
|
||||
}
|
||||
|
||||
// UserByEmailOrUsername resolves an identifier to a single user, trying it first as a primary
|
||||
// email address and then as a username. A verified, owned email takes precedence over a
|
||||
// freely-chosen username, so a look-alike username cannot shadow the email's real owner. Returns
|
||||
// ErrUserNotFound if neither matches.
|
||||
func (a *Manager) UserByEmailOrUsername(identifier string) (*User, error) {
|
||||
if userID, err := a.UserIDByPrimaryEmail(identifier); err == nil {
|
||||
if u, err := a.UserByID(userID); err == nil {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
return a.User(identifier)
|
||||
}
|
||||
|
||||
// userByToken returns the user with the given token if it exists and is not expired, or ErrUserNotFound otherwise
|
||||
func (a *Manager) userByToken(token string) (*User, error) {
|
||||
rows, err := a.db.Query(a.queries.selectUserByToken, token, time.Now().Unix())
|
||||
@@ -630,7 +668,7 @@ func (a *Manager) maybeHashPassword(password string, hashed bool) (string, error
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
return hashPassword(password, a.config.BcryptCost)
|
||||
return HashPassword(password, a.config.BcryptCost)
|
||||
}
|
||||
|
||||
// Authorize returns nil if the given user has access to the given topic using the desired
|
||||
@@ -639,6 +677,13 @@ func (a *Manager) Authorize(user *User, topic string, perm Permission) error {
|
||||
if user != nil && user.Role == RoleAdmin {
|
||||
return nil // Admin can do everything
|
||||
}
|
||||
// A user always has full access to their own sync topic, which the apps use
|
||||
// to sync subscriptions/settings across devices. Without this, an
|
||||
// auth-default-access of "deny-all" locks the user out of their own sync
|
||||
// topic (no ACL entry is created for it at user creation). See #733.
|
||||
if user != nil && user.SyncTopic != "" && subtle.ConstantTimeCompare([]byte(topic), []byte(user.SyncTopic)) == 1 {
|
||||
return nil
|
||||
}
|
||||
username := Everyone
|
||||
if user != nil {
|
||||
username = user.Name
|
||||
@@ -896,7 +941,9 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error {
|
||||
|
||||
// Reservations returns all user-owned topics, and the associated everyone-access
|
||||
func (a *Manager) Reservations(username string) ([]Reservation, error) {
|
||||
return a.reservationsTx(a.db.ReadOnly(), username)
|
||||
// Read from the primary, not a replica: this backs GET /account, which the web app refetches
|
||||
// immediately after a sync event. Replication lag would otherwise show stale data.
|
||||
return a.reservationsTx(a.db, username)
|
||||
}
|
||||
|
||||
func (a *Manager) reservationsTx(tx db.Querier, username string) ([]Reservation, error) {
|
||||
@@ -1159,7 +1206,8 @@ func (a *Manager) Token(userID, token string) (*Token, error) {
|
||||
|
||||
// Tokens returns all existing tokens for the user with the given user ID
|
||||
func (a *Manager) Tokens(userID string) ([]*Token, error) {
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectTokens, userID)
|
||||
// Primary read: backs GET /account (read-your-writes after a sync event).
|
||||
rows, err := a.db.Query(a.queries.selectTokens, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1372,7 +1420,8 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
|
||||
|
||||
// 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.ReadOnly().Query(a.queries.selectPhoneNumbers, userID)
|
||||
// Primary read: backs GET /account (read-your-writes after a sync event).
|
||||
rows, err := a.db.Query(a.queries.selectPhoneNumbers, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1420,14 +1469,17 @@ func (a *Manager) readPhoneNumber(rows *sql.Rows) (string, error) {
|
||||
return phoneNumber, nil
|
||||
}
|
||||
|
||||
// Emails returns all verified email addresses for the user with the given user ID
|
||||
func (a *Manager) Emails(userID string) ([]string, error) {
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectEmails, userID)
|
||||
// Emails returns all verified email addresses for the user with the given user ID, each carrying
|
||||
// whether it is the primary (recovery) address. Because the primary flag is included, callers that
|
||||
// need it (e.g. the account view) do not need a separate PrimaryEmail call.
|
||||
func (a *Manager) Emails(userID string) (Emails, error) {
|
||||
// Primary read: backs GET /account (read-your-writes after a sync event).
|
||||
rows, err := a.db.Query(a.queries.selectEmails, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
emails := make([]string, 0)
|
||||
emails := make(Emails, 0)
|
||||
for {
|
||||
email, err := a.readEmail(rows)
|
||||
if errors.Is(err, ErrEmailNotFound) {
|
||||
@@ -1451,23 +1503,280 @@ func (a *Manager) AddEmail(userID, email string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveEmail deletes a verified email address from the user with the given user ID
|
||||
// RemoveEmail deletes a verified email address from the user with the given user ID.
|
||||
// Removing the primary email leaves the account with no primary -- there is deliberately
|
||||
// no auto-promotion of another verified address; the user is nudged to pick a new one.
|
||||
func (a *Manager) RemoveEmail(userID, email string) error {
|
||||
_, err := a.db.Exec(a.queries.deleteEmail, userID, email)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Manager) readEmail(rows *sql.Rows) (string, error) {
|
||||
var email string
|
||||
// PrimaryEmail returns the user's primary (recovery) email address, or an empty string if
|
||||
// the user has not designated one.
|
||||
func (a *Manager) PrimaryEmail(userID string) (string, error) {
|
||||
var email sql.NullString
|
||||
// Primary read: backs GET /account (read-your-writes after a sync event).
|
||||
err := a.db.QueryRow(a.queries.selectPrimaryEmail, userID).Scan(&email)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return email.String, nil
|
||||
}
|
||||
|
||||
// UserIDByPrimaryEmail returns the ID of the (at most one) account for which the given address
|
||||
// is the primary email. Returns ErrUserNotFound if no account claims it as primary. Used by the
|
||||
// password-reset request flow to resolve an email identifier to a single account.
|
||||
func (a *Manager) UserIDByPrimaryEmail(email string) (string, error) {
|
||||
var userID string
|
||||
err := a.db.ReadOnly().QueryRow(a.queries.selectUserIDByPrimary, email).Scan(&userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrUserNotFound
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// PendingEmails returns the user's unverified (pending) email addresses, i.e. addresses with
|
||||
// an outstanding email-verification magic link.
|
||||
func (a *Manager) PendingEmails(userID string) ([]string, error) {
|
||||
// Primary read: backs GET /account (read-your-writes after a sync event).
|
||||
rows, err := a.db.Query(a.queries.selectPendingEmails, string(MagicLinkKindEmailVerify), userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
emails := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var email string
|
||||
if err := rows.Scan(&email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
emails = append(emails, email)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return emails, nil
|
||||
}
|
||||
|
||||
// SetPrimaryEmail marks a verified email address as the user's primary (recovery) email,
|
||||
// clearing any previous primary in the same transaction. Returns ErrEmailNotFound if the
|
||||
// address is not verified on the account, or ErrEmailPrimaryElsewhere if it is already the
|
||||
// primary email on another account (enforced by the global partial unique index).
|
||||
func (a *Manager) SetPrimaryEmail(userID, email string) error {
|
||||
return db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(a.queries.updateEmailClearPrimary, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := tx.Exec(a.queries.updateEmailSetPrimary, userID, email)
|
||||
if err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return ErrEmailPrimaryElsewhere
|
||||
}
|
||||
return err
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrEmailNotFound // Address not verified on this account
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// AddMagicLink generates a fresh magic-link token of the given kind, stores it (hashed, replacing
|
||||
// any existing link in the same scope), and returns the RAW token for use in the emailed link.
|
||||
// Only the hash is persisted; the raw token is never stored. email is the address being verified
|
||||
// for email_verify, and "" for password_reset.
|
||||
//
|
||||
// The scope replaced is, for email_verify, the (user_id, email) pair (one pending verification per
|
||||
// address); for password_reset, the user_id (one active reset per account). The replace-delete and
|
||||
// the insert run in one transaction so a re-request atomically supersedes the old token.
|
||||
func (a *Manager) AddMagicLink(kind MagicLinkKind, userID, email string, ttl time.Duration) (string, error) {
|
||||
token := generateLinkToken()
|
||||
now := time.Now()
|
||||
m := &MagicLink{
|
||||
TokenHash: hashToken(token),
|
||||
Kind: kind,
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
Expires: now.Add(ttl).Unix(),
|
||||
Created: now.Unix(),
|
||||
}
|
||||
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||
switch m.Kind {
|
||||
case MagicLinkKindEmailVerify:
|
||||
if _, err := tx.Exec(a.queries.deleteMagicLinkEmailVerify, string(MagicLinkKindEmailVerify), m.UserID, m.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
case MagicLinkKindPasswordReset:
|
||||
if _, err := tx.Exec(a.queries.deleteMagicLinkResetPassword, string(MagicLinkKindPasswordReset), m.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return ErrInvalidArgument
|
||||
}
|
||||
if _, err := tx.Exec(a.queries.insertMagicLink, m.TokenHash, string(m.Kind), m.UserID, nullString(m.Email), m.Expires, m.Created); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// MagicLinkByToken looks up a magic link by its raw token (hashing it first). See MagicLinkByHash.
|
||||
func (a *Manager) MagicLinkByToken(rawToken string) (*MagicLink, error) {
|
||||
return a.MagicLinkByHash(hashToken(rawToken))
|
||||
}
|
||||
|
||||
// MagicLinkByHash looks up a magic link by the hex SHA-256 of its raw token, returning
|
||||
// ErrMagicLinkNotFound if none exists. Callers must assert the returned Kind matches the flow
|
||||
// they serve and check Expires themselves.
|
||||
func (a *Manager) MagicLinkByHash(tokenHash string) (*MagicLink, error) {
|
||||
var m MagicLink
|
||||
var kind string
|
||||
var email sql.NullString
|
||||
err := a.db.ReadOnly().QueryRow(a.queries.selectMagicLinkByHash, tokenHash).Scan(&m.TokenHash, &kind, &m.UserID, &email, &m.Expires, &m.Created)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrMagicLinkNotFound
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Kind = MagicLinkKind(kind)
|
||||
m.Email = email.String
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// DeleteMagicLinkByToken deletes a magic link identified by its raw token (single-use consume).
|
||||
// Used to enforce single use after a reset is performed (email verification deletes the row
|
||||
// inside VerifyEmail's transaction).
|
||||
func (a *Manager) DeleteMagicLinkByToken(rawToken string) error {
|
||||
_, err := a.db.Exec(a.queries.deleteMagicLinkByHash, hashToken(rawToken))
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteEmailVerification removes any pending email verification for (userID, email). Used when
|
||||
// an unverified (pending) address is cancelled/deleted from the account.
|
||||
func (a *Manager) DeleteEmailVerification(userID, email string) error {
|
||||
_, err := a.db.Exec(a.queries.deleteMagicLinkEmailVerify, string(MagicLinkKindEmailVerify), userID, email)
|
||||
return err
|
||||
}
|
||||
|
||||
// VerifyEmail consumes an email-verification magic link, identified by its raw token: after
|
||||
// validating the token (kind + expiry), it deletes the link, adds the address to the user's
|
||||
// verified emails, and -- if the user has no primary email yet and the address is not already
|
||||
// primary on another account -- promotes the new address to primary. All mutations run in one
|
||||
// transaction. A primary collision simply leaves the address verified but non-primary. Provisioned
|
||||
// users never get a primary (the recovery email is meaningless for them -- they can't reset).
|
||||
// Returns the consumed link.
|
||||
func (a *Manager) VerifyEmail(rawToken string) (*MagicLink, error) {
|
||||
tokenHash := hashToken(rawToken)
|
||||
m, err := a.MagicLinkByHash(tokenHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.Kind != MagicLinkKindEmailVerify || time.Now().Unix() > m.Expires {
|
||||
return nil, ErrMagicLinkNotFound
|
||||
}
|
||||
err = db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||
// Single use: delete the link, then add the (idempotent) verified address
|
||||
if _, err := tx.Exec(a.queries.deleteMagicLinkByHash, tokenHash); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(a.queries.insertEmailIgnore, m.UserID, m.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
// Promote to primary only if the user has none yet and the address is globally free.
|
||||
// We check with SELECTs rather than catching a unique violation, because Postgres aborts
|
||||
// the whole transaction on any constraint error (which would undo the verified-email add).
|
||||
var primary sql.NullString
|
||||
err := tx.QueryRow(a.queries.selectPrimaryEmail, m.UserID).Scan(&primary)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
if primary.String != "" {
|
||||
return nil // User already has a primary -- leave it
|
||||
}
|
||||
// If the address is already another account's primary, leave it a verified secondary here
|
||||
var ownerUserID string
|
||||
if err = tx.QueryRow(a.queries.selectUserIDByPrimary, m.Email).Scan(&ownerUserID); err == nil {
|
||||
return nil // Address is primary elsewhere -> not promoted
|
||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err // Real query error
|
||||
}
|
||||
// Address is globally free -> promote it to this user's primary
|
||||
if _, err := tx.Exec(a.queries.updateEmailSetPrimary, m.UserID, m.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ResetPassword consumes a password-reset magic link, identified by its raw token: after
|
||||
// validating the token (kind + expiry), it sets the user's password and deletes the link in one
|
||||
// transaction. Existing access tokens are intentionally left valid (only the password changes).
|
||||
// Returns ErrMagicLinkNotFound if the token is invalid, expired, or not a reset token.
|
||||
func (a *Manager) ResetPassword(rawToken, newPassword string) error {
|
||||
m, err := a.MagicLinkByHash(hashToken(rawToken))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Kind != MagicLinkKindPasswordReset || time.Now().Unix() > m.Expires {
|
||||
return ErrMagicLinkNotFound
|
||||
}
|
||||
u, err := a.UserByID(m.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.Provisioned {
|
||||
return ErrProvisionedUserChange // Provisioned users get their password from the config file, not reset
|
||||
}
|
||||
hash, err := HashPassword(newPassword, a.config.BcryptCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||
if err := a.changePasswordHashTx(tx, u.Name, hash); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(a.queries.deleteMagicLinkByHash, m.TokenHash); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// deleteExpiredMagicLinks removes magic links whose expiry has passed. Expiry is also enforced
|
||||
// on read, so this is purely housekeeping to bound table growth; it runs from the reaper loop.
|
||||
func (a *Manager) deleteExpiredMagicLinks() error {
|
||||
_, err := a.db.Exec(a.queries.deleteExpiredMagicLinks, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Manager) readEmail(rows *sql.Rows) (*Email, error) {
|
||||
var address string
|
||||
var primary bool
|
||||
if !rows.Next() {
|
||||
return "", ErrEmailNotFound
|
||||
return nil, ErrEmailNotFound
|
||||
}
|
||||
if err := rows.Scan(&email); err != nil {
|
||||
return "", err
|
||||
if err := rows.Scan(&address, &primary); err != nil {
|
||||
return nil, err
|
||||
} else if err := rows.Err(); err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
return email, nil
|
||||
return &Email{Address: address, Primary: primary}, nil
|
||||
}
|
||||
|
||||
// ChangeBilling updates a user's billing fields
|
||||
|
||||
@@ -217,9 +217,23 @@ const (
|
||||
postgresDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = $1 AND phone_number = $2`
|
||||
|
||||
// Email queries
|
||||
postgresSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = $1 ORDER BY email`
|
||||
postgresInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2)`
|
||||
postgresDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = $1 AND email = $2`
|
||||
postgresSelectEmailsQuery = `SELECT email, is_primary FROM user_email WHERE user_id = $1 ORDER BY email`
|
||||
postgresInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2)`
|
||||
postgresInsertEmailIgnoreQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2) ON CONFLICT (user_id, email) DO NOTHING`
|
||||
postgresDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = $1 AND email = $2`
|
||||
postgresSelectPrimaryEmailQuery = `SELECT email FROM user_email WHERE user_id = $1 AND is_primary`
|
||||
postgresSelectUserIDByPrimaryQuery = `SELECT user_id FROM user_email WHERE email = $1 AND is_primary`
|
||||
postgresUpdateEmailSetPrimaryQuery = `UPDATE user_email SET is_primary = TRUE WHERE user_id = $1 AND email = $2`
|
||||
postgresUpdateEmailClearPrimaryQuery = `UPDATE user_email SET is_primary = FALSE WHERE user_id = $1 AND is_primary`
|
||||
|
||||
// Magic link queries (email verification + password reset)
|
||||
postgresInsertMagicLinkQuery = `INSERT INTO user_magic_link (token_hash, kind, user_id, email, expires, created) VALUES ($1, $2, $3, $4, $5, $6)`
|
||||
postgresSelectMagicLinkByHashQuery = `SELECT token_hash, kind, user_id, email, expires, created FROM user_magic_link WHERE token_hash = $1`
|
||||
postgresDeleteMagicLinkByHashQuery = `DELETE FROM user_magic_link WHERE token_hash = $1`
|
||||
postgresDeleteVerifyScopeQuery = `DELETE FROM user_magic_link WHERE kind = $1 AND user_id = $2 AND email = $3`
|
||||
postgresDeleteResetScopeQuery = `DELETE FROM user_magic_link WHERE kind = $1 AND user_id = $2`
|
||||
postgresSelectPendingEmailsQuery = `SELECT email FROM user_magic_link WHERE kind = $1 AND user_id = $2 ORDER BY email`
|
||||
postgresDeleteExpiredMagicLinksQuery = `DELETE FROM user_magic_link WHERE expires < $1`
|
||||
|
||||
// Billing queries
|
||||
postgresUpdateBillingQuery = `
|
||||
@@ -306,7 +320,19 @@ var postgresQueries = queries{
|
||||
deletePhoneNumber: postgresDeletePhoneNumberQuery,
|
||||
selectEmails: postgresSelectEmailsQuery,
|
||||
insertEmail: postgresInsertEmailQuery,
|
||||
insertEmailIgnore: postgresInsertEmailIgnoreQuery,
|
||||
deleteEmail: postgresDeleteEmailQuery,
|
||||
selectPrimaryEmail: postgresSelectPrimaryEmailQuery,
|
||||
selectUserIDByPrimary: postgresSelectUserIDByPrimaryQuery,
|
||||
updateEmailSetPrimary: postgresUpdateEmailSetPrimaryQuery,
|
||||
updateEmailClearPrimary: postgresUpdateEmailClearPrimaryQuery,
|
||||
insertMagicLink: postgresInsertMagicLinkQuery,
|
||||
selectMagicLinkByHash: postgresSelectMagicLinkByHashQuery,
|
||||
deleteMagicLinkByHash: postgresDeleteMagicLinkByHashQuery,
|
||||
deleteMagicLinkEmailVerify: postgresDeleteVerifyScopeQuery,
|
||||
deleteMagicLinkResetPassword: postgresDeleteResetScopeQuery,
|
||||
selectPendingEmails: postgresSelectPendingEmailsQuery,
|
||||
deleteExpiredMagicLinks: postgresDeleteExpiredMagicLinksQuery,
|
||||
updateBilling: postgresUpdateBillingQuery,
|
||||
}
|
||||
|
||||
|
||||
@@ -75,8 +75,21 @@ const (
|
||||
CREATE TABLE IF NOT EXISTS user_email (
|
||||
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL,
|
||||
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY (user_id, email)
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary;
|
||||
CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary;
|
||||
CREATE TABLE IF NOT EXISTS user_magic_link (
|
||||
token_hash TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
email TEXT,
|
||||
expires BIGINT NOT NULL,
|
||||
created BIGINT NOT NULL,
|
||||
PRIMARY KEY (token_hash)
|
||||
);
|
||||
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
store TEXT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
@@ -89,7 +102,7 @@ const (
|
||||
|
||||
// Schema table management queries for Postgres
|
||||
const (
|
||||
postgresCurrentSchemaVersion = 7
|
||||
postgresCurrentSchemaVersion = 8
|
||||
postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'user'`
|
||||
postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('user', $1)`
|
||||
)
|
||||
@@ -102,11 +115,30 @@ const (
|
||||
PRIMARY KEY (user_id, email)
|
||||
);
|
||||
`
|
||||
|
||||
// 7 -> 8: primary (recovery) email + magic-link table for verification/reset.
|
||||
// No backfill -- existing verified emails stay non-primary.
|
||||
postgresMigrate7To8UpdateQueries = `
|
||||
ALTER TABLE user_email ADD COLUMN is_primary BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary;
|
||||
CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary;
|
||||
CREATE TABLE IF NOT EXISTS user_magic_link (
|
||||
token_hash TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
email TEXT,
|
||||
expires BIGINT NOT NULL,
|
||||
created BIGINT NOT NULL,
|
||||
PRIMARY KEY (token_hash)
|
||||
);
|
||||
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
|
||||
`
|
||||
postgresUpdateSchemaVersionQuery = `UPDATE schema_version SET version = $1 WHERE store = 'user'`
|
||||
)
|
||||
|
||||
var postgresMigrations = map[int]func(db *sql.DB) error{
|
||||
6: postgresMigrateFrom6,
|
||||
7: postgresMigrateFrom7,
|
||||
}
|
||||
|
||||
func setupPostgres(db *sql.DB) error {
|
||||
@@ -141,6 +173,16 @@ func postgresMigrateFrom6(db *sql.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func postgresMigrateFrom7(db *sql.DB) error {
|
||||
if _, err := db.Exec(postgresMigrate7To8UpdateQueries); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(postgresUpdateSchemaVersionQuery, 8); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupNewPostgres(db *sql.DB) error {
|
||||
if _, err := db.Exec(postgresCreateTablesQueries); err != nil {
|
||||
return err
|
||||
|
||||
+29
-3
@@ -214,9 +214,23 @@ const (
|
||||
sqliteDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?`
|
||||
|
||||
// Email queries
|
||||
sqliteSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = ? ORDER BY email`
|
||||
sqliteInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?)`
|
||||
sqliteDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = ? AND email = ?`
|
||||
sqliteSelectEmailsQuery = `SELECT email, is_primary FROM user_email WHERE user_id = ? ORDER BY email`
|
||||
sqliteInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?)`
|
||||
sqliteInsertEmailIgnoreQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?) ON CONFLICT (user_id, email) DO NOTHING`
|
||||
sqliteDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = ? AND email = ?`
|
||||
sqliteSelectPrimaryEmailQuery = `SELECT email FROM user_email WHERE user_id = ? AND is_primary = 1`
|
||||
sqliteSelectUserIDByPrimaryQuery = `SELECT user_id FROM user_email WHERE email = ? AND is_primary = 1`
|
||||
sqliteUpdateEmailSetPrimaryQuery = `UPDATE user_email SET is_primary = 1 WHERE user_id = ? AND email = ?`
|
||||
sqliteUpdateEmailClearPrimaryQuery = `UPDATE user_email SET is_primary = 0 WHERE user_id = ? AND is_primary = 1`
|
||||
|
||||
// Magic link queries (email verification + password reset)
|
||||
sqliteInsertMagicLinkQuery = `INSERT INTO user_magic_link (token_hash, kind, user_id, email, expires, created) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
sqliteSelectMagicLinkByHashQuery = `SELECT token_hash, kind, user_id, email, expires, created FROM user_magic_link WHERE token_hash = ?`
|
||||
sqliteDeleteMagicLinkByHashQuery = `DELETE FROM user_magic_link WHERE token_hash = ?`
|
||||
sqliteDeleteVerifyScopeQuery = `DELETE FROM user_magic_link WHERE kind = ? AND user_id = ? AND email = ?`
|
||||
sqliteDeleteResetScopeQuery = `DELETE FROM user_magic_link WHERE kind = ? AND user_id = ?`
|
||||
sqliteSelectPendingEmailsQuery = `SELECT email FROM user_magic_link WHERE kind = ? AND user_id = ? ORDER BY email`
|
||||
sqliteDeleteExpiredMagicLinksQuery = `DELETE FROM user_magic_link WHERE expires < ?`
|
||||
|
||||
// Billing queries
|
||||
sqliteUpdateBillingQuery = `
|
||||
@@ -302,7 +316,19 @@ var sqliteQueries = queries{
|
||||
deletePhoneNumber: sqliteDeletePhoneNumberQuery,
|
||||
selectEmails: sqliteSelectEmailsQuery,
|
||||
insertEmail: sqliteInsertEmailQuery,
|
||||
insertEmailIgnore: sqliteInsertEmailIgnoreQuery,
|
||||
deleteEmail: sqliteDeleteEmailQuery,
|
||||
selectPrimaryEmail: sqliteSelectPrimaryEmailQuery,
|
||||
selectUserIDByPrimary: sqliteSelectUserIDByPrimaryQuery,
|
||||
updateEmailSetPrimary: sqliteUpdateEmailSetPrimaryQuery,
|
||||
updateEmailClearPrimary: sqliteUpdateEmailClearPrimaryQuery,
|
||||
insertMagicLink: sqliteInsertMagicLinkQuery,
|
||||
selectMagicLinkByHash: sqliteSelectMagicLinkByHashQuery,
|
||||
deleteMagicLinkByHash: sqliteDeleteMagicLinkByHashQuery,
|
||||
deleteMagicLinkEmailVerify: sqliteDeleteVerifyScopeQuery,
|
||||
deleteMagicLinkResetPassword: sqliteDeleteResetScopeQuery,
|
||||
selectPendingEmails: sqliteSelectPendingEmailsQuery,
|
||||
deleteExpiredMagicLinks: sqliteDeleteExpiredMagicLinksQuery,
|
||||
updateBilling: sqliteUpdateBillingQuery,
|
||||
}
|
||||
|
||||
|
||||
@@ -88,9 +88,23 @@ const (
|
||||
CREATE TABLE IF NOT EXISTS user_email (
|
||||
user_id TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
is_primary INT NOT NULL DEFAULT (0),
|
||||
PRIMARY KEY (user_id, email),
|
||||
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary = 1;
|
||||
CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary = 1;
|
||||
CREATE TABLE IF NOT EXISTS user_magic_link (
|
||||
token_hash TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
email TEXT,
|
||||
expires INT NOT NULL,
|
||||
created INT NOT NULL,
|
||||
PRIMARY KEY (token_hash),
|
||||
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
@@ -107,7 +121,7 @@ const (
|
||||
|
||||
// Schema version table management for SQLite
|
||||
const (
|
||||
sqliteCurrentSchemaVersion = 7
|
||||
sqliteCurrentSchemaVersion = 8
|
||||
sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)`
|
||||
sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1`
|
||||
sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
||||
@@ -236,6 +250,26 @@ const (
|
||||
);
|
||||
`
|
||||
|
||||
// 7 -> 8: primary (recovery) email + magic-link table for verification/reset.
|
||||
// No backfill -- existing verified emails stay non-primary, so the ALTER cannot
|
||||
// conflict and no old notification address becomes a recovery channel.
|
||||
sqliteMigrate7To8UpdateQueries = `
|
||||
ALTER TABLE user_email ADD COLUMN is_primary INT NOT NULL DEFAULT (0);
|
||||
CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary = 1;
|
||||
CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary = 1;
|
||||
CREATE TABLE IF NOT EXISTS user_magic_link (
|
||||
token_hash TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
email TEXT,
|
||||
expires INT NOT NULL,
|
||||
created INT NOT NULL,
|
||||
PRIMARY KEY (token_hash),
|
||||
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
|
||||
`
|
||||
|
||||
// 5 -> 6
|
||||
sqliteMigrate5To6UpdateQueries = `
|
||||
PRAGMA foreign_keys=off;
|
||||
@@ -339,6 +373,7 @@ var (
|
||||
4: sqliteMigrateFrom4,
|
||||
5: sqliteMigrateFrom5,
|
||||
6: sqliteMigrateFrom6,
|
||||
7: sqliteMigrateFrom7,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -493,3 +528,16 @@ func sqliteMigrateFrom6(sqlDB *sql.DB) error {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom7(sqlDB *sql.DB) error {
|
||||
log.Tag(tag).Info("Migrating user database schema: from 7 to 8")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate7To8UpdateQueries); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 8); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
+488
-2
@@ -2,6 +2,7 @@ package user
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
@@ -129,6 +130,13 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
|
||||
require.Nil(t, a.Authorize(ben, "announcements", PermissionRead))
|
||||
require.Equal(t, ErrUnauthorized, a.Authorize(ben, "announcements", PermissionWrite))
|
||||
|
||||
// User has full access to their own sync topic, even under deny-all,
|
||||
// but not to another user's sync topic (#733)
|
||||
require.Nil(t, a.Authorize(ben, ben.SyncTopic, PermissionRead))
|
||||
require.Nil(t, a.Authorize(ben, ben.SyncTopic, PermissionWrite))
|
||||
require.Equal(t, ErrUnauthorized, a.Authorize(ben, john.SyncTopic, PermissionRead))
|
||||
require.Equal(t, ErrUnauthorized, a.Authorize(ben, john.SyncTopic, PermissionWrite))
|
||||
|
||||
// User john should have
|
||||
// "deny" to mytopic_deny*,
|
||||
// "ro" to mytopic_ro*,
|
||||
@@ -1149,7 +1157,7 @@ func TestUser_EmailAddListRemove(t *testing.T) {
|
||||
emails, err := a.Emails(phil.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(emails))
|
||||
require.Equal(t, "phil@example.com", emails[0])
|
||||
require.Equal(t, "phil@example.com", emails[0].Address)
|
||||
|
||||
require.Nil(t, a.RemoveEmail(phil.ID, "phil@example.com"))
|
||||
emails, err = a.Emails(phil.ID)
|
||||
@@ -2693,7 +2701,7 @@ func TestStoreEmails(t *testing.T) {
|
||||
emails, err = manager.Emails(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, emails, 1)
|
||||
require.Equal(t, "phil2@example.com", emails[0])
|
||||
require.Equal(t, "phil2@example.com", emails[0].Address)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2827,3 +2835,481 @@ func TestStoreOtherAccessCount(t *testing.T) {
|
||||
require.Equal(t, 2, count) // ben's owner entry + everyone entry
|
||||
})
|
||||
}
|
||||
|
||||
// addVerifyLink stores an email-verification magic link and returns the raw token so the test
|
||||
// can "click" it via VerifyEmail.
|
||||
func addVerifyLink(t *testing.T, a *Manager, userID, email string, ttl time.Duration) string {
|
||||
raw, err := a.AddMagicLink(MagicLinkKindEmailVerify, userID, email, ttl)
|
||||
require.Nil(t, err)
|
||||
return raw
|
||||
}
|
||||
|
||||
func TestUser_MagicLink_VerifyEmail_SetsPrimary(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||
phil, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
|
||||
raw := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour)
|
||||
|
||||
// Before verifying: pending, not yet verified, no primary
|
||||
pending, err := a.PendingEmails(phil.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, []string{"phil@example.com"}, pending)
|
||||
emails, err := a.Emails(phil.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, len(emails))
|
||||
primary, err := a.PrimaryEmail(phil.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "", primary)
|
||||
|
||||
// Verify: the first verified email auto-becomes primary
|
||||
m, err := a.VerifyEmail(raw)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "phil@example.com", m.Email)
|
||||
|
||||
emails, err = a.Emails(phil.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, []string{"phil@example.com"}, emails.Strings())
|
||||
primary, err = a.PrimaryEmail(phil.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "phil@example.com", primary)
|
||||
pending, err = a.PendingEmails(phil.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, len(pending))
|
||||
|
||||
// Reset-by-email lookup resolves to the account
|
||||
userID, err := a.UserIDByPrimaryEmail("phil@example.com")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, phil.ID, userID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_MagicLink_VerifyEmail_SecondStaysSecondary(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||
phil, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
|
||||
raw1 := addVerifyLink(t, a, phil.ID, "first@example.com", 24*time.Hour)
|
||||
_, err = a.VerifyEmail(raw1)
|
||||
require.Nil(t, err)
|
||||
|
||||
raw2 := addVerifyLink(t, a, phil.ID, "second@example.com", 24*time.Hour)
|
||||
_, err = a.VerifyEmail(raw2)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Both verified, but primary is still the first
|
||||
emails, err := a.Emails(phil.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, []string{"first@example.com", "second@example.com"}, emails.Strings())
|
||||
primary, err := a.PrimaryEmail(phil.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "first@example.com", primary)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_MagicLink_PrimaryGlobalUniqueness(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
||||
phil, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
ben, err := a.User("ben")
|
||||
require.Nil(t, err)
|
||||
|
||||
// phil verifies shared@ first -> becomes his primary
|
||||
_, err = a.VerifyEmail(addVerifyLink(t, a, phil.ID, "shared@example.com", 24*time.Hour))
|
||||
require.Nil(t, err)
|
||||
primary, err := a.PrimaryEmail(phil.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "shared@example.com", primary)
|
||||
|
||||
// ben verifies the same address -> allowed as secondary, but NOT his primary
|
||||
_, err = a.VerifyEmail(addVerifyLink(t, a, ben.ID, "shared@example.com", 24*time.Hour))
|
||||
require.Nil(t, err)
|
||||
emails, err := a.Emails(ben.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, []string{"shared@example.com"}, emails.Strings())
|
||||
primary, err = a.PrimaryEmail(ben.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "", primary)
|
||||
|
||||
// Explicitly promoting ben's copy to primary collides with phil's
|
||||
require.ErrorIs(t, a.SetPrimaryEmail(ben.ID, "shared@example.com"), ErrEmailPrimaryElsewhere)
|
||||
// ...and phil keeps his primary (the failed promotion rolled back ben's clear)
|
||||
primary, err = a.PrimaryEmail(phil.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "shared@example.com", primary)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_MagicLink_SetPrimary_NotVerified(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||
phil, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.ErrorIs(t, a.SetPrimaryEmail(phil.ID, "nope@example.com"), ErrEmailNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_MagicLink_Expired(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||
phil, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
|
||||
raw := addVerifyLink(t, a, phil.ID, "phil@example.com", -time.Minute)
|
||||
_, err = a.VerifyEmail(raw)
|
||||
require.ErrorIs(t, err, ErrMagicLinkNotFound)
|
||||
|
||||
// Nothing got verified
|
||||
emails, err := a.Emails(phil.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, len(emails))
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_MagicLink_SingleUse(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||
phil, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
|
||||
raw := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour)
|
||||
_, err = a.VerifyEmail(raw)
|
||||
require.Nil(t, err)
|
||||
// Second click: token already consumed
|
||||
_, err = a.VerifyEmail(raw)
|
||||
require.ErrorIs(t, err, ErrMagicLinkNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_MagicLink_ReplaceOnReRequest(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||
phil, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
|
||||
raw1 := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour)
|
||||
raw2 := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour)
|
||||
|
||||
// Only one pending row remains; the old token no longer works
|
||||
pending, err := a.PendingEmails(phil.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, []string{"phil@example.com"}, pending)
|
||||
_, err = a.MagicLinkByToken(raw1)
|
||||
require.ErrorIs(t, err, ErrMagicLinkNotFound)
|
||||
|
||||
m, err := a.MagicLinkByToken(raw2)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "phil@example.com", m.Email)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_MagicLink_PasswordReset_RoundTrip(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||
phil, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
|
||||
raw, err := a.AddMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour)
|
||||
require.Nil(t, err)
|
||||
|
||||
m, err := a.MagicLinkByToken(raw)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, MagicLinkKindPasswordReset, m.Kind)
|
||||
require.Equal(t, phil.ID, m.UserID)
|
||||
require.Equal(t, "", m.Email) // reset rows carry no email
|
||||
|
||||
// Reset rows do not appear as pending emails
|
||||
pending, err := a.PendingEmails(phil.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, len(pending))
|
||||
|
||||
// New request replaces the old token
|
||||
raw2, err := a.AddMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour)
|
||||
require.Nil(t, err)
|
||||
_, err = a.MagicLinkByToken(raw)
|
||||
require.ErrorIs(t, err, ErrMagicLinkNotFound)
|
||||
|
||||
// Single use: deleting consumes it
|
||||
require.Nil(t, a.DeleteMagicLinkByToken(raw2))
|
||||
_, err = a.MagicLinkByToken(raw2)
|
||||
require.ErrorIs(t, err, ErrMagicLinkNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_MagicLink_Reaper(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||
phil, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
|
||||
expired := addVerifyLink(t, a, phil.ID, "expired@example.com", -time.Hour)
|
||||
valid := addVerifyLink(t, a, phil.ID, "valid@example.com", time.Hour)
|
||||
|
||||
require.Nil(t, a.deleteExpiredMagicLinks())
|
||||
|
||||
_, err = a.MagicLinkByToken(expired)
|
||||
require.ErrorIs(t, err, ErrMagicLinkNotFound)
|
||||
m, err := a.MagicLinkByToken(valid)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "valid@example.com", m.Email)
|
||||
})
|
||||
}
|
||||
|
||||
// TestUser_MagicLink_ReaperLoop proves the background reap goroutine actually runs on its
|
||||
// configured interval: an expired link inserted into a manager with a tiny reap interval is
|
||||
// deleted without anyone calling deleteExpiredMagicLinks directly. Mirrors the loop-coverage
|
||||
// pattern of TestAccessCacheReloadInterval_PicksUpExternalWrite.
|
||||
func TestUser_MagicLink_ReaperLoop(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
a := newTestManagerFromConfig(t, newManager, &Config{
|
||||
DefaultAccess: PermissionDenyAll,
|
||||
BcryptCost: bcrypt.MinCost,
|
||||
ExpiredMagicLinkReapInterval: 25 * time.Millisecond,
|
||||
})
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||
phil, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
|
||||
expired := addVerifyLink(t, a, phil.ID, "expired@example.com", -time.Hour)
|
||||
valid := addVerifyLink(t, a, phil.ID, "valid@example.com", time.Hour)
|
||||
|
||||
// The background loop (not a direct call) must reap the expired link within a few intervals
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := a.MagicLinkByToken(expired)
|
||||
return errors.Is(err, ErrMagicLinkNotFound)
|
||||
}, 2*time.Second, 10*time.Millisecond, "reaper loop never deleted the expired magic link")
|
||||
|
||||
// The unexpired link must survive
|
||||
m, err := a.MagicLinkByToken(valid)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "valid@example.com", m.Email)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_MagicLink_ResetPassword(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false))
|
||||
phil, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
|
||||
raw, err := a.AddMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Old password works before reset
|
||||
_, err = a.Authenticate("phil", "oldpass")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Nil(t, a.ResetPassword(raw, "newpass"))
|
||||
|
||||
// New password works, old does not
|
||||
_, err = a.Authenticate("phil", "newpass")
|
||||
require.Nil(t, err)
|
||||
_, err = a.Authenticate("phil", "oldpass")
|
||||
require.ErrorIs(t, err, ErrUnauthenticated)
|
||||
|
||||
// Token is single-use
|
||||
require.ErrorIs(t, a.ResetPassword(raw, "againpass"), ErrMagicLinkNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_MagicLink_ResetPassword_WrongKindRejected(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false))
|
||||
phil, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
|
||||
// An email-verification token must not be usable for password reset...
|
||||
verifyToken := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Hour)
|
||||
require.ErrorIs(t, a.ResetPassword(verifyToken, "newpass"), ErrMagicLinkNotFound)
|
||||
|
||||
// ...and a reset token must not be usable for email verification
|
||||
resetToken, err := a.AddMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour)
|
||||
require.Nil(t, err)
|
||||
_, err = a.VerifyEmail(resetToken)
|
||||
require.ErrorIs(t, err, ErrMagicLinkNotFound)
|
||||
|
||||
// Old password unchanged
|
||||
_, err = a.Authenticate("phil", "oldpass")
|
||||
require.Nil(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_MagicLink_VerifyEmail_ProvisionedGetsPrimary(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
a := newTestManagerFromConfig(t, newManager, &Config{
|
||||
DefaultAccess: PermissionDenyAll,
|
||||
ProvisionEnabled: true,
|
||||
Users: []*User{
|
||||
{Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
|
||||
},
|
||||
})
|
||||
prov, err := a.User("prov")
|
||||
require.Nil(t, err)
|
||||
|
||||
// A provisioned user's first verified email becomes their primary, just like a regular user
|
||||
// (the primary is also the X-Email: yes target; password reset stays blocked separately).
|
||||
_, err = a.VerifyEmail(addVerifyLink(t, a, prov.ID, "prov@example.com", time.Hour))
|
||||
require.Nil(t, err)
|
||||
|
||||
emails, err := a.Emails(prov.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, []string{"prov@example.com"}, emails.Strings())
|
||||
primary, err := a.PrimaryEmail(prov.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "prov@example.com", primary)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_MagicLink_ResetPassword_ProvisionedRejected(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
// Provisioned users come from the config file (ProvisionEnabled), not AddUser
|
||||
a := newTestManagerFromConfig(t, newManager, &Config{
|
||||
DefaultAccess: PermissionDenyAll,
|
||||
ProvisionEnabled: true,
|
||||
Users: []*User{
|
||||
{Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
|
||||
},
|
||||
})
|
||||
prov, err := a.User("prov")
|
||||
require.Nil(t, err)
|
||||
require.True(t, prov.Provisioned)
|
||||
|
||||
// A reset token can be created, but consuming it must be rejected for a provisioned user
|
||||
// (their password comes from the config file, like change-pass).
|
||||
raw, err := a.AddMagicLink(MagicLinkKindPasswordReset, prov.ID, "", time.Hour)
|
||||
require.Nil(t, err)
|
||||
require.ErrorIs(t, a.ResetPassword(raw, "newpass"), ErrProvisionedUserChange)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_MagicLink_ResetPassword_Expired(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false))
|
||||
phil, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
|
||||
raw, err := a.AddMagicLink(MagicLinkKindPasswordReset, phil.ID, "", -time.Minute)
|
||||
require.Nil(t, err)
|
||||
require.ErrorIs(t, a.ResetPassword(raw, "newpass"), ErrMagicLinkNotFound)
|
||||
_, err = a.Authenticate("phil", "oldpass")
|
||||
require.Nil(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_MagicLink_UserIDByPrimaryEmail_NotFound(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||
_, err := a.UserIDByPrimaryEmail("ghost@example.com")
|
||||
require.ErrorIs(t, err, ErrUserNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_Emails_PrimaryFlagAndHelpers(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||
u, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, a.AddEmail(u.ID, "a@example.com"))
|
||||
require.Nil(t, a.AddEmail(u.ID, "b@example.com"))
|
||||
require.Nil(t, a.SetPrimaryEmail(u.ID, "b@example.com"))
|
||||
|
||||
// Emails() carries the primary flag, so a separate PrimaryEmail() call is unnecessary.
|
||||
emails, err := a.Emails(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, emails, 2)
|
||||
require.Equal(t, "a@example.com", emails[0].Address) // ORDER BY email
|
||||
require.False(t, emails[0].Primary)
|
||||
require.Equal(t, "b@example.com", emails[1].Address)
|
||||
require.True(t, emails[1].Primary)
|
||||
|
||||
// Helper methods for the address-only callers
|
||||
require.Equal(t, []string{"a@example.com", "b@example.com"}, emails.Strings())
|
||||
require.True(t, emails.Contains("a@example.com"))
|
||||
require.False(t, emails.Contains("c@example.com"))
|
||||
})
|
||||
}
|
||||
|
||||
// openReplicaTestSQLite opens a fresh SQLite database file with the user schema applied.
|
||||
func openReplicaTestSQLite(t *testing.T, filename string) *sql.DB {
|
||||
d, err := sql.Open("sqlite3", filename+"?_case_sensitive_like=on")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, setupSQLite(d))
|
||||
return d
|
||||
}
|
||||
|
||||
// TestManager_AccountReadsUsePrimary verifies that the per-user reads backing the GET /account
|
||||
// endpoint read from the primary, not from a read replica. The sync event ("something changed")
|
||||
// is published immediately after a write, so a replica that lags would make the account view
|
||||
// stale right after the user changes it. These reads must therefore be read-your-writes consistent.
|
||||
//
|
||||
// The test wires up a primary and a deliberately-empty replica (simulating replication lag),
|
||||
// forces the replica healthy so ReadOnly() would route to it, writes everything to the primary,
|
||||
// and asserts the reads still observe the fresh primary data.
|
||||
func TestManager_AccountReadsUsePrimary(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
primaryDB := openReplicaTestSQLite(t, filepath.Join(dir, "primary.db"))
|
||||
replicaDB := openReplicaTestSQLite(t, filepath.Join(dir, "replica.db")) // intentionally left empty
|
||||
|
||||
pool := db.New(&db.Host{DB: primaryDB}, []*db.Host{{DB: replicaDB}})
|
||||
pool.MarkReplicasHealthyForTest() // force ReadOnly() to route to the stale replica
|
||||
a, err := newManager(pool, sqliteQueries, &Config{BcryptCost: bcrypt.MinCost})
|
||||
require.Nil(t, err)
|
||||
t.Cleanup(func() { a.Close() })
|
||||
|
||||
// All writes below go to the primary; the replica stays empty.
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
|
||||
u, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
_, err = a.CreateToken(u.ID, "test token", time.Now().Add(time.Hour), netip.IPv4Unspecified(), false)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, a.AddReservation("phil", "mytopic", PermissionDenyAll, 10))
|
||||
require.Nil(t, a.AddPhoneNumber(u.ID, "+12223334444"))
|
||||
require.Nil(t, a.AddEmail(u.ID, "phil@example.com"))
|
||||
require.Nil(t, a.SetPrimaryEmail(u.ID, "phil@example.com"))
|
||||
_, err = a.AddMagicLink(MagicLinkKindEmailVerify, u.ID, "pending@example.com", time.Hour)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Each read must observe the just-written primary data, NOT the empty replica.
|
||||
tokens, err := a.Tokens(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, tokens, 1)
|
||||
|
||||
reservations, err := a.Reservations("phil")
|
||||
require.Nil(t, err)
|
||||
require.Len(t, reservations, 1)
|
||||
|
||||
phoneNumbers, err := a.PhoneNumbers(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, phoneNumbers, 1)
|
||||
|
||||
emails, err := a.Emails(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, emails, 1)
|
||||
require.Equal(t, "phil@example.com", emails[0].Address)
|
||||
require.True(t, emails[0].Primary)
|
||||
|
||||
primaryEmail, err := a.PrimaryEmail(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "phil@example.com", primaryEmail)
|
||||
|
||||
pendingEmails, err := a.PendingEmails(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, pendingEmails, 1)
|
||||
}
|
||||
|
||||
+85
-15
@@ -73,9 +73,32 @@ type TokenUpdate struct {
|
||||
LastOrigin netip.Addr
|
||||
}
|
||||
|
||||
// MagicLinkKind discriminates the two link-token flows stored in the user_magic_link table.
|
||||
type MagicLinkKind string
|
||||
|
||||
// Magic link kinds
|
||||
const (
|
||||
MagicLinkKindEmailVerify MagicLinkKind = "email_verify"
|
||||
MagicLinkKindPasswordReset MagicLinkKind = "password_reset"
|
||||
)
|
||||
|
||||
// MagicLink is a pending, single-use link token -- either an email verification or a
|
||||
// password reset, distinguished by Kind. The raw token travels in the emailed link;
|
||||
// only its TokenHash (hex SHA-256) is persisted.
|
||||
type MagicLink struct {
|
||||
TokenHash string
|
||||
Kind MagicLinkKind
|
||||
UserID string
|
||||
Email string // Address being verified for email_verify; empty (NULL) for password_reset
|
||||
Expires int64
|
||||
Created int64
|
||||
}
|
||||
|
||||
// Prefs represents a user's configuration settings
|
||||
type Prefs struct {
|
||||
Language *string `json:"language,omitempty"`
|
||||
DateFormat *string `json:"date_format,omitempty"`
|
||||
TimeFormat *string `json:"time_format,omitempty"`
|
||||
Notification *NotificationPrefs `json:"notification,omitempty"`
|
||||
Subscriptions []*Subscription `json:"subscriptions,omitempty"`
|
||||
}
|
||||
@@ -161,6 +184,36 @@ type Reservation struct {
|
||||
Everyone Permission
|
||||
}
|
||||
|
||||
// Email is a verified email address on a user account, along with whether it is the user's
|
||||
// designated primary (recovery) address.
|
||||
type Email struct {
|
||||
Address string
|
||||
Primary bool
|
||||
}
|
||||
|
||||
// Emails is a list of verified email addresses for a user.
|
||||
type Emails []*Email
|
||||
|
||||
// Strings returns just the address strings, in the same order. It is a convenience for callers
|
||||
// that only care about the addresses and not the primary flag.
|
||||
func (e Emails) Strings() []string {
|
||||
addresses := make([]string, len(e))
|
||||
for i, email := range e {
|
||||
addresses[i] = email.Address
|
||||
}
|
||||
return addresses
|
||||
}
|
||||
|
||||
// Contains reports whether the given address is in the list.
|
||||
func (e Emails) Contains(address string) bool {
|
||||
for _, email := range e {
|
||||
if email.Address == address {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Permission represents a read or write permission to a topic
|
||||
type Permission uint8
|
||||
|
||||
@@ -245,18 +298,19 @@ const (
|
||||
|
||||
// Config holds the configuration for the user Manager
|
||||
type Config struct {
|
||||
Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" (SQLite)
|
||||
DatabaseURL string // Database connection string (PostgreSQL)
|
||||
StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers (SQLite only)
|
||||
DefaultAccess Permission // Default permission if no ACL matches
|
||||
ProvisionEnabled bool // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands
|
||||
Users []*User // Predefined users to create on startup
|
||||
Access map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant)
|
||||
Tokens map[string][]*Token // Predefined users to create on startup (username -> []*Token)
|
||||
QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database
|
||||
BcryptCost int // Cost of generated passwords; lowering makes testing faster
|
||||
AccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only)
|
||||
AccessCacheReloadInterval time.Duration // Reload interval for access cache, relevant for ACL writes from CLI
|
||||
Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" (SQLite)
|
||||
DatabaseURL string // Database connection string (PostgreSQL)
|
||||
StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers (SQLite only)
|
||||
DefaultAccess Permission // Default permission if no ACL matches
|
||||
ProvisionEnabled bool // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands
|
||||
Users []*User // Predefined users to create on startup
|
||||
Access map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant)
|
||||
Tokens map[string][]*Token // Predefined users to create on startup (username -> []*Token)
|
||||
QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database
|
||||
BcryptCost int // Cost of generated passwords; lowering makes testing faster
|
||||
AccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only)
|
||||
AccessCacheReloadInterval time.Duration // Reload interval for access cache, relevant for ACL writes from CLI
|
||||
ExpiredMagicLinkReapInterval time.Duration // Interval for sweeping expired email-verify/password-reset links
|
||||
}
|
||||
|
||||
// Error constants used by the package
|
||||
@@ -275,6 +329,8 @@ var (
|
||||
ErrPhoneNumberExists = errors.New("phone number already exists")
|
||||
ErrEmailNotFound = errors.New("email not found")
|
||||
ErrEmailExists = errors.New("email already exists")
|
||||
ErrEmailPrimaryElsewhere = errors.New("email is the primary email on another account")
|
||||
ErrMagicLinkNotFound = errors.New("magic link not found")
|
||||
ErrProvisionedUserChange = errors.New("cannot change or delete provisioned user")
|
||||
ErrProvisionedTokenChange = errors.New("cannot change or delete provisioned token")
|
||||
)
|
||||
@@ -350,9 +406,23 @@ type queries struct {
|
||||
deletePhoneNumber string
|
||||
|
||||
// Email queries
|
||||
selectEmails string
|
||||
insertEmail string
|
||||
deleteEmail string
|
||||
selectEmails string
|
||||
insertEmail string
|
||||
insertEmailIgnore string // Idempotent insert (ON CONFLICT DO NOTHING) used inside VerifyEmail
|
||||
deleteEmail string
|
||||
selectPrimaryEmail string
|
||||
selectUserIDByPrimary string
|
||||
updateEmailSetPrimary string
|
||||
updateEmailClearPrimary string
|
||||
|
||||
// Magic link queries (email verification + password reset)
|
||||
insertMagicLink string
|
||||
selectMagicLinkByHash string
|
||||
deleteMagicLinkByHash string
|
||||
deleteMagicLinkEmailVerify string // Delete pending email_verify rows for (user_id, email)
|
||||
deleteMagicLinkResetPassword string // Delete the active password_reset row for user_id
|
||||
selectPendingEmails string // Pending (unverified) email addresses for a user
|
||||
deleteExpiredMagicLinks string
|
||||
|
||||
// Billing queries
|
||||
updateBilling string
|
||||
|
||||
+22
-4
@@ -1,7 +1,9 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -9,6 +11,11 @@ import (
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
// linkTokenLength is the length of a raw magic-link token. At 48 base62 characters
|
||||
// it carries ~285 bits of entropy, well above the ~256-bit target, so the tokens
|
||||
// need no brute-force cap -- just expiry and single-use.
|
||||
const linkTokenLength = 48
|
||||
|
||||
var (
|
||||
allowedUsernameRegex = regexp.MustCompile(`^[-_.+@a-zA-Z0-9]+$`) // Does not include Everyone (*)
|
||||
allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*'
|
||||
@@ -67,12 +74,23 @@ func GenerateToken() string {
|
||||
return util.RandomLowerStringPrefix(tokenPrefix, tokenLength)
|
||||
}
|
||||
|
||||
// HashPassword hashes the given password using bcrypt with the configured cost
|
||||
func HashPassword(password string) (string, error) {
|
||||
return hashPassword(password, DefaultUserPasswordBcryptCost)
|
||||
// generateLinkToken returns a fresh high-entropy raw token for a magic link
|
||||
// (email verification or password reset). The raw token is carried in the emailed
|
||||
// link; only its hashToken digest is persisted.
|
||||
func generateLinkToken() string {
|
||||
return util.RandomString(linkTokenLength)
|
||||
}
|
||||
|
||||
func hashPassword(password string, cost int) (string, error) {
|
||||
// hashToken returns the hex-encoded SHA-256 digest of a raw magic-link token.
|
||||
// Tokens are stored hashed so a database read cannot yield working links; a high-entropy
|
||||
// token makes a fast (unsalted) hash sufficient, unlike a password.
|
||||
func hashToken(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// HashPassword hashes the given password using bcrypt with the given cost
|
||||
func HashPassword(password string, cost int) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), cost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
+6
-6
@@ -176,7 +176,7 @@ func TestHashPassword(t *testing.T) {
|
||||
password := "test-password-123"
|
||||
|
||||
// Hash the password
|
||||
hash, err := HashPassword(password)
|
||||
hash, err := HashPassword(password, DefaultUserPasswordBcryptCost)
|
||||
require.Nil(t, err)
|
||||
require.NotEmpty(t, hash)
|
||||
|
||||
@@ -187,12 +187,12 @@ func TestHashPassword(t *testing.T) {
|
||||
require.True(t, strings.HasPrefix(hash, "$2a$"))
|
||||
|
||||
// Hash the same password again - should produce different hash
|
||||
hash2, err := HashPassword(password)
|
||||
hash2, err := HashPassword(password, DefaultUserPasswordBcryptCost)
|
||||
require.Nil(t, err)
|
||||
require.NotEqual(t, hash, hash2, "Same password should produce different hashes (salt)")
|
||||
|
||||
// Empty password should still work
|
||||
emptyHash, err := HashPassword("")
|
||||
emptyHash, err := HashPassword("", DefaultUserPasswordBcryptCost)
|
||||
require.Nil(t, err)
|
||||
require.NotEmpty(t, emptyHash)
|
||||
require.Nil(t, ValidPasswordHash(emptyHash, DefaultUserPasswordBcryptCost))
|
||||
@@ -202,15 +202,15 @@ func TestHashPassword_WithCost(t *testing.T) {
|
||||
password := "test-password"
|
||||
|
||||
// Test with different costs
|
||||
hash4, err := hashPassword(password, 4)
|
||||
hash4, err := HashPassword(password, 4)
|
||||
require.Nil(t, err)
|
||||
require.True(t, strings.HasPrefix(hash4, "$2a$04$"))
|
||||
|
||||
hash10, err := hashPassword(password, 10)
|
||||
hash10, err := HashPassword(password, 10)
|
||||
require.Nil(t, err)
|
||||
require.True(t, strings.HasPrefix(hash10, "$2a$10$"))
|
||||
|
||||
hash12, err := hashPassword(password, 12)
|
||||
hash12, err := HashPassword(password, 12)
|
||||
require.Nil(t, err)
|
||||
require.True(t, strings.HasPrefix(hash12, "$2a$12$"))
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -21,8 +20,9 @@ const (
|
||||
//
|
||||
// tpl := template.New("foo").Funcs(sprig.FuncMap()))
|
||||
//
|
||||
// TxtFuncMap returns a 'text/template'.FuncMap
|
||||
func TxtFuncMap() template.FuncMap {
|
||||
// TxtFuncMap returns the function map as a plain map[string]any, assignable to any text/template or
|
||||
// html/template FuncMap (including ntfy's vendored internal/template).
|
||||
func TxtFuncMap() map[string]any {
|
||||
return map[string]any{
|
||||
// Date functions
|
||||
"ago": dateAgo,
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrWriteTimeout is returned when a write timed out
|
||||
var ErrWriteTimeout = errors.New("write operation failed due to timeout")
|
||||
|
||||
// TimeoutWriter wraps an io.Writer that will time out after the given timeout
|
||||
type TimeoutWriter struct {
|
||||
writer io.Writer
|
||||
timeout time.Duration
|
||||
start time.Time
|
||||
}
|
||||
|
||||
// NewTimeoutWriter creates a new TimeoutWriter
|
||||
func NewTimeoutWriter(w io.Writer, timeout time.Duration) *TimeoutWriter {
|
||||
return &TimeoutWriter{
|
||||
writer: w,
|
||||
timeout: timeout,
|
||||
start: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Write implements the io.Writer interface, failing if called after the timeout period from creation.
|
||||
func (tw *TimeoutWriter) Write(p []byte) (n int, err error) {
|
||||
if time.Since(tw.start) > tw.timeout {
|
||||
return 0, ErrWriteTimeout
|
||||
}
|
||||
return tw.writer.Write(p)
|
||||
}
|
||||
+36
-10
@@ -2,20 +2,19 @@ package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
crand "crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"math/rand"
|
||||
"net/netip"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -30,8 +29,6 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
random = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
randomMutex = sync.Mutex{}
|
||||
sizeStrRegex = regexp.MustCompile(`(?i)^(\d+)([gmkb])?$`)
|
||||
errInvalidPriority = errors.New("invalid priority")
|
||||
noQuotesRegex = regexp.MustCompile(`^[-_./:@a-zA-Z0-9]+$`)
|
||||
@@ -144,14 +141,33 @@ func RandomLowerStringPrefix(prefix string, length int) string {
|
||||
return randomStringPrefixWithCharset(prefix, length, randomStringLowerCaseCharset)
|
||||
}
|
||||
|
||||
// randomStringPrefixWithCharset builds a random string from charset using crypto/rand.
|
||||
// We use rejection sampling (dropping the few highest byte values that would skew the
|
||||
// distribution) so every character is uniformly distributed -- important because these
|
||||
// strings back security tokens (access tokens, magic-link tokens, IDs), not just labels.
|
||||
func randomStringPrefixWithCharset(prefix string, length int, charset string) string {
|
||||
randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?!
|
||||
defer randomMutex.Unlock()
|
||||
b := make([]byte, length-len(prefix))
|
||||
for i := range b {
|
||||
b[i] = charset[random.Intn(len(charset))]
|
||||
n := length - len(prefix)
|
||||
if n <= 0 {
|
||||
return prefix[:length]
|
||||
}
|
||||
return prefix + string(b)
|
||||
result := make([]byte, n)
|
||||
limit := 256 - (256 % len(charset)) // reject byte values >= limit to avoid modulo bias
|
||||
buf := make([]byte, n)
|
||||
for i := 0; i < n; {
|
||||
if _, err := crand.Read(buf); err != nil {
|
||||
panic("crypto/rand failed: " + err.Error()) // Should never happen on a sane system
|
||||
}
|
||||
for _, c := range buf {
|
||||
if i >= n {
|
||||
break
|
||||
}
|
||||
if int(c) < limit {
|
||||
result[i] = charset[int(c)%len(charset)]
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
return prefix + string(result)
|
||||
}
|
||||
|
||||
// ValidRandomString returns true if the given string matches the format created by RandomString
|
||||
@@ -343,6 +359,16 @@ func MaybeMarshalJSON(v any) string {
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
// EncodeJSON writes the JSON encoding of v to w, without escaping HTML-significant
|
||||
// characters (<, >, &). Unlike the standard library's default, ntfy does not embed its
|
||||
// JSON responses in HTML, so escaping these characters only makes the raw output harder
|
||||
// to read (see #1511).
|
||||
func EncodeJSON(w io.Writer, v any) error {
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetEscapeHTML(false)
|
||||
return encoder.Encode(v)
|
||||
}
|
||||
|
||||
// QuoteCommand combines a command array to a string, quoting arguments that need quoting.
|
||||
// This function is naive, and sometimes wrong. It is only meant for lo pretty-printing a command.
|
||||
//
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/netip"
|
||||
@@ -25,6 +26,30 @@ func TestRandomString(t *testing.T) {
|
||||
require.NotEqual(t, s1, s2)
|
||||
}
|
||||
|
||||
// TestRandomString_CSPRNG guards the crypto/rand-backed generator: every character must come
|
||||
// from the expected charset (rejection sampling correctness) and a large batch must be unique
|
||||
// (no clock-seeded PRNG collapsing to a predictable stream).
|
||||
func TestRandomString_CSPRNG(t *testing.T) {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
seen := make(map[string]bool)
|
||||
charCounts := make(map[rune]int)
|
||||
for i := 0; i < 5000; i++ {
|
||||
s := RandomString(48)
|
||||
require.Equal(t, 48, len(s))
|
||||
require.False(t, seen[s], "duplicate random string generated")
|
||||
seen[s] = true
|
||||
for _, c := range s {
|
||||
require.Contains(t, charset, string(c))
|
||||
charCounts[c]++
|
||||
}
|
||||
}
|
||||
// Every charset character should appear at least once across 5000*48 draws; a heavily
|
||||
// biased or broken generator would leave gaps.
|
||||
for _, c := range charset {
|
||||
require.Greater(t, charCounts[c], 0, "character %q never appeared", string(c))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileExists(t *testing.T) {
|
||||
filename := filepath.Join(t.TempDir(), "somefile.txt")
|
||||
require.Nil(t, os.WriteFile(filename, []byte{0x25, 0x86}, 0600))
|
||||
@@ -275,3 +300,10 @@ func TestMaybeMarshalJSON(t *testing.T) {
|
||||
require.Equal(t, `"`+strings.Repeat("x", 4999), MaybeMarshalJSON(strings.Repeat("x", 6000)))
|
||||
|
||||
}
|
||||
|
||||
func TestEncodeJSON(t *testing.T) {
|
||||
// HTML-significant characters (<, >, &) must NOT be escaped, see #1511
|
||||
var buf bytes.Buffer
|
||||
require.Nil(t, EncodeJSON(&buf, map[string]string{"message": "<b>a&b</b>"}))
|
||||
require.Equal(t, `{"message":"<b>a&b</b>"}`+"\n", buf.String())
|
||||
}
|
||||
|
||||
+2
-1
@@ -1 +1,2 @@
|
||||
src/app/emojis.js
|
||||
src/app/emojis.js
|
||||
src/app/emojisMapped.js
|
||||
|
||||
@@ -2,3 +2,4 @@ build/
|
||||
dist/
|
||||
public/static/langs/
|
||||
src/app/emojis.js
|
||||
src/app/emojisMapped.js
|
||||
|
||||
+86
-2
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -14,7 +14,7 @@
|
||||
<meta name="msapplication-navbutton-color" content="#317f6f" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" />
|
||||
<link rel="apple-touch-icon" href="/static/images/apple-touch-icon.png" sizes="180x180" />
|
||||
<link rel="mask-icon" href="/static/images/mask-icon.svg" color="#317f6f" />
|
||||
<link rel="mask-icon" href="/static/images/ntfy-mask.svg" color="#317f6f" />
|
||||
|
||||
<!-- Favicon, see favicon.io -->
|
||||
<link rel="icon" type="image/png" href="/static/images/favicon.ico" />
|
||||
@@ -44,6 +44,85 @@
|
||||
|
||||
<!-- PWA -->
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
|
||||
<!-- Splash: painted before the JS bundle loads, faded out by the app once ready (see
|
||||
src/app/splash.js). Background matches MUI's grey[100]/grey[900] for a seamless handoff. -->
|
||||
<style>
|
||||
html {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
background-color: #212121;
|
||||
}
|
||||
|
||||
#splash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f5f5f5;
|
||||
opacity: 1;
|
||||
/* Background fade = the app fading in once the logo is gone (see src/app/splash.js). */
|
||||
transition: opacity 0.1s ease-out;
|
||||
}
|
||||
|
||||
html.dark #splash {
|
||||
background-color: #212121;
|
||||
}
|
||||
|
||||
#splash.splash-hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#splash img {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
/* Gently pulse while loading; src/app/splash.js stops this and fades the logo out. */
|
||||
animation: splash-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes splash-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#splash {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
#splash img {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Decide light/dark before first paint from the "prefcache" blob (written by PrefCache.jsx) --
|
||||
avoids the async-IndexedDB theme flash. Keep the key in sync with PrefCache.jsx. -->
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var cache = JSON.parse(localStorage.getItem("prefcache"));
|
||||
var theme = cache && cache.theme;
|
||||
var prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
var dark = theme === "dark" || ((!theme || theme === "system") && prefersDark);
|
||||
if (dark) {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
} catch (e) {
|
||||
/* localStorage/matchMedia/JSON unavailable -- fall back to the default light splash */
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
@@ -51,6 +130,11 @@
|
||||
<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>
|
||||
|
||||
<!-- Static splash, removed by src/app/splash.js once ready. Logo is a same-origin SVG (precached by the SW). -->
|
||||
<div id="splash" aria-hidden="true">
|
||||
<img src="/static/images/ntfy-splash.svg" alt="" />
|
||||
</div>
|
||||
<div id="root"></div>
|
||||
<script src="/config.js"></script>
|
||||
<script type="module" src="/src/index.jsx"></script>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"checkJs": false
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "build"]
|
||||
}
|
||||
Generated
+1539
-1011
File diff suppressed because it is too large
Load Diff
+15
-12
@@ -8,30 +8,32 @@
|
||||
"serve": "vite preview",
|
||||
"format": "prettier . --write",
|
||||
"format:check": "prettier . --check",
|
||||
"lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/"
|
||||
"lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/cache": "^11.11.0",
|
||||
"@emotion/react": "^11.11.0",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.4.2",
|
||||
"@mui/material": "latest",
|
||||
"dexie": "^3.2.1",
|
||||
"dexie-react-hooks": "^1.1.1",
|
||||
"@mui/icons-material": "^9.1.1",
|
||||
"@mui/material": "^9.1.2",
|
||||
"dexie": "^4.4.4",
|
||||
"dexie-react-hooks": "^4.4.0",
|
||||
"i18next": "^21.6.14",
|
||||
"i18next-browser-languagedetector": "^6.1.4",
|
||||
"i18next-http-backend": "^3.0.5",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"i18next-http-backend": "^4.0.0",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"react-i18next": "^11.16.2",
|
||||
"react-infinite-scroll-component": "^6.1.0",
|
||||
"react-infinite-scroll-component": "^7.2.1",
|
||||
"react-remark": "^2.1.0",
|
||||
"react-router-dom": "^6.30.4",
|
||||
"stylis": "^4.3.0",
|
||||
"stylis-plugin-rtl": "^2.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"eslint": "^8.41.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
@@ -39,9 +41,10 @@
|
||||
"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": "^6.4.2",
|
||||
"vite-plugin-pwa": "^1.0.0"
|
||||
"prettier": "^3.9.4",
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-pwa": "^1.0.0",
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
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 */
|
||||
@@ -13,7 +15,9 @@
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
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 */
|
||||
@@ -21,7 +25,9 @@
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
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 */
|
||||
@@ -29,5 +35,7 @@
|
||||
font-family: "Roboto";
|
||||
font-style: normal;
|
||||
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");
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="#9a9a9a">
|
||||
<g transform="translate(-51.451771,-87.327048)">
|
||||
<path d="m 59.291677,93.677052 c -3.579993,0 -6.646873,2.817003 -6.646873,6.398338 v 0.003 l 0.03508,27.86677 -0.899113,6.63475 12.226096,-3.24797 H 94.40052 c 3.579985,0 6.64687,-2.82079 6.64687,-6.40216 v -24.85449 c 0,-3.580312 -3.065184,-6.39668 -6.643822,-6.398338 h -0.0031 z m 0,4.516205 h 35.108844 0.0031 c 1.257851,0.0013 2.12767,0.916373 2.12767,1.882133 v 24.85442 c 0,0.9666 -0.871353,1.88213 -2.13072,1.88213 H 63.344139 l -6.211425,1.87679 0.0633,-0.36604 -0.03431,-28.2473 c 0,-0.966516 0.870609,-1.882133 2.129956,-1.882133 z" />
|
||||
<g transform="matrix(2.1452134,0,0,2.5503116,-71.247407,-178.388)">
|
||||
<path d="m 62.57046,116.77004 v -1.31201 l 3.280018,-1.45904 q 0.158346,-0.0679 0.305381,-0.1018 0.158346,-0.0452 0.282761,-0.0679 0.135725,-0.0113 0.271449,-0.0226 v -0.0905 q -0.135724,-0.0113 -0.271449,-0.0452 -0.124415,-0.0226 -0.282761,-0.0566 -0.147035,-0.0452 -0.305381,-0.1131 l -3.280018,-1.45904 v -1.32332 l 5.067063,2.31863 v 1.4138 z" />
|
||||
<path d="m 62.308594,110.31055 v 1.90234 l 3.4375,1.5293 c 0.0073,0.003 0.0142,0.005 0.02148,0.008 -0.0073,0.003 -0.0142,0.005 -0.02148,0.008 l -3.4375,1.5293 v 1.89258 l 0.371093,-0.16992 5.220704,-2.39063 v -1.75 z m 0.52539,0.8164 4.541016,2.08008 v 1.07617 l -4.541016,2.07813 v -0.73242 l 3.119141,-1.38868 0.0039,-0.002 c 0.09141,-0.0389 0.178343,-0.0676 0.257813,-0.0859 h 0.0059 l 0.0078,-0.002 c 0.09483,-0.0271 0.176055,-0.0474 0.246093,-0.0606 l 0.498047,-0.041 v -0.57422 l -0.240234,-0.0195 c -0.07606,-0.006 -0.153294,-0.0198 -0.230469,-0.0391 l -0.0078,-0.002 -0.0078,-0.002 c -0.07608,-0.0138 -0.16556,-0.0318 -0.263672,-0.0527 -0.08398,-0.0262 -0.172736,-0.058 -0.265625,-0.0977 l -0.0039,-0.002 -3.119141,-1.38868 z" />
|
||||
</g>
|
||||
<g transform="matrix(2.1388566,0,0,2.4558588,-69.745456,-170.93962)">
|
||||
<path d="m 69.17132,117.75404 h 5.428996 v 1.27808 H 69.17132 Z" />
|
||||
<path d="m 68.908203,117.49219 v 0.26172 1.54101 h 5.955078 v -1.80273 z m 0.525391,0.52344 h 4.904297 v 0.7539 h -4.904297 z" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -361,7 +361,7 @@
|
||||
"account_upgrade_dialog_tier_price_per_month": "شهر",
|
||||
"prefs_appearance_theme_title": "السمة",
|
||||
"subscribe_dialog_subscribe_use_another_background_info": "لن يتم استلام الاشعارات من الخوادم الخارجية عندما يكون تطبيق الويب مغلقاً",
|
||||
"prefs_appearance_theme_system": "النظام (الافتراضي)",
|
||||
"prefs_system_default": "النظام الافتراضي",
|
||||
"prefs_notifications_min_priority_low_and_higher": "أولوية منخفضة وأعلى",
|
||||
"prefs_notifications_min_priority_default_and_higher": "الأولوية الافتراضية وما فوقها",
|
||||
"prefs_notifications_min_priority_high_and_higher": "أولوية عالية وأعلى"
|
||||
|
||||
@@ -243,8 +243,8 @@
|
||||
"account_basics_password_description": "Промяна на паролата на профила",
|
||||
"account_basics_tier_title": "Вид на профила",
|
||||
"account_basics_tier_admin": "Администратор",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(с {{tier}} ниво)",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(без ниво)",
|
||||
"account_basics_tier_admin_suffix_with_tier": "с {{tier}} ниво",
|
||||
"account_basics_tier_admin_suffix_no_tier": "без ниво",
|
||||
"account_basics_tier_free": "безплатен",
|
||||
"account_basics_tier_basic": "базов",
|
||||
"account_basics_tier_change_button": "Променяне",
|
||||
@@ -373,7 +373,7 @@
|
||||
"prefs_reservations_table_everyone_read_write": "Всички могат да публикуват и да се абонират",
|
||||
"reservation_delete_dialog_submit_button": "Премахване на резервирането",
|
||||
"account_tokens_description": "Използвайте код за достъп когато публикувате или се абонирате през ППИ на ntfy, за да не се налага да изпращате потребителско име и парола. Прочетете <Link>документацията</Link> за повече информация.",
|
||||
"account_tokens_delete_dialog_description": "Преди да премахвате код за достъп се уверете, че не се използва от приложения или скриптове. <strong>Действието е необратимо.</strong>",
|
||||
"account_tokens_delete_dialog_description": "Преди да премахвате код за достъп се уверете, че не се използва от приложения или скриптове. <strong>Действието е необратимо.</strong>.",
|
||||
"prefs_reservations_dialog_description": "Резервирането ви осигурява собственост върху темата и ви дава възможност да определяте права за достъп от други потребители.",
|
||||
"reservation_delete_dialog_action_keep_title": "Пазене на съобщения и прикачени файлове",
|
||||
"reservation_delete_dialog_action_keep_description": "Съобщенията и прикачените файлове, които са във временната памет на сървъра ще бъдат достъпни за всеки, който знае името на темата.",
|
||||
@@ -400,10 +400,64 @@
|
||||
"prefs_notifications_web_push_title": "Известия във фонов режим",
|
||||
"prefs_notifications_web_push_enabled_description": "Известията ще бъдат получавани даже и ако приложението за уеб не работи (чрез Web Push)",
|
||||
"prefs_appearance_theme_title": "Цветова тема",
|
||||
"prefs_appearance_theme_system": "Системна (подразбирана)",
|
||||
"prefs_system_default": "Системна подразбирана",
|
||||
"web_push_subscription_expiring_title": "Известията временно ще бъдат спрени",
|
||||
"web_push_subscription_expiring_body": "За да продължите да получавате известия, отворете ntfy",
|
||||
"action_bar_unmute_notifications": "Включване звука на известията",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Кодът за защита от външна система не може да бъде променян или премахван",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Потребител от външна система не може да бъде променян или премахван"
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Потребител от външна система не може да бъде променян или премахван",
|
||||
"common_close": "Затваряне",
|
||||
"common_refresh": "Презареждане",
|
||||
"email_verify_progress_title": "Проверяване адреса на ел. поща…",
|
||||
"email_verify_success_title": "Адресът на ел. поща е проверен",
|
||||
"email_verify_success_description": "Адресът на ел. поща е проверен е добавен към профила.",
|
||||
"email_verify_error_title": "Проверката не е успешна",
|
||||
"email_verify_error_description": "Препратката за проверка е повредена или с изтекла валидност. Вземете друга от настройките на профила.",
|
||||
"email_verify_button_account": "Към профила",
|
||||
"version_update_available_title": "Налично е ново издание",
|
||||
"version_update_available_description": "Сървърът на ntfy е обновен. Презаредете страницата.",
|
||||
"signup_form_email": "Адрес на ел. поща (по желание, с цел възстановяване)",
|
||||
"login_link_forgot_password": "Забравена парола",
|
||||
"reset_password_request_title": "Нулиране на парола",
|
||||
"reset_password_request_description": "Въведете потребителско име или адрес на ел. поща. Ако такъв профил съществува по пощата ще бъде изпратена препратка, от която да смените паролата си.",
|
||||
"reset_password_request_primary_required": "Работи само в случай, че сте задали и потвърдили основен адрес на ел. поща.",
|
||||
"reset_password_request_identifier_label": "Потребителско име или ел. поща",
|
||||
"reset_password_request_button_submit": "Изпращане",
|
||||
"reset_password_sent_title": "Проверете входящата поща",
|
||||
"reset_password_sent_description": "Ако такъв профил съществува на пощата е изпратена препратка, от която да смените паролата си.",
|
||||
"reset_password_back_to_login": "Към вписване",
|
||||
"reset_password_disabled": "Смяната на парола е изключена",
|
||||
"reset_password_title": "Задаване на нова парола",
|
||||
"reset_password_form_password": "Нова парола",
|
||||
"reset_password_form_confirm": "Потвърждаване на новата парола",
|
||||
"reset_password_form_button_submit": "Задаване на парола",
|
||||
"reset_password_form_error_invalid": "Препратката е неприемлива или изтекла. Поискайте нова.",
|
||||
"reset_password_success_title": "Паролата е променена",
|
||||
"reset_password_success_description": "Паролата е сменена. Можете да се впишете с нея.",
|
||||
"action_bar_reload": "Презареждане на приложението",
|
||||
"account_basics_emails_title": "Адреси на ел. поща",
|
||||
"account_basics_emails_description": "За известия и промяна на праролата",
|
||||
"account_basics_emails_no_emails_yet": "Не са зададени ел. адреси",
|
||||
"account_basics_emails_copied_to_clipboard": "Адресът на ел. поща е копиран",
|
||||
"account_basics_emails_chip_actions_primary": "Основен адрес, използван като ел. адрес по подразбиране. Щракнете за действия.",
|
||||
"account_basics_emails_chip_actions_verified": "Може да бъде използван за известия. Щракнете за действия.",
|
||||
"account_basics_emails_chip_actions_unverified": "Непотвърден адрес, проверете входящата поща. Щракнете за действия.",
|
||||
"account_basics_emails_unverified": "непотвърден",
|
||||
"account_basics_emails_set_primary": "Задаване като основен",
|
||||
"account_basics_emails_delete": "Премахване",
|
||||
"account_basics_emails_resend": "Повторно изпращане на писмо за потвърждаване",
|
||||
"account_basics_emails_resent": "Писмото е изпратено, проверете входящата поща",
|
||||
"account_basics_emails_primary_elsewhere": "Този адрес на ел. поща е използван като основен адрес на друг профил",
|
||||
"account_basics_emails_no_recovery_warning": "Добавете адрес на ел. поща, за да можете да възстановите профила ако забравите паролата.",
|
||||
"account_basics_emails_no_primary_warning": "Добавете основен адрес на ел. поща, за да можете да възстановите профила ако забравите паролата.",
|
||||
"account_basics_emails_dialog_title": "Добавяне адреси на ел. поща",
|
||||
"account_basics_emails_dialog_description": "Въведете адрес на ел. поща, който да бъде добавен към профила. Ще бъде изпратена препратка за потвърждение.",
|
||||
"account_basics_emails_dialog_email_label": "Адрес на ел. поща",
|
||||
"account_basics_emails_dialog_email_placeholder": "напр. user@example.com",
|
||||
"account_basics_emails_dialog_verify_button": "Препратка за потвърждаване",
|
||||
"account_basics_emails_dialog_check_inbox": "Проверете входящата поща и посетете препратката за потвърждаване. Докато не го направите адресът ще стои непотвърден.",
|
||||
"account_basics_tier_provisioned": "Потребител от външна система",
|
||||
"account_usage_emails_none": "Профилът не може да изпраща известия по ел. поща",
|
||||
"prefs_users_dialog_base_url_invalid": "Неприемлив формат на адрес. Трябва да започва с http:// или https://",
|
||||
"prefs_users_dialog_base_url_exists": "Потребител за този адрес на услуга вече съществува"
|
||||
}
|
||||
|
||||
@@ -398,7 +398,7 @@
|
||||
"prefs_notifications_web_push_disabled_description": "Oznámení jsou přijímána, když je webová aplikace spuštěna (přes WebSocket)",
|
||||
"prefs_notifications_web_push_disabled": "Zakázáno",
|
||||
"prefs_appearance_theme_title": "Motiv",
|
||||
"prefs_appearance_theme_system": "Systém (výchozí)",
|
||||
"prefs_system_default": "Systém výchozí",
|
||||
"prefs_appearance_theme_dark": "Tmavý režim",
|
||||
"prefs_appearance_theme_light": "Světlý režim",
|
||||
"web_push_subscription_expiring_title": "Oznámení budou pozastavena",
|
||||
|
||||
@@ -236,8 +236,8 @@
|
||||
"account_usage_limits_reset_daily": "Verbrauchslimits werden täglich um Mitternacht (UTC) zurückgesetzt",
|
||||
"account_basics_password_title": "Kennwort",
|
||||
"account_basics_tier_description": "Der Funktionsumfang Deines Konto-Levels",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(mit Level {{tier}})",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(kein Level)",
|
||||
"account_basics_tier_admin_suffix_with_tier": "mit Level {{tier}}",
|
||||
"account_basics_tier_admin_suffix_no_tier": "kein Level",
|
||||
"account_basics_tier_admin": "Admin",
|
||||
"account_basics_tier_basic": "Basic",
|
||||
"account_basics_tier_free": "Kostenlos",
|
||||
@@ -395,7 +395,7 @@
|
||||
"prefs_notifications_web_push_enabled": "Aktiviert für {{server}}",
|
||||
"prefs_notifications_web_push_disabled": "Deaktiviert",
|
||||
"prefs_appearance_theme_title": "Thema",
|
||||
"prefs_appearance_theme_system": "System (Standard)",
|
||||
"prefs_system_default": "System Standard",
|
||||
"prefs_appearance_theme_dark": "Nachtmodus",
|
||||
"prefs_appearance_theme_light": "Tagmodus",
|
||||
"error_boundary_button_reload_ntfy": "ntfy neu laden",
|
||||
@@ -405,5 +405,59 @@
|
||||
"web_push_unknown_notification_body": "Du musst möglicherweise ntfy aktualisieren, indem du die Web App öffnest",
|
||||
"prefs_notifications_web_push_enabled_description": "Benachrichtigungen werden empfangen, auch wenn die Web App nicht geöffnet ist (via Web Push)",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Bereitgestelltes Token kann nicht bearbeitet oder gelöscht werden",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Ein bereitgestellter Benutzer kann nicht bearbeitet oder gelöscht werden"
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Ein bereitgestellter Benutzer kann nicht bearbeitet oder gelöscht werden",
|
||||
"common_close": "Schließen",
|
||||
"common_refresh": "Aktualisieren",
|
||||
"email_verify_progress_title": "Deine E-Mail-Adresse wird verifiziert...",
|
||||
"email_verify_success_title": "E-Mail-Adresse verifiziert",
|
||||
"email_verify_success_description": "Deine E-Mail-Adresse wurde verifiziert und Deinem Konto hinzugefügt.",
|
||||
"email_verify_error_title": "Verifizierung fehlgeschlagen",
|
||||
"email_verify_error_description": "Dieser Verifizierungslink ist ungültig oder abgelaufen. Du kannst in Deinen Konto-Einstellungen einen neuen anfordern.",
|
||||
"email_verify_button_account": "Zum Konto",
|
||||
"version_update_available_title": "Neue Version verfügbar",
|
||||
"version_update_available_description": "Der ntfy-Server wurde aktualisiert. Bitte lade die Seite neu.",
|
||||
"signup_form_email": "E-Mail (optional, zur Konto-Wiederherstellung)",
|
||||
"login_link_forgot_password": "Kennwort vergessen",
|
||||
"reset_password_request_title": "Kennwort zurücksetzen",
|
||||
"reset_password_request_description": "Gib Deinen Benutzernamen oder Deine E-Mail-Adresse ein. Falls ein Konto existiert, wird ein Link zum Zurücksetzen des Kennworts per E-Mail gesendet.",
|
||||
"reset_password_request_primary_required": "Dies funktioniert nur, wenn Du bereits eine primäre E-Mail-Adresse hinzugefügt und verifiziert hast.",
|
||||
"reset_password_request_identifier_label": "Benutzername oder E-Mail",
|
||||
"reset_password_request_button_submit": "Link zum Zurücksetzen senden",
|
||||
"reset_password_sent_title": "Überprüfe Deinen Posteingang",
|
||||
"reset_password_sent_description": "Falls ein Konto existiert, wurde ein Link zum Zurücksetzen des Kennworts per E-Mail gesendet.",
|
||||
"reset_password_back_to_login": "Zurück zur Anmeldung",
|
||||
"reset_password_disabled": "Das Zurücksetzen des Kennworts ist deaktiviert",
|
||||
"reset_password_title": "Neues Kennwort festlegen",
|
||||
"reset_password_form_password": "Neues Kennwort",
|
||||
"reset_password_form_confirm": "Neues Kennwort bestätigen",
|
||||
"reset_password_form_button_submit": "Kennwort festlegen",
|
||||
"reset_password_form_error_invalid": "Dieser Link zum Zurücksetzen ist ungültig oder abgelaufen. Bitte fordere einen neuen an.",
|
||||
"reset_password_success_title": "Kennwort geändert",
|
||||
"reset_password_success_description": "Dein Kennwort wurde geändert. Du kannst Dich nun mit Deinem neuen Kennwort anmelden.",
|
||||
"action_bar_reload": "App neu laden",
|
||||
"account_basics_emails_title": "E-Mail-Adressen",
|
||||
"account_basics_emails_description": "Für E-Mail-Benachrichtigungen und das Zurücksetzen des Kennworts",
|
||||
"account_basics_emails_no_emails_yet": "Noch keine E-Mail-Adressen",
|
||||
"account_basics_emails_copied_to_clipboard": "E-Mail-Adresse in die Zwischenablage kopiert",
|
||||
"account_basics_emails_chip_actions_primary": "Primäre Adresse, wird als Deine standardmäßige E-Mail-Adresse verwendet. Klicke für Aktionen.",
|
||||
"account_basics_emails_chip_actions_verified": "Kann für Benachrichtigungen verwendet werden. Klicke für Aktionen.",
|
||||
"account_basics_emails_chip_actions_unverified": "Nicht verifizierte Adresse, überprüfe Deinen Posteingang, um sie zu verifizieren. Klicke für Aktionen.",
|
||||
"account_basics_emails_unverified": "nicht verifiziert",
|
||||
"account_basics_emails_set_primary": "Als primäre E-Mail-Adresse festlegen",
|
||||
"account_basics_emails_delete": "Adresse entfernen",
|
||||
"account_basics_emails_resend": "Verifizierungs-E-Mail erneut senden",
|
||||
"account_basics_emails_resent": "Verifizierungs-E-Mail gesendet, überprüfe Deinen Posteingang",
|
||||
"account_basics_emails_primary_elsewhere": "Diese E-Mail-Adresse wird als primäre Adresse eines anderen Kontos verwendet",
|
||||
"account_basics_emails_no_recovery_warning": "Füge mindestens eine E-Mail-Adresse hinzu, damit Du Dein Konto wiederherstellen kannst, falls Du Dein Kennwort verlierst.",
|
||||
"account_basics_emails_no_primary_warning": "Füge eine primäre E-Mail-Adresse hinzu, damit Du Dein Konto wiederherstellen kannst, falls Du Dein Kennwort verlierst.",
|
||||
"account_basics_emails_dialog_title": "E-Mail-Adresse hinzufügen",
|
||||
"account_basics_emails_dialog_description": "Gib eine E-Mail-Adresse ein, um sie Deinem Konto hinzuzufügen. Es wird ein Verifizierungslink gesendet, um zu bestätigen, dass sie Dir gehört.",
|
||||
"account_basics_emails_dialog_email_label": "E-Mail-Adresse",
|
||||
"account_basics_emails_dialog_email_placeholder": "z.B. user@example.com",
|
||||
"account_basics_emails_dialog_verify_button": "Verifizierungslink senden",
|
||||
"account_basics_emails_dialog_check_inbox": "Überprüfe Deinen Posteingang und klicke auf den Verifizierungslink, um diese E-Mail-Adresse zu bestätigen. Sie wird als nicht verifiziert angezeigt, bis Du dies tust.",
|
||||
"account_basics_tier_provisioned": "Bereitgestellt",
|
||||
"account_usage_emails_none": "Mit diesem Konto können keine E-Mail-Benachrichtigungen gesendet werden",
|
||||
"prefs_users_dialog_base_url_invalid": "Ungültiges URL-Format. Muss mit http:// oder https:// beginnen",
|
||||
"prefs_users_dialog_base_url_exists": "Für diese Service-URL existiert bereits ein Benutzer"
|
||||
}
|
||||
|
||||
@@ -3,12 +3,20 @@
|
||||
"common_save": "Save",
|
||||
"common_add": "Add",
|
||||
"common_back": "Back",
|
||||
"common_close": "Close",
|
||||
"common_copy_to_clipboard": "Copy to clipboard",
|
||||
"common_refresh": "Refresh",
|
||||
"email_verify_progress_title": "Verifying your email...",
|
||||
"email_verify_success_title": "Email verified",
|
||||
"email_verify_success_description": "Your email address has been verified and added to your account.",
|
||||
"email_verify_error_title": "Verification failed",
|
||||
"email_verify_error_description": "This verification link is invalid or has expired. You can request a new one from your account settings.",
|
||||
"email_verify_button_account": "Go to account",
|
||||
"version_update_available_title": "New version available",
|
||||
"version_update_available_description": "The ntfy server has been updated. Please refresh the page.",
|
||||
"signup_title": "Create a ntfy account",
|
||||
"signup_form_username": "Username",
|
||||
"signup_form_email": "Email (optional, for account recovery)",
|
||||
"signup_form_password": "Password",
|
||||
"signup_form_confirm_password": "Confirm password",
|
||||
"signup_form_button_submit": "Sign up",
|
||||
@@ -20,6 +28,23 @@
|
||||
"login_title": "Sign in to your ntfy account",
|
||||
"login_form_button_submit": "Sign in",
|
||||
"login_link_signup": "Sign up",
|
||||
"login_link_forgot_password": "Forgot password",
|
||||
"reset_password_request_title": "Reset password",
|
||||
"reset_password_request_description": "Enter your username or email address. If an account exists, a link to reset your password will be emailed.",
|
||||
"reset_password_request_primary_required": "This only works if you already added a primary email address and verified it.",
|
||||
"reset_password_request_identifier_label": "Username or email",
|
||||
"reset_password_request_button_submit": "Send reset link",
|
||||
"reset_password_sent_title": "Check your inbox",
|
||||
"reset_password_sent_description": "If an account exists, a link to reset your password has been emailed.",
|
||||
"reset_password_back_to_login": "Back to sign-in",
|
||||
"reset_password_disabled": "Password reset is disabled",
|
||||
"reset_password_title": "Set a new password",
|
||||
"reset_password_form_password": "New password",
|
||||
"reset_password_form_confirm": "Confirm new password",
|
||||
"reset_password_form_button_submit": "Set password",
|
||||
"reset_password_form_error_invalid": "This reset link is invalid or has expired. Please request a new one.",
|
||||
"reset_password_success_title": "Password changed",
|
||||
"reset_password_success_description": "Your password has been changed. You can now sign in with your new password.",
|
||||
"login_disabled": "Login is disabled",
|
||||
"action_bar_show_menu": "Show menu",
|
||||
"action_bar_logo_alt": "ntfy logo",
|
||||
@@ -42,6 +67,7 @@
|
||||
"action_bar_profile_logout": "Logout",
|
||||
"action_bar_sign_in": "Sign in",
|
||||
"action_bar_sign_up": "Sign up",
|
||||
"action_bar_reload": "Reload app",
|
||||
"message_bar_type_message": "Type a message here",
|
||||
"message_bar_error_publishing": "Error publishing notification",
|
||||
"message_bar_show_dialog": "Show publish dialog",
|
||||
@@ -216,18 +242,26 @@
|
||||
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
||||
"account_basics_phone_numbers_dialog_channel_call": "Call",
|
||||
"account_basics_emails_title": "Email addresses",
|
||||
"account_basics_emails_description": "For email notifications",
|
||||
"account_basics_emails_no_emails_yet": "No verified emails yet",
|
||||
"account_basics_emails_description": "For email notifications and password reset",
|
||||
"account_basics_emails_no_emails_yet": "No emails yet",
|
||||
"account_basics_emails_copied_to_clipboard": "Email address copied to clipboard",
|
||||
"account_basics_emails_chip_actions_primary": "Primary address, used as your default email address. Click for actions.",
|
||||
"account_basics_emails_chip_actions_verified": "Can be used for notifications. Click for actions.",
|
||||
"account_basics_emails_chip_actions_unverified": "Unverified address, check your inbox to verify. Click for actions.",
|
||||
"account_basics_emails_unverified": "unverified",
|
||||
"account_basics_emails_set_primary": "Set as primary email",
|
||||
"account_basics_emails_delete": "Remove address",
|
||||
"account_basics_emails_resend": "Resend verification email",
|
||||
"account_basics_emails_resent": "Verification email sent, check your inbox",
|
||||
"account_basics_emails_primary_elsewhere": "This email address is used as the primary address on another account",
|
||||
"account_basics_emails_no_recovery_warning": "Add at least one email address to ensure you can recover your account if you lose your password.",
|
||||
"account_basics_emails_no_primary_warning": "Add a primary email address to ensure you can recover your account if you lose your password.",
|
||||
"account_basics_emails_dialog_title": "Add email address",
|
||||
"account_basics_emails_dialog_description": "To receive email notifications, you need to add and verify at least one email address. A verification code will be sent to your email.",
|
||||
"account_basics_emails_dialog_description": "Enter an email address to add it to your account. A verification link will be sent to confirm it is yours.",
|
||||
"account_basics_emails_dialog_email_label": "Email address",
|
||||
"account_basics_emails_dialog_email_placeholder": "e.g. user@example.com",
|
||||
"account_basics_emails_dialog_verify_button": "Add email",
|
||||
"account_basics_emails_dialog_code_label": "Verification code",
|
||||
"account_basics_emails_dialog_code_placeholder": "e.g. 123456",
|
||||
"account_basics_emails_dialog_code_invalid": "Verification code is invalid or expired",
|
||||
"account_basics_emails_dialog_check_verification_button": "Confirm",
|
||||
"account_basics_emails_dialog_verify_button": "Send verification link",
|
||||
"account_basics_emails_dialog_check_inbox": "Check your inbox and click the verification link to confirm this email address. It will appear as unverified until you do.",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "A provisioned user cannot be edited or deleted",
|
||||
"account_usage_title": "Usage",
|
||||
"account_usage_of_limit": "of {{limit}}",
|
||||
@@ -236,9 +270,10 @@
|
||||
"account_basics_tier_title": "Account type",
|
||||
"account_basics_tier_description": "Your account's power level",
|
||||
"account_basics_tier_admin": "Admin",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(with {{tier}} tier)",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(no tier)",
|
||||
"account_basics_tier_admin_suffix_with_tier": "with {{tier}} tier",
|
||||
"account_basics_tier_admin_suffix_no_tier": "no tier",
|
||||
"account_basics_tier_basic": "Basic",
|
||||
"account_basics_tier_provisioned": "Provisioned",
|
||||
"account_basics_tier_free": "Free",
|
||||
"account_basics_tier_interval_monthly": "monthly",
|
||||
"account_basics_tier_interval_yearly": "annually",
|
||||
@@ -286,7 +321,6 @@
|
||||
"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_total_size": "{{totalsize}} total storage",
|
||||
"account_upgrade_dialog_tier_price_per_month": "month",
|
||||
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} per year. Billed monthly.",
|
||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} billed annually. Save {{save}}.",
|
||||
@@ -378,9 +412,17 @@
|
||||
"prefs_appearance_title": "Appearance",
|
||||
"prefs_appearance_language_title": "Language",
|
||||
"prefs_appearance_theme_title": "Theme",
|
||||
"prefs_appearance_theme_system": "System (default)",
|
||||
"prefs_system_default": "System default",
|
||||
"prefs_appearance_theme_dark": "Dark mode",
|
||||
"prefs_appearance_theme_light": "Light mode",
|
||||
"prefs_appearance_date_format_title": "Date format",
|
||||
"prefs_appearance_date_format_iso8601": "ISO 8601",
|
||||
"prefs_appearance_date_format_dmy": "Day/Month/Year",
|
||||
"prefs_appearance_date_format_dmy_dot": "Day.Month.Year",
|
||||
"prefs_appearance_date_format_mdy": "Month/Day/Year",
|
||||
"prefs_appearance_time_format_title": "Time format",
|
||||
"prefs_appearance_time_format_12h": "12-hour",
|
||||
"prefs_appearance_time_format_24h": "24-hour",
|
||||
"prefs_reservations_title": "Reserved topics",
|
||||
"prefs_reservations_description": "You can reserve topic names for personal use here. Reserving a topic gives you ownership over the topic, and allows you to define access permissions for other users over the topic.",
|
||||
"prefs_reservations_limit_reached": "You reached your reserved topics limit.",
|
||||
@@ -418,7 +460,6 @@
|
||||
"error_boundary_button_copy_stack_trace": "Copy stack trace",
|
||||
"error_boundary_button_reload_ntfy": "Reload ntfy",
|
||||
"error_boundary_stack_trace": "Stack trace",
|
||||
"error_boundary_gathering_info": "Gather more info …",
|
||||
"error_boundary_unsupported_indexeddb_title": "Private browsing not supported",
|
||||
"error_boundary_unsupported_indexeddb_description": "The ntfy web app needs IndexedDB to function, and your browser does not support IndexedDB in private browsing mode.<br/><br/>While this is unfortunate, it also doesn't really make a lot of sense to use the ntfy web app in private browsing mode anyway, because everything is stored in the browser storage. You can read more about it <githubLink>in this GitHub issue</githubLink>, or talk to us on <discordLink>Discord</discordLink> or <matrixLink>Matrix</matrixLink>.",
|
||||
"web_push_subscription_expiring_title": "Notifications will be paused",
|
||||
|
||||
@@ -395,7 +395,7 @@
|
||||
"prefs_notifications_web_push_enabled_description": "Las notificaciones se reciben incluso cuando la aplicación web no se está ejecutando (a través de Web Push)",
|
||||
"prefs_notifications_web_push_disabled": "Desactivado",
|
||||
"prefs_appearance_theme_title": "Tema",
|
||||
"prefs_appearance_theme_system": "Sistema (por defecto)",
|
||||
"prefs_system_default": "Sistema por defecto",
|
||||
"error_boundary_button_reload_ntfy": "Volver a cargar ntfy",
|
||||
"web_push_subscription_expiring_title": "Las notificaciones se pausarán",
|
||||
"prefs_notifications_web_push_disabled_description": "Las notificaciones se reciben cuando la aplicación web se está ejecutando (a través de WebSocket)",
|
||||
@@ -406,5 +406,25 @@
|
||||
"web_push_unknown_notification_title": "Notificación desconocida recibida del servidor",
|
||||
"web_push_unknown_notification_body": "Puede que necesites actualizar ntfy abriendo la aplicación web",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Un usuario provisionado no se puede editar o eliminar",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "No se puede editar o eliminar un token provisionado"
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "No se puede editar o eliminar un token provisionado",
|
||||
"common_refresh": "Recargar",
|
||||
"email_verify_progress_title": "Verificando tu correo...",
|
||||
"email_verify_success_title": "Correo verificado",
|
||||
"email_verify_success_description": "Tu correo electronico ha sido verificado y añadido a tu cuenta.",
|
||||
"email_verify_error_title": "La verificacion falló",
|
||||
"email_verify_error_description": "Este enlace de verificacion es invalido o ha expirado. Puedes solicitar unon nuevo desde los ajustes de tu cuenta.",
|
||||
"email_verify_button_account": "Ir a cuenta",
|
||||
"version_update_available_title": "Nueva versión disponible",
|
||||
"version_update_available_description": "El servidor ntfy ha sido actualizado. Por favor recarga la página.",
|
||||
"signup_form_email": "Correo (opcional, para recuperación de cuenta)",
|
||||
"login_link_forgot_password": "Olvidé mi contraseña",
|
||||
"reset_password_request_title": "Reiniciar contraseña",
|
||||
"reset_password_request_description": "Introduce tu usuario o correo electrónico. Si una cuenta existe, un enlace para reiniciar tu contraseña será enviado a tu correo.",
|
||||
"reset_password_request_primary_required": "Esto solo funciona si ya añadiste un correo eletrónico primario y lo verificaste.",
|
||||
"reset_password_request_identifier_label": "Usuario o correo",
|
||||
"reset_password_request_button_submit": "Enviar enlace de reinicio",
|
||||
"reset_password_sent_title": "Checa tu bandeja de entrada",
|
||||
"reset_password_sent_description": "Si una cuenta existe, un enlace para reiniciar tu contraseña se acaba de enviar a tu correo.",
|
||||
"reset_password_back_to_login": "Regresar a iniciar sesión",
|
||||
"reset_password_disabled": "Reinicio de contraseña ha sido deshabilitado"
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@
|
||||
"account_basics_tier_title": "Kasutajakonto tüüp",
|
||||
"account_basics_tier_description": "Sinu kasutajakonto õigused",
|
||||
"account_delete_dialog_button_submit": "Kustuta kasutajakonto jäädavalt",
|
||||
"prefs_appearance_theme_system": "Süsteemi kujundus",
|
||||
"prefs_system_default": "Süsteemi kujundus",
|
||||
"prefs_appearance_theme_dark": "Tume kujundus",
|
||||
"prefs_appearance_theme_light": "Hele kujundus",
|
||||
"prefs_reservations_title": "Reserveeritud teemad",
|
||||
@@ -332,8 +332,8 @@
|
||||
"subscribe_dialog_error_user_not_authorized": "Kasutajal {{username}} puudub volitus",
|
||||
"account_usage_of_limit": "piirangust {{limit}}",
|
||||
"account_usage_limits_reset_daily": "Kasutuspiirangud lähtestatakse keskööl (UTC järgi)",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(tasemega {{tier}})",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(tase puudub)",
|
||||
"account_basics_tier_admin_suffix_with_tier": "tasemega {{tier}}",
|
||||
"account_basics_tier_admin_suffix_no_tier": "tase puudub",
|
||||
"account_upgrade_dialog_title": "Muuda kasutajakonto taset",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} faili kohta",
|
||||
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} kõnet päevas",
|
||||
@@ -405,5 +405,59 @@
|
||||
"account_upgrade_dialog_reservations_warning_other": "Sinu praegune teenusepakett võimaldab senise paketiga võrreldes reserveerida vähem teemasid. Enne paketi muutmist <strong>palun esmalt kustuta vähemalt {{count}} reserveeringut</strong>. Seda saad <Link>teha siin</Link>.",
|
||||
"prefs_users_description": "Oma kaitstud teemade kasutajaid saad lisada ja eemaldada siin. Palun arvesta, et kasutajanimi ja salasõna on salvestatud veebibrauseri kohalikus andmeruumis.",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Eelsisestatud kasutajat ei saa muuta ega kustutada",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Eelsisestatud tunnusluba ei saa muuta ega kustutada"
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Eelsisestatud tunnusluba ei saa muuta ega kustutada",
|
||||
"common_close": "Sulge",
|
||||
"common_refresh": "Värskenda andmeid",
|
||||
"email_verify_progress_title": "Kontrollin sinu e-posti aadressi…",
|
||||
"email_verify_success_title": "E-posti aadress on kontrollitud",
|
||||
"email_verify_success_description": "Sinu a-posti aadress on kontrollitud ja lisatud sinu kasutajakontole.",
|
||||
"email_verify_error_title": "Õigsuse kontrollimine ei õnnestunud",
|
||||
"login_link_forgot_password": "Unustasin salasõna",
|
||||
"reset_password_request_title": "Lähtesta salasõna",
|
||||
"reset_password_request_description": "Sisesta oma kasutajanimi või e-posti aadress. Kui selline kasutajakonto on olemas, siis saadame seotud e-posti aadressile kirja salasõna lähtestamise lingiga.",
|
||||
"reset_password_request_primary_required": "Selline võimalus toimib vaid siis, kui kasutajakontoga on seotud põhiline e-posti aadress ja see on kinnitatud olekus.",
|
||||
"reset_password_request_identifier_label": "Kasutajanimi või e-posti aadress",
|
||||
"reset_password_request_button_submit": "Saada lähtestamise link",
|
||||
"reset_password_sent_title": "Vaata oma saabuvate e-kirjade postkasti",
|
||||
"version_update_available_title": "Saadaval on uus versioon",
|
||||
"version_update_available_description": "ntfy server on uuendatud. Palun laadi leht uuesti.",
|
||||
"signup_form_email": "E-posti aadress (pole kohustuslik, aga on kasutatav kasutajakontole ligipääsu taastamisel)",
|
||||
"reset_password_back_to_login": "Tagasi sisselogimiseks",
|
||||
"reset_password_disabled": "Salasõnade lähtestamise võimalus on lülitatud välja",
|
||||
"reset_password_title": "Sisesta uus salasõna",
|
||||
"reset_password_form_password": "Uus salasõna",
|
||||
"reset_password_form_confirm": "Kinnita uus salasõna",
|
||||
"reset_password_form_button_submit": "Sisesta salasõna",
|
||||
"reset_password_form_error_invalid": "Lähtestamise link on vigane või aegunud. Palun saada see link endale uuesti.",
|
||||
"reset_password_success_title": "Salasõna on muudetud",
|
||||
"reset_password_success_description": "Sinu salasõna on muudetud. Saad nüüd uue salasõnaga sisse logida.",
|
||||
"action_bar_reload": "Laadi rakendus uuesti",
|
||||
"account_basics_emails_title": "E-posti aadressid",
|
||||
"account_basics_emails_description": "E-kirjaga saadetavate teavituste ja salasõna lähtestamise jaoks",
|
||||
"account_basics_emails_no_emails_yet": "E-posti aadresse veel pole",
|
||||
"account_basics_emails_copied_to_clipboard": "E-posti aadress on kopeeritud lõikelauale",
|
||||
"account_basics_emails_delete": "Eemalda aadress",
|
||||
"account_basics_emails_set_primary": "Määra põhiliseks e-posti aadressika",
|
||||
"account_basics_emails_resend": "Saada kinnituskiri uuesti",
|
||||
"account_basics_emails_resent": "Kinnituskiri on saadetud, vaata oma saabuvate kirjade postkasti",
|
||||
"account_basics_emails_dialog_email_label": "E-posti aadress",
|
||||
"account_basics_emails_dialog_email_placeholder": "nt. kadri@toredomeen.com",
|
||||
"prefs_users_dialog_base_url_exists": "Selle teenuse võrguaadressi jaoks on kasutaja juba olemas",
|
||||
"prefs_users_dialog_base_url_invalid": "Vigane võrguaadressi vorming. Alguses peab olema http:// või https://",
|
||||
"account_basics_emails_dialog_verify_button": "Saada kinnituslink",
|
||||
"account_basics_emails_dialog_check_inbox": "Vaata oma saabuvate e-kirjade postkasti ja klõpsa selle e-posti aadressi kinnituskirjas leiduvat linki. Seni on e-posti aadress kinnitamata olekus.",
|
||||
"account_basics_tier_provisioned": "Ettevalmistatud",
|
||||
"account_basics_emails_chip_actions_primary": "Põhiline e-posti aadress, mida kasutatakse vaikimisi aadressina. Tegevuste nägemiseks klõpsa.",
|
||||
"account_basics_emails_chip_actions_verified": "Võid kasutada ka teavituste jaoks. Tegevuste nägemiseks klõpsa.",
|
||||
"account_basics_emails_chip_actions_unverified": "Kinnitamata e-posti aadress, kinnitamiseks vaata oma saabuvate kirjade postkasti. Tegevuste nägemiseks klõpsa.",
|
||||
"account_basics_emails_unverified": "kinnitamata",
|
||||
"email_verify_error_description": "Kinnituse link on vigane või aegunud. Oma kasutajakonto seadistustest võid saata uue.",
|
||||
"email_verify_button_account": "Mine kasutajakonto juurde",
|
||||
"reset_password_sent_description": "Kui selline kasutajakonto on olemas, saadetakse sinna e-kiri salasõna lähtestamiseks vajaliku lingiga.",
|
||||
"account_basics_emails_primary_elsewhere": "Seda e-posti aadressi kasutatakse põhilise e-postiaadressina ühe teise kasutajakonto juures",
|
||||
"account_basics_emails_no_recovery_warning": "Tagamaks, et saada peale salasõna unustamist/kaotamist vajadusel taastada ligipääsu oma kasutajakontole lisa vähemalt üks e-posti aadress.",
|
||||
"account_basics_emails_no_primary_warning": "Tagamaks, et saada peale salasõna unustamist/kaotamist vajadusel taastada ligipääsu oma kasutajakontole põhiline e-posti aadress.",
|
||||
"account_basics_emails_dialog_title": "Lisa e-posti aadress",
|
||||
"account_basics_emails_dialog_description": "Sisesta kasutajakontole lisatav e-posti aadress. Kinnitamaks, et tegemist on sinu e-posti aadressiga saadame sinna kinnituslingi.",
|
||||
"account_usage_emails_none": "Selle kasutajakontoga seotud teavitusi pole võimalik saata"
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@
|
||||
"alert_notification_ios_install_required_title": "iOS-asennus vaaditaan",
|
||||
"publish_dialog_checkbox_markdown": "Muotoile Markdownina",
|
||||
"prefs_notifications_web_push_title": "Taustailmoitukset",
|
||||
"prefs_appearance_theme_system": "Järjestelmä (oletus)",
|
||||
"prefs_system_default": "Järjestelmä oletus",
|
||||
"alert_notification_permission_denied_description": "Ota ilmoitukset uudelleen käyttöön selaimessa",
|
||||
"prefs_appearance_theme_title": "Teema",
|
||||
"prefs_appearance_theme_light": "Vaalea tila",
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
"notifications_actions_not_supported": "Cette action n'est pas supportée dans l'application web",
|
||||
"notifications_actions_http_request_title": "Envoyer une requête HTTP {{method}} à {{url}}",
|
||||
"publish_dialog_attachment_limits_quota_reached": "quota dépassé, {{remainingBytes}} restants",
|
||||
"publish_dialog_tags_placeholder": "Liste d'étiquettes séparée par des virgules, par ex. avertissement, backup-srv1",
|
||||
"publish_dialog_tags_placeholder": "Liste d'étiquettes séparées par des virgules, par ex. avertissement, backup-srv1",
|
||||
"publish_dialog_priority_label": "Priorité",
|
||||
"publish_dialog_click_label": "URL du clic",
|
||||
"publish_dialog_click_placeholder": "URL ouverte lors d'un clic sur la notification",
|
||||
@@ -248,8 +248,8 @@
|
||||
"account_basics_tier_title": "Type de compte",
|
||||
"account_basics_tier_description": "Le niveau de puissance de votre compte",
|
||||
"account_basics_tier_admin": "Administrateur",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(avec le tarif {{tier}})",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(pas de tarif)",
|
||||
"account_basics_tier_admin_suffix_with_tier": "avec le niveau {{tier}}",
|
||||
"account_basics_tier_admin_suffix_no_tier": "pas de niveau",
|
||||
"account_basics_tier_free": "Gratuit",
|
||||
"account_basics_tier_upgrade_button": "Passer à Pro",
|
||||
"account_basics_tier_change_button": "Changer",
|
||||
@@ -396,7 +396,7 @@
|
||||
"prefs_notifications_web_push_enabled": "Activé pour {{server}}",
|
||||
"prefs_notifications_web_push_disabled": "Désactivé",
|
||||
"prefs_appearance_theme_title": "Thème",
|
||||
"prefs_appearance_theme_system": "Système (défaut)",
|
||||
"prefs_system_default": "Système défaut",
|
||||
"prefs_appearance_theme_dark": "Mode sombre",
|
||||
"prefs_appearance_theme_light": "Mode clair",
|
||||
"error_boundary_button_reload_ntfy": "Recharger ntfy",
|
||||
@@ -405,5 +405,59 @@
|
||||
"web_push_unknown_notification_title": "Notification inconnue reçue du serveur",
|
||||
"web_push_unknown_notification_body": "Il est possible que vous deviez mettre à jour ntfy en ouvrant l'application web",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Un utilisateur provisionné ne peut pas être modifié ou supprimé",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Impossible de modifier ou de supprimer le jeton provisionné"
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Impossible de modifier ou de supprimer le jeton provisionné",
|
||||
"common_close": "Fermer",
|
||||
"common_refresh": "Rafraichir",
|
||||
"email_verify_progress_title": "Vérification de votre email...",
|
||||
"email_verify_success_title": "Email vérifié",
|
||||
"email_verify_success_description": "Votre adresse mail a été vérifiée et ajoutée à votre compte.",
|
||||
"email_verify_error_title": "Echec de la vérification",
|
||||
"email_verify_error_description": "Ce lien de vérification est invalide ou a expiré. Vous pouvez en demander un nouveau depuis les paramètres de votre compte.",
|
||||
"email_verify_button_account": "Aller au compte",
|
||||
"version_update_available_title": "Nouvelle version disponible",
|
||||
"version_update_available_description": "Le serveur ntfy a été lis à jour. Veuillez rafraichir la page.",
|
||||
"signup_form_email": "Email (optionnel, pour la récupération du compte)",
|
||||
"login_link_forgot_password": "Mot de passe oublié",
|
||||
"reset_password_request_title": "Réinitialiser le mot de passe",
|
||||
"reset_password_request_description": "Entrer votre nom d'utilisateur ou une adresse mail. Si un compte existe, un lien pour réinitialiser votre mot de passe sera envoyé par mail.",
|
||||
"reset_password_request_primary_required": "Ceci ne fonctionne que si vous avez ajouté une adresse mail primaire et que vous l'avez vérifiée.",
|
||||
"reset_password_request_identifier_label": "Nom d'utilisateur ou email",
|
||||
"reset_password_request_button_submit": "Envoyer un lien de réinitialisation",
|
||||
"reset_password_sent_title": "Vérifiez votre boîte de réception",
|
||||
"reset_password_sent_description": "Si un compte existe, un lien pour réinitialiser votre mot de passe sera envoyé par mail.",
|
||||
"reset_password_back_to_login": "Retour à l'authentification",
|
||||
"reset_password_disabled": "La réinitialisation du mot de passe est désactivée",
|
||||
"reset_password_title": "Définir un nouveau mot de passe",
|
||||
"reset_password_form_password": "Nouveau mot de passe",
|
||||
"reset_password_form_confirm": "Confirmer le nouveau mot de passe",
|
||||
"reset_password_form_button_submit": "Définir le mot de passe",
|
||||
"reset_password_form_error_invalid": "Ce lien de réinitialisation est invalide ou a expiré. Merci d'en demander un nouveau.",
|
||||
"reset_password_success_title": "Mot de passe modifié",
|
||||
"reset_password_success_description": "Votre mot de passe a été changé. Vous pouvez maintenant vous authentifier avec votre nouveau mot de passe.",
|
||||
"action_bar_reload": "Recharger l'application",
|
||||
"account_basics_emails_title": "Adresses mail",
|
||||
"account_basics_emails_description": "Pour les notifications par mail et la réinitialisation du mot de passe",
|
||||
"account_basics_emails_no_emails_yet": "Pas encore d'emails",
|
||||
"account_basics_emails_copied_to_clipboard": "Adresse mail copiée dans le presse papiers",
|
||||
"account_basics_emails_chip_actions_primary": "Adresse primaire, utilisée comme votre adresse mail par défaut. Cliquer pour choisir l'action.",
|
||||
"account_basics_emails_chip_actions_verified": "Peut être utilisée pour les notifications. Cliquer pour choisir l'action.",
|
||||
"account_basics_emails_chip_actions_unverified": "Adresse non vérifiée, vérifier votre boîte de réception pour la vérifier. Cliquer pour choisir l'action.",
|
||||
"account_basics_emails_unverified": "Non vérifié",
|
||||
"account_basics_emails_set_primary": "Définir comme adresse mail primaire",
|
||||
"account_basics_emails_delete": "Supprimer l'adresse",
|
||||
"account_basics_emails_resend": "Renvoyer le mail de vérification",
|
||||
"account_basics_emails_resent": "Mail de vérification envoyé, vérifier votre boite de réception",
|
||||
"account_basics_emails_primary_elsewhere": "Cette adresse mail est utilisée comme adresse primaire d'un autre compte",
|
||||
"account_basics_emails_no_recovery_warning": "Ajouter au moins une adresse mail pour garantir que vous pouvez récupérer votre compte si vous oubliez votre mot de passe.",
|
||||
"account_basics_emails_no_primary_warning": "Ajouter une adresse mail primaire pour garantir la récupération de votre compte si vous oubliez votre mot de paqsse.",
|
||||
"account_basics_emails_dialog_title": "Ajouter une adresse mail",
|
||||
"account_basics_emails_dialog_description": "Entrer une adresse mail pour l'ajouter à votre compte. Un lien de vérification sera envoyé pour confirmer votre adresse.",
|
||||
"account_basics_emails_dialog_email_label": "Adresse mail",
|
||||
"account_basics_emails_dialog_email_placeholder": "par ex. utilisateur@example.com",
|
||||
"account_basics_emails_dialog_verify_button": "Envoyer le lien de vérification",
|
||||
"account_basics_emails_dialog_check_inbox": "Vérifier votre boîte de réception et cliquer sur le lien de vérification pour confirmer cette adresse mail. Elle apparaitra non vérifiée tant qu'elle n'aura pas été vérifiée.",
|
||||
"account_basics_tier_provisioned": "Provisionné",
|
||||
"account_usage_emails_none": "Aucune notification par mail ne peut être envoyée avec ce compte",
|
||||
"prefs_users_dialog_base_url_invalid": "Format d'URL invalide. Doit commencer par http:// ou https://",
|
||||
"prefs_users_dialog_base_url_exists": "Un utilisateur pour cette URL existe déjà"
|
||||
}
|
||||
|
||||
@@ -398,7 +398,7 @@
|
||||
"prefs_notifications_web_push_enabled": "Activadas para {{server}}",
|
||||
"prefs_notifications_web_push_disabled": "Desactivadas",
|
||||
"prefs_appearance_theme_title": "Decorado",
|
||||
"prefs_appearance_theme_system": "Sistema (por defecto)",
|
||||
"prefs_system_default": "Sistema por defecto",
|
||||
"prefs_appearance_theme_dark": "Modo escuro",
|
||||
"prefs_appearance_theme_light": "Modo claro",
|
||||
"error_boundary_button_reload_ntfy": "Recargar ntfy",
|
||||
|
||||
+399
-163
@@ -1,227 +1,463 @@
|
||||
{
|
||||
"action_bar_send_test_notification": "Teszt értesítés küldése",
|
||||
"action_bar_clear_notifications": "Összes értesítés törlése",
|
||||
"alert_not_supported_description": "A böngésződ nem támogatja az értesítések fogadását",
|
||||
"action_bar_send_test_notification": "Tesztértesítés küldése",
|
||||
"action_bar_clear_notifications": "Az összes értesítés törlése",
|
||||
"alert_not_supported_description": "A böngésző nem támogatja az értesítéseket",
|
||||
"action_bar_settings": "Beállítások",
|
||||
"action_bar_unsubscribe": "Leiratkozás",
|
||||
"message_bar_type_message": "Írd ide az üzenetet",
|
||||
"message_bar_error_publishing": "Hiba történt az értesítés elküldése közben",
|
||||
"nav_button_all_notifications": "Összes értesítés",
|
||||
"message_bar_type_message": "Írj ide egy üzenetet",
|
||||
"message_bar_error_publishing": "Hiba az értesítés közzétételénél",
|
||||
"nav_button_all_notifications": "Minden értesítés",
|
||||
"nav_topics_title": "Feliratkozott témák",
|
||||
"alert_notification_permission_required_title": "Az értesítések le vannak tiltva",
|
||||
"alert_notification_permission_required_description": "Engedélyezd a böngésződnek, hogy asztali értesítéseket jelenítsen meg",
|
||||
"alert_notification_permission_required_description": "Engedélyezze a böngészőjében az asztali értesítések megjelenítését",
|
||||
"nav_button_settings": "Beállítások",
|
||||
"nav_button_documentation": "Dokumentáció",
|
||||
"nav_button_publish_message": "Értesítés küldése",
|
||||
"alert_notification_permission_required_button": "Engedélyezés",
|
||||
"alert_not_supported_title": "Az értesítések nincsenek támogatva",
|
||||
"notifications_copied_to_clipboard": "Vágólapra másolva",
|
||||
"nav_button_publish_message": "Értesítés közzététele",
|
||||
"alert_notification_permission_required_button": "Jelentkezz most",
|
||||
"alert_not_supported_title": "Az értesítések nem támogatottak",
|
||||
"notifications_copied_to_clipboard": "A vágólapra másolva",
|
||||
"notifications_tags": "Címkék",
|
||||
"notifications_attachment_copy_url_title": "Másolja vágólapra a csatolmány URL-ét",
|
||||
"notifications_attachment_copy_url_title": "Copy attachment URL to clipboard",
|
||||
"notifications_attachment_copy_url_button": "URL másolása",
|
||||
"notifications_attachment_open_title": "Menjen a(z) {{url}} címre",
|
||||
"notifications_attachment_open_button": "Csatolmány megnyitása",
|
||||
"notifications_attachment_link_expired": "A letöltési link lejárt",
|
||||
"notifications_attachment_link_expires": "A hivatkozás {{date}}-kor jár le",
|
||||
"nav_button_subscribe": "Feliratkozás témára",
|
||||
"notifications_click_copy_url_title": "Másolja vágólapra a hivatkozás URL-ét",
|
||||
"notifications_actions_open_url_title": "Menjen a(z) {{url}} címre",
|
||||
"notifications_actions_not_supported": "A művelet nem támogatott a webes alkalmazásban",
|
||||
"notifications_actions_http_request_title": "Küldjön HTTP {{method}} kérést a(z) {{url}} címre",
|
||||
"notifications_none_for_topic_title": "Még nem érkezett értesítés erre a témára.",
|
||||
"notifications_none_for_any_title": "Még nem érkezett egy értesítés sem.",
|
||||
"notifications_none_for_any_description": "Értesítés beküldéséhez csak küldj egy PUT, vagy POST kérést a téma URL-ére. Itt egy példa az egyik témádhoz.",
|
||||
"notifications_no_subscriptions_title": "Úgy tűnik, még nem iratkoztál fel egy témára sem.",
|
||||
"publish_dialog_message_published": "Értesítés elküldve",
|
||||
"notifications_attachment_open_title": "Ugrás a{{url}}-ra",
|
||||
"notifications_attachment_open_button": "Melléklet megnyitása",
|
||||
"notifications_attachment_link_expired": "A letöltési link érvényessége lejárt",
|
||||
"notifications_attachment_link_expires": "A link érvényessége lejár:{{date}}",
|
||||
"nav_button_subscribe": "Iratkozz fel a témára",
|
||||
"notifications_click_copy_url_title": "A link URL-jét a vágólapra másolja",
|
||||
"notifications_actions_open_url_title": "Ugrás a{{url}}-ra",
|
||||
"notifications_actions_not_supported": "Ez a művelet nem támogatott a webalkalmazásban",
|
||||
"notifications_actions_http_request_title": "HTTP-kérés küldése {{method}} címre {{url}}",
|
||||
"notifications_none_for_topic_title": "Erre a témára még nem kaptál értesítést.",
|
||||
"notifications_none_for_any_title": "Nem érkezett hozzád értesítés.",
|
||||
"notifications_none_for_any_description": "Ha értesítéseket szeretnél küldeni egy témához, egyszerűen hajts végre egy PUT vagy POST műveletet a téma URL-jére. Íme egy példa az egyik témádra vonatkozóan.",
|
||||
"notifications_no_subscriptions_title": "Úgy tűnik, még nincs előfizetésed.",
|
||||
"publish_dialog_message_published": "Értesítés közzététele",
|
||||
"notifications_example": "Példa",
|
||||
"notifications_no_subscriptions_description": "Kattints a \"{{linktext}}\" linkre egy téma létrehozásához, vagy rá feliratkozáshoz. Ezután PUT, vagy POST kéréssel fogsz tudni értesítéseket küldeni rá, amik utána meg fognak itt jelenni.",
|
||||
"notifications_no_subscriptions_description": "Kattints a „{{linktext}}” linkre egy téma létrehozásához vagy feliratkozáshoz. Ezt követően PUT vagy POST kéréssel küldhetsz üzeneteket, és itt fogod megkapni az értesítéseket.",
|
||||
"publish_dialog_priority_low": "Alacsony prioritás",
|
||||
"publish_dialog_priority_default": "Közepes prioritás",
|
||||
"publish_dialog_priority_high": "Magas prioritás",
|
||||
"notifications_more_details": "További információkért keresd fel a <websiteLink>weboldalunkat</websiteLink> vagy olvasd el a <docsLink>dokumentációt</docsLink>.",
|
||||
"publish_dialog_title_no_topic": "Értesítés küldése",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "túllépi a fájlméret korlátot ({{fileSizeLimit}}) és a kvótát is ({{remainingBytes}} maradt)",
|
||||
"publish_dialog_attachment_limits_quota_reached": "túllépi a kvótát, {{remainingBytes}} maradt",
|
||||
"publish_dialog_priority_min": "Legkisebb prioritás",
|
||||
"publish_dialog_base_url_label": "A szolgáltatás URL-e",
|
||||
"publish_dialog_base_url_placeholder": "A szolgáltatás URL-e, pl: https://example.com",
|
||||
"publish_dialog_topic_label": "Téma neve",
|
||||
"publish_dialog_priority_max": "Legmagasabb prioritás",
|
||||
"publish_dialog_topic_placeholder": "Téma neve, pl: jozsi_riasztasai",
|
||||
"publish_dialog_priority_default": "Alapértelmezett prioritás",
|
||||
"publish_dialog_priority_high": "Kiemelt fontosságú",
|
||||
"notifications_more_details": "További információkért látogasson el a<websiteLink>weboldalra, vagy tekintse meg a</websiteLink>vagy a<docsLink>dokumentációt.",
|
||||
"publish_dialog_title_no_topic": "Értesítés közzététele",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "meghaladja a{{fileSizeLimit}}fájlkorlátot és kvótát,{{remainingBytes}}maradt",
|
||||
"publish_dialog_attachment_limits_quota_reached": "meghaladja a kvótát, {{remainingBytes}} maradt",
|
||||
"publish_dialog_priority_min": "Minimális prioritás",
|
||||
"publish_dialog_base_url_label": "Szolgáltatás URL-je",
|
||||
"publish_dialog_base_url_placeholder": "Szolgáltatás URL-címe, pl. https://example.com",
|
||||
"publish_dialog_topic_label": "A téma neve",
|
||||
"publish_dialog_priority_max": "Legfőbb prioritás",
|
||||
"publish_dialog_topic_placeholder": "Téma neve, pl. phil_alerts",
|
||||
"publish_dialog_title_label": "Cím",
|
||||
"publish_dialog_title_placeholder": "Értesítés címe, pl: Fogy a szabad hely",
|
||||
"publish_dialog_title_placeholder": "Értesítés címe, pl. Lemezterület-figyelmeztetés",
|
||||
"publish_dialog_message_label": "Üzenet",
|
||||
"publish_dialog_message_placeholder": "Írj ide egy üzenetet",
|
||||
"publish_dialog_tags_label": "Címkék",
|
||||
"publish_dialog_tags_placeholder": "Címkék vesszővel elválasztva, pl: fontos,srv1-backup",
|
||||
"publish_dialog_priority_label": "Prioritás",
|
||||
"publish_dialog_click_label": "URL",
|
||||
"publish_dialog_click_placeholder": "Webcím, ami megnyílik, ha az értesítésre kattintanak",
|
||||
"publish_dialog_email_label": "Email",
|
||||
"publish_dialog_email_placeholder": "Email cím, amire továbbítjuk az értesítést, pl: jozsi@example.com",
|
||||
"publish_dialog_attach_label": "Csatolmány URL-e",
|
||||
"publish_dialog_tags_placeholder": "Vesszővel elválasztott címkék listája, pl.: warning, srv1-backup",
|
||||
"publish_dialog_priority_label": "Elsőbbség",
|
||||
"publish_dialog_click_label": "Kattintson az URL-re",
|
||||
"publish_dialog_click_placeholder": "Az értesítésre kattintáskor megnyíló URL",
|
||||
"publish_dialog_email_label": "E-mail",
|
||||
"publish_dialog_email_placeholder": "A bejelentés továbbításának címe, pl.: phil@example.com",
|
||||
"publish_dialog_attach_label": "A melléklet URL-címe",
|
||||
"publish_dialog_filename_label": "Fájlnév",
|
||||
"publish_dialog_filename_placeholder": "Csatolmány fájlneve",
|
||||
"publish_dialog_filename_placeholder": "A melléklet fájlneve",
|
||||
"publish_dialog_delay_label": "Késleltetés",
|
||||
"publish_dialog_delay_placeholder": "Késleltetett küldés, pl: {{unixTimestamp}}, {{relativeTime}}, vagy \"{{naturalLanguage}}\" (Csak angolul)",
|
||||
"publish_dialog_other_features": "Egyéb lehetőségek:",
|
||||
"publish_dialog_chip_click_label": "Kattintási URL",
|
||||
"publish_dialog_delay_placeholder": "Szállítás késleltetése, pl. {{unixTimestamp}}, {{relativeTime}}vagy „{{naturalLanguage}}” (csak angolul)",
|
||||
"publish_dialog_other_features": "Egyéb jellemzők:",
|
||||
"publish_dialog_chip_click_label": "Kattintson az URL-re",
|
||||
"publish_dialog_chip_attach_file_label": "Helyi fájl csatolása",
|
||||
"publish_dialog_chip_delay_label": "Késleltetett kézbesítés",
|
||||
"publish_dialog_chip_topic_label": "Téma megváltoztatása",
|
||||
"publish_dialog_button_cancel_sending": "Küldés megállítása",
|
||||
"publish_dialog_button_cancel": "Mégsem",
|
||||
"publish_dialog_checkbox_publish_another": "Küldök még egyet",
|
||||
"publish_dialog_chip_delay_label": "Szállítás késleltetése",
|
||||
"publish_dialog_chip_topic_label": "Téma váltása",
|
||||
"publish_dialog_button_cancel_sending": "Elküldés visszavonása",
|
||||
"publish_dialog_button_cancel": "Mégse",
|
||||
"publish_dialog_checkbox_publish_another": "Újabb közzététel",
|
||||
"publish_dialog_attached_file_title": "Csatolt fájl:",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Csatolmány fájlneve",
|
||||
"publish_dialog_drop_file_here": "Ejtsd ide a fájlt",
|
||||
"emoji_picker_search_placeholder": "Emoji keresése",
|
||||
"publish_dialog_details_examples_description": "Példákért és az összes küldési képesség részletes leírásához olvasd el a <docsLink>dokumentációt</docsLink>.",
|
||||
"publish_dialog_attached_file_filename_placeholder": "A melléklet fájlneve",
|
||||
"publish_dialog_drop_file_here": "Helyezze ide a fájlt",
|
||||
"emoji_picker_search_placeholder": "Emoji keresés",
|
||||
"publish_dialog_details_examples_description": "Példákért és az összes küldési funkció részletes leírásáért kérjük, olvassa el a <docsLink>dokumentációt</docsLink>.",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Használjon másik szervert",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Feliratkozás",
|
||||
"subscribe_dialog_login_title": "Be kell jelentkezni",
|
||||
"subscribe_dialog_subscribe_description": "A témák nem mindig vannak jelszóval védve, ezért olyan nevet válassz, ami nehezen található ki. Miután feliratkoztál, küldhetsz értesítéseket.",
|
||||
"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_subscribe_button_subscribe": "Iratkozz fel",
|
||||
"subscribe_dialog_login_title": "Bejelentkezés szükséges",
|
||||
"subscribe_dialog_subscribe_description": "A témák nem jelszóval védhetők, ezért válasszon olyan nevet, amelyet nem könnyű kitalálni. A feliratkozás után PUT/POST értesítéseket küldhet.",
|
||||
"subscribe_dialog_login_description": "Ez a téma jelszóval védett. Kérjük, adja meg a felhasználónevét és a jelszavát a feliratkozáshoz.",
|
||||
"subscribe_dialog_login_username_label": "Felhasználónév, pl. phil",
|
||||
"subscribe_dialog_login_password_label": "Jelszó",
|
||||
"common_back": "Vissza",
|
||||
"subscribe_dialog_login_button_login": "Belépés",
|
||||
"subscribe_dialog_login_button_login": "Bejelentkezés",
|
||||
"subscribe_dialog_error_user_anonymous": "névtelen",
|
||||
"subscribe_dialog_error_user_not_authorized": "A(z) {{username}} felhasználónak nincs hozzáférése",
|
||||
"prefs_notifications_min_priority_description_any": "Minden értesítést mutat, prioritástól függetlenül",
|
||||
"prefs_notifications_min_priority_description_max": "Csak az 5-ös (legmagasabb) prioritású értesítések jelennek meg",
|
||||
"prefs_notifications_min_priority_any": "Bármilyen prioritás",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Alacsony prioritás, vagy magasabb",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Magas, vagy legmagasabb prioritás",
|
||||
"prefs_notifications_min_priority_max_only": "Csak a legmagasabb prioritás",
|
||||
"prefs_notifications_sound_title": "Értesítés hangja",
|
||||
"prefs_notifications_sound_description_none": "Az értesítések nem fognak hangot adni, amikor megérkeznek",
|
||||
"prefs_notifications_sound_no_sound": "Hang nélkül",
|
||||
"prefs_notifications_delete_after_one_week": "1 hét után",
|
||||
"prefs_notifications_delete_after_one_month": "1 hónap után",
|
||||
"prefs_notifications_delete_after_never_description": "Az értesítések soha nem lesznek automatikusan törölve",
|
||||
"prefs_notifications_delete_after_three_hours_description": "A 3 óránál régebbi értesítések automatikus törlése",
|
||||
"prefs_notifications_delete_after_one_day_description": "Az egy napnál régebbi értesítések automatikus törlése",
|
||||
"prefs_users_description": "Itt tudsz hozzáadni/eltávolítani felhasználókat a védett témákról. Fontos, hogy a felhasználónevet és a jelszót a böngésző helyi tárolójába fogjuk menteni.",
|
||||
"subscribe_dialog_error_user_not_authorized": "A{{username}}felhasználó nem rendelkezik jogosultsággal",
|
||||
"prefs_notifications_min_priority_description_any": "Az összes értesítés megjelenítése, prioritástól függetlenül",
|
||||
"prefs_notifications_min_priority_description_max": "Értesítések megjelenítése, ha a prioritás 5 (maximális)",
|
||||
"prefs_notifications_min_priority_any": "Bármely prioritás",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Alacsony prioritás és annál magasabb",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Magas prioritás és annál magasabb",
|
||||
"prefs_notifications_min_priority_max_only": "Csak a legmagasabb prioritású",
|
||||
"prefs_notifications_sound_title": "Értesítési hang",
|
||||
"prefs_notifications_sound_description_none": "Az értesítések érkezésekor nem hallatszik hang",
|
||||
"prefs_notifications_sound_no_sound": "Nincs hang",
|
||||
"prefs_notifications_delete_after_one_week": "Egy hét elteltével",
|
||||
"prefs_notifications_delete_after_one_month": "Egy hónap elteltével",
|
||||
"prefs_notifications_delete_after_never_description": "Az értesítések soha nem kerülnek automatikusan törlésre",
|
||||
"prefs_notifications_delete_after_three_hours_description": "Az értesítések három óra elteltével automatikusan törlődnek",
|
||||
"prefs_notifications_delete_after_one_day_description": "Az értesítések egy nap elteltével automatikusan törlődnek",
|
||||
"prefs_users_description": "Itt adhat hozzá vagy távolíthat el felhasználókat a védett témákhoz. Felhívjuk figyelmét, hogy a felhasználónév és a jelszó a böngésző helyi tárolójában kerülnek elmentésre.",
|
||||
"prefs_users_table_user_header": "Felhasználó",
|
||||
"prefs_users_table_base_url_header": "Szerver címe",
|
||||
"prefs_users_table_base_url_header": "Szolgáltatás URL-je",
|
||||
"prefs_users_dialog_title_edit": "Felhasználó szerkesztése",
|
||||
"prefs_users_dialog_username_label": "Felhasználónév, pl: jozsi",
|
||||
"prefs_users_dialog_username_label": "Felhasználónév, pl. phil",
|
||||
"prefs_users_dialog_password_label": "Jelszó",
|
||||
"common_add": "Hozzáadás",
|
||||
"prefs_users_dialog_base_url_label": "Szerver címe, pl: https://ntfy.sh",
|
||||
"prefs_users_dialog_base_url_label": "Szolgáltatási URL, pl. https://ntfy.sh",
|
||||
"notifications_loading": "Értesítések betöltése …",
|
||||
"publish_dialog_progress_uploading": "Feltöltés …",
|
||||
"notifications_click_copy_url_button": "Hivatkozás másolása",
|
||||
"notifications_click_open_button": "Hivatkozás megnyitása",
|
||||
"publish_dialog_progress_uploading_detail": "Feltöltés folyamatban: {{loaded}}/{{total}} ({{percent}}%) …",
|
||||
"notifications_none_for_topic_description": "Értesítés beküldéséhez csak küldj egy PUT, vagy POST kérést a téma URL-ére.",
|
||||
"prefs_notifications_delete_after_one_day": "1 nap után",
|
||||
"publish_dialog_attach_placeholder": "Csatolandó fájl címe, pl: https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_chip_email_label": "Továbbítás email-ben",
|
||||
"publish_dialog_chip_attach_url_label": "Fájl csatolása URL-lel",
|
||||
"publish_dialog_progress_uploading": "Feltöltés…",
|
||||
"notifications_click_copy_url_button": "Link másolása",
|
||||
"notifications_click_open_button": "Link megnyitása",
|
||||
"publish_dialog_progress_uploading_detail": "{{loaded}}/{{total}}feltöltése ({{percent}}%) …",
|
||||
"notifications_none_for_topic_description": "Ha értesítéseket szeretnél küldeni erre a témára, egyszerűen hajts végre egy PUT vagy POST műveletet a téma URL-jére.",
|
||||
"prefs_notifications_delete_after_one_day": "Egy nap elteltével",
|
||||
"publish_dialog_attach_placeholder": "Fájl csatolása URL-címen keresztül, pl. https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_chip_email_label": "Továbbítás e-mailben",
|
||||
"publish_dialog_chip_attach_url_label": "Fájl csatolása URL-címen keresztül",
|
||||
"publish_dialog_button_send": "Küldés",
|
||||
"subscribe_dialog_subscribe_title": "Feliratkozás témára",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Mégsem",
|
||||
"prefs_notifications_min_priority_title": "Legkisebb megjelenítendő prioritás",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Csak akkor jelenik meg egy értesítés, ha a prioritása {{number}} ({{name}}), vagy fontosabb",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Közepes prioritás, vagy magasabb",
|
||||
"prefs_notifications_delete_after_one_week_description": "Az egy hétnél régebbi értesítések automatikus törlése",
|
||||
"subscribe_dialog_subscribe_title": "Iratkozz fel a témára",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Mégse",
|
||||
"prefs_notifications_min_priority_title": "Legalacsonyabb prioritás",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Értesítéseket jelenít meg, ha a prioritás {{number}} ({{name}}) vagy annál magasabb",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Alapértelmezett prioritás és annál magasabb",
|
||||
"prefs_notifications_delete_after_one_week_description": "Az értesítések egy hét elteltével automatikusan törlődnek",
|
||||
"prefs_users_add_button": "Felhasználó hozzáadása",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Téma neve, pl: jozsi_riasztasai",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Téma neve, pl. phil_alerts",
|
||||
"prefs_notifications_title": "Értesítések",
|
||||
"error_boundary_button_copy_stack_trace": "Verem nyomkövetés másolása",
|
||||
"prefs_notifications_delete_after_title": "Régi értesítések törlése",
|
||||
"prefs_notifications_delete_after_three_hours": "3 óra után",
|
||||
"error_boundary_title": "Jaj ne, az ntfy összeomlott",
|
||||
"error_boundary_button_copy_stack_trace": "A veremnyomtatvány másolása",
|
||||
"prefs_notifications_delete_after_title": "Értesítések törlése",
|
||||
"prefs_notifications_delete_after_three_hours": "Három óra múlva",
|
||||
"error_boundary_title": "Jaj, ne, az ntfy összeomlott",
|
||||
"prefs_notifications_delete_after_never": "Soha",
|
||||
"prefs_notifications_delete_after_one_month_description": "Az egy hónapnál régebbi értesítések automatikus törlése",
|
||||
"prefs_notifications_delete_after_one_month_description": "Az értesítések egy hónap elteltével automatikusan törlődnek",
|
||||
"prefs_appearance_title": "Megjelenés",
|
||||
"priority_default": "közepes",
|
||||
"priority_default": "alapértelmezett",
|
||||
"priority_high": "magas",
|
||||
"priority_max": "legmagasabb",
|
||||
"priority_min": "legkisebb",
|
||||
"error_boundary_gathering_info": "Több információ…",
|
||||
"publish_dialog_attachment_limits_file_reached": "túllépi a fájlméret korlátot ({{fileSizeLimit}})",
|
||||
"priority_max": "legnagyobb",
|
||||
"priority_min": "Nekem",
|
||||
"error_boundary_gathering_info": "További információk …",
|
||||
"publish_dialog_attachment_limits_file_reached": "meghaladja a{{fileSizeLimit}}fájlkorlátot",
|
||||
"prefs_users_title": "Felhasználók kezelése",
|
||||
"common_cancel": "Mégsem",
|
||||
"common_cancel": "Mégse",
|
||||
"common_save": "Mentés",
|
||||
"prefs_users_dialog_title_add": "Felhasználó hozzáadása",
|
||||
"prefs_appearance_language_title": "Nyelv",
|
||||
"priority_low": "alacsony",
|
||||
"error_boundary_stack_trace": "Verem nyomkövetés",
|
||||
"publish_dialog_title_topic": "A {{topic}} téma értesítése",
|
||||
"prefs_notifications_sound_description_some": "Az értesítéseket a(z) {{sound}} hang fogja jelezni",
|
||||
"error_boundary_description": "Ennek nem szabadott volna megtörténnie. Nagyon sajnáljuk.<br/>Ha van egy perced, <githubLink>jelentsd be GitHubon</githubLink>, vagy tudasd velünk <discordLink>Discordon</discordLink>, vagy <matrixLink>Matrixon</matrixLink>.",
|
||||
"action_bar_show_menu": "Menü mutatása",
|
||||
"action_bar_toggle_mute": "Üzenetek némítása/bekapcsolása",
|
||||
"error_boundary_stack_trace": "Hibajelentés",
|
||||
"publish_dialog_title_topic": "Közzététel a {{topic}}-ban",
|
||||
"prefs_notifications_sound_description_some": "Az értesítések érkezéskor a {{sound}} hangot játsszák le",
|
||||
"error_boundary_description": "Ez természetesen nem lenne szabad, hogy megtörténjen. Nagyon sajnáljuk a kellemetlenséget.<br/>Ha van egy perced, kérlek, <githubLink>jelentsd be a hibát a GitHubon</githubLink>, vagy értesíts minket a <discordLink>Discordon</discordLink> vagy a <matrixLink>Matrixon</matrixLink>.",
|
||||
"action_bar_show_menu": "Menü megjelenítése",
|
||||
"action_bar_toggle_mute": "Értesítések némítása/némításának feloldása",
|
||||
"notifications_list_item": "Értesítés",
|
||||
"error_boundary_unsupported_indexeddb_description": "A ntfy web alkalmazás működéséhez szükséges az IndexedDB funkció, az ön böngészője nem támogatja az IndexedDB használatát privát böngészés közben.<br/><br/>Miközben privát mód sajnos nem lehetséges, szeretnénk értesíteni hogy magabiztosan használhatja normál módban mert a böngésző minden adatot az ön gépén tárol. Tovább tájékozódhat <githubLink>ezen a Github oldalon</githubLink>, vagy beszéljen velünk <discordLink>Discord-on</discordLink> vagy <matrixLink>Matrix-on</matrixLink>.",
|
||||
"notifications_priority_x": "Prioritás {{prioritás}}",
|
||||
"message_bar_show_dialog": "Küldött üzenetek megjelenítése",
|
||||
"error_boundary_unsupported_indexeddb_description": "Az ntfy webalkalmazás működéséhez szükség van az IndexedDB-re, a böngésződ azonban nem támogatja az IndexedDB-t inkognitó módban.<br/><br/>Bár ez sajnálatos, valójában nem is lenne túl értelmes az ntfy webalkalmazást inkognitó módban használni, mivel minden a böngésző tárolójában kerül elmentésre. További információkat erről a GitHub-problémában találsz, vagy lépj kapcsolatba velünk a Discordon vagy a Matrixon.",
|
||||
"notifications_priority_x": "Prioritás {{priority}}",
|
||||
"message_bar_show_dialog": "Közzétételi párbeszédpanel megjelenítése",
|
||||
"action_bar_logo_alt": "ntfy logó",
|
||||
"action_bar_toggle_action_menu": "Tevékenységkezelő nyitása/zárása",
|
||||
"message_bar_publish": "Üzenet küldése",
|
||||
"nav_button_muted": "Értesítések némítva",
|
||||
"nav_button_connecting": "csatlakozás",
|
||||
"notifications_list": "Értesítés lista",
|
||||
"notifications_mark_read": "Jelölés olvasottként",
|
||||
"action_bar_toggle_action_menu": "Műveleti menü megnyitása/bezárása",
|
||||
"message_bar_publish": "Üzenet közzététele",
|
||||
"nav_button_muted": "Értesítések elnémítva",
|
||||
"nav_button_connecting": "összekötő",
|
||||
"notifications_list": "Értesítések listája",
|
||||
"notifications_mark_read": "Olvasottként jelölés",
|
||||
"notifications_delete": "Törlés",
|
||||
"notifications_new_indicator": "Új értesítés",
|
||||
"notifications_attachment_image": "Csatolt kép",
|
||||
"notifications_attachment_file_image": "Kép fájl",
|
||||
"notifications_attachment_file_video": "Videó fájl",
|
||||
"notifications_attachment_file_audio": "Hang fájl",
|
||||
"notifications_attachment_file_app": "Android alkalmazás fájl",
|
||||
"notifications_attachment_image": "Melléklet kép",
|
||||
"notifications_attachment_file_image": "képfájl",
|
||||
"notifications_attachment_file_video": "videofájl",
|
||||
"notifications_attachment_file_audio": "hangfájl",
|
||||
"notifications_attachment_file_app": "Android-alkalmazásfájl",
|
||||
"notifications_attachment_file_document": "egyéb dokumentum",
|
||||
"publish_dialog_emoji_picker_show": "Emoji kiválasztása",
|
||||
"publish_dialog_topic_reset": "Téma visszaállítása",
|
||||
"publish_dialog_click_reset": "URL kattintás törlése",
|
||||
"publish_dialog_email_reset": "Email továbbítás törlése",
|
||||
"publish_dialog_attach_reset": "Csatolt URL törlése",
|
||||
"publish_dialog_delay_reset": "Késleltetett kézbesítés törlése",
|
||||
"publish_dialog_attached_file_remove": "Csatolt fájl törlése",
|
||||
"publish_dialog_click_reset": "Az URL-re kattintás eltávolítása",
|
||||
"publish_dialog_email_reset": "Az e-mail továbbításának megszüntetése",
|
||||
"publish_dialog_attach_reset": "A melléklet URL-jének eltávolítása",
|
||||
"publish_dialog_delay_reset": "A késleltetett kézbesítés eltávolítása",
|
||||
"publish_dialog_attached_file_remove": "A csatolt fájl eltávolítása",
|
||||
"emoji_picker_search_clear": "Keresés törlése",
|
||||
"prefs_notifications_sound_play": "Kijelölt hang lejátszása",
|
||||
"prefs_users_table": "Felhasználó táblázat",
|
||||
"prefs_notifications_sound_play": "A kiválasztott hang lejátszása",
|
||||
"prefs_users_table": "Felhasználók táblázata",
|
||||
"prefs_users_edit_button": "Felhasználó szerkesztése",
|
||||
"prefs_users_delete_button": "Felhasználó törlése",
|
||||
"error_boundary_unsupported_indexeddb_title": "Privát böngészés nem támogatott",
|
||||
"subscribe_dialog_subscribe_base_url_label": "Szolgáltató URL",
|
||||
"error_boundary_unsupported_indexeddb_title": "A magánböngészés nem támogatott",
|
||||
"subscribe_dialog_subscribe_base_url_label": "Szolgáltatás URL-je",
|
||||
"signup_form_username": "Felhasználónév",
|
||||
"signup_form_password": "Jelszó",
|
||||
"signup_form_button_submit": "Regisztráció",
|
||||
"signup_form_button_submit": "Regisztrálj",
|
||||
"login_form_button_submit": "Bejelentkezés",
|
||||
"login_link_signup": "Regisztráció",
|
||||
"login_disabled": "Bejelentkezés kikapcsolva",
|
||||
"action_bar_change_display_name": "Megjelenített név módosítása",
|
||||
"login_link_signup": "Regisztrálj",
|
||||
"login_disabled": "A bejelentkezés le van tiltva",
|
||||
"action_bar_change_display_name": "A megjelenített név módosítása",
|
||||
"action_bar_profile_logout": "Kijelentkezés",
|
||||
"action_bar_sign_in": "Bejelentkezés",
|
||||
"action_bar_sign_up": "Regisztráció",
|
||||
"action_bar_sign_up": "Regisztrálj",
|
||||
"action_bar_profile_title": "Profil",
|
||||
"nav_button_account": "Fiók",
|
||||
"common_copy_to_clipboard": "Másolás vágólapra",
|
||||
"action_bar_reservation_limit_reached": "Limit elérve",
|
||||
"login_title": "Jelentkezz be a ntfy felhasználódba",
|
||||
"signup_title": "Hozz létre egy ntfy felhasználói fiókot",
|
||||
"common_copy_to_clipboard": "Másolás a vágólapra",
|
||||
"action_bar_reservation_limit_reached": "Elérte a határt",
|
||||
"login_title": "Jelentkezzen be az ntfy-fiókjába",
|
||||
"signup_title": "Hozzon létre egy ntfy-fiókot",
|
||||
"signup_form_confirm_password": "Jelszó megerősítése",
|
||||
"signup_already_have_account": "Már van felhasználód? Jelentkezz be!",
|
||||
"signup_already_have_account": "Van már fiókod? Jelentkezz be!",
|
||||
"action_bar_account": "Fiók",
|
||||
"action_bar_profile_settings": "Beállítások",
|
||||
"signup_error_username_taken": "A felhasználónév {{username}} már foglalt",
|
||||
"signup_error_creation_limit_reached": "Felhasználói regisztráció limit elérve",
|
||||
"signup_error_username_taken": "A{{username}}felhasználónév már foglalt",
|
||||
"signup_error_creation_limit_reached": "Elérte a fiók létrehozásának korlátját",
|
||||
"action_bar_mute_notifications": "Értesítések némítása",
|
||||
"action_bar_unmute_notifications": "Értesítések némításának feloldása",
|
||||
"alert_notification_permission_denied_title": "Az értesítések blokkolva vannak",
|
||||
"alert_notification_permission_denied_description": "Kérjük kapcsold őket vissza a böngésződben",
|
||||
"alert_notification_ios_install_required_title": "iOS telepítés szükséges",
|
||||
"alert_not_supported_context_description": "Az értesítések kizárólag HTTPS-en keresztül támogatottak. Ez a <mdnLink>Notifications API</mdnLink> korlátozása.",
|
||||
"signup_form_toggle_password_visibility": "Jelszó láthatóságának kapcsolása",
|
||||
"action_bar_unmute_notifications": "Értesítések hangjának visszaállítása",
|
||||
"alert_notification_permission_denied_title": "Az értesítések letiltva vannak",
|
||||
"alert_notification_permission_denied_description": "Kérjük, kapcsolja be őket újra a böngészőjében",
|
||||
"alert_notification_ios_install_required_title": "iOS-telepítés szükséges",
|
||||
"alert_not_supported_context_description": "Az értesítések kizárólag HTTPS-en keresztül támogatottak. Ez a <mdnLink>Értesítési API</mdnLink>korlátozása.",
|
||||
"signup_form_toggle_password_visibility": "A jelszó láthatóságának beállítása",
|
||||
"signup_disabled": "A regisztráció le van tiltva",
|
||||
"action_bar_reservation_add": "Téma fenntartása",
|
||||
"action_bar_reservation_add": "Téma elmentése",
|
||||
"action_bar_reservation_edit": "Foglalás módosítása",
|
||||
"action_bar_reservation_delete": "Foglalás törlése",
|
||||
"nav_upgrade_banner_label": "Frissítés ntfy Pro-ra",
|
||||
"nav_upgrade_banner_description": "Témák, több üzenet és e-mail, valamint nagyobb mellékletek megőrzése",
|
||||
"alert_notification_ios_install_required_description": "Kattintson a Megosztás ikonra, majd a Hozzáadás a kezdőképernyőhöz gombra, hogy engedélyezze az értesítéseket iOS rendszeren"
|
||||
"nav_upgrade_banner_label": "Frissíts az ntfy Pro-ra",
|
||||
"nav_upgrade_banner_description": "További témák, több üzenet és e-mail, valamint nagyobb mellékletek",
|
||||
"alert_notification_ios_install_required_description": "Kattints a Megosztás ikonra, majd a „Hozzáadás a kezdőképernyőhöz” gombra az értesítések engedélyezéséhez iOS rendszeren",
|
||||
"notifications_actions_failed_notification": "Sikertelen művelet",
|
||||
"display_name_dialog_title": "A megjelenített név módosítása",
|
||||
"display_name_dialog_description": "Állítson be egy alternatív nevet egy témához, amely az előfizetési listában jelenik meg. Ez megkönnyíti a bonyolult nevű témák azonosítását.",
|
||||
"display_name_dialog_placeholder": "Megjelenítendő név",
|
||||
"reserve_dialog_checkbox_label": "Téma lefoglalása és a hozzáférés beállítása",
|
||||
"publish_dialog_call_label": "Telefonhívás",
|
||||
"publish_dialog_call_item": "Hívja a{{number}}telefonszámot",
|
||||
"publish_dialog_call_reset": "Hívás törlése",
|
||||
"publish_dialog_chip_call_label": "Telefonhívás",
|
||||
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Nincs ellenőrzött telefonszám",
|
||||
"publish_dialog_checkbox_markdown": "Markdown formátum",
|
||||
"subscribe_dialog_subscribe_use_another_background_info": "A webalkalmazás bezárása esetén a többi szerverről érkező értesítések nem érkeznek meg",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Név generálása",
|
||||
"subscribe_dialog_error_topic_already_reserved": "A téma már lefoglalva",
|
||||
"account_basics_title": "Fiók",
|
||||
"account_basics_username_title": "Felhasználónév",
|
||||
"account_basics_username_description": "Hé, ez te vagy ❤",
|
||||
"account_basics_username_admin_tooltip": "Ön rendszergazda",
|
||||
"account_basics_password_title": "Jelszó",
|
||||
"account_basics_password_description": "Fiókjának jelszavának módosítása",
|
||||
"account_basics_password_dialog_title": "Jelszó módosítása",
|
||||
"account_basics_password_dialog_current_password_label": "Jelenlegi jelszó",
|
||||
"account_basics_password_dialog_new_password_label": "Új jelszó",
|
||||
"account_basics_password_dialog_confirm_password_label": "Jelszó megerősítése",
|
||||
"account_basics_password_dialog_button_submit": "Jelszó módosítása",
|
||||
"account_basics_password_dialog_current_password_incorrect": "Helytelen jelszó",
|
||||
"account_basics_phone_numbers_title": "Telefonszámok",
|
||||
"account_basics_phone_numbers_dialog_description": "A hívásértesítési funkció használatához legalább egy telefonszámot hozzá kell adnia és igazolnia kell. Az igazolás SMS-ben vagy telefonhívás útján történhet.",
|
||||
"account_basics_phone_numbers_description": "Telefonos értesítések esetén",
|
||||
"account_basics_phone_numbers_no_phone_numbers_yet": "Még nincs telefonszám",
|
||||
"account_basics_phone_numbers_copied_to_clipboard": "A telefonszámot a vágólapra másoltam",
|
||||
"account_basics_phone_numbers_dialog_title": "Telefonszám hozzáadása",
|
||||
"account_basics_phone_numbers_dialog_number_label": "Telefonszám",
|
||||
"account_basics_phone_numbers_dialog_number_placeholder": "pl. +1222333444",
|
||||
"account_basics_phone_numbers_dialog_verify_button_sms": "SMS küldése",
|
||||
"account_basics_phone_numbers_dialog_verify_button_call": "Hívj fel",
|
||||
"account_basics_phone_numbers_dialog_code_label": "Ellenőrző kód",
|
||||
"account_basics_phone_numbers_dialog_code_placeholder": "pl. 123456",
|
||||
"account_basics_phone_numbers_dialog_check_verification_button": "Kód megerősítése",
|
||||
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
||||
"account_basics_phone_numbers_dialog_channel_call": "Hívás",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "A hozzárendelt felhasználót nem lehet szerkeszteni vagy törölni",
|
||||
"account_usage_title": "Használat",
|
||||
"account_usage_of_limit": "a{{limit}}",
|
||||
"account_usage_unlimited": "Korlátlan",
|
||||
"account_usage_limits_reset_daily": "A használati korlátok minden nap éjfélkor (UTC) visszaállnak",
|
||||
"account_basics_tier_title": "Számlatípus",
|
||||
"account_basics_tier_description": "Fiókod erősségi szintje",
|
||||
"account_basics_tier_admin": "Admin",
|
||||
"account_basics_tier_admin_suffix_with_tier": "C(a{{tier}}szinttel)",
|
||||
"account_basics_tier_admin_suffix_no_tier": "nincs szint",
|
||||
"account_basics_tier_basic": "Alapvető",
|
||||
"account_basics_tier_free": "Ingyenes",
|
||||
"account_basics_tier_interval_monthly": "havonta",
|
||||
"account_basics_tier_interval_yearly": "éves szinten",
|
||||
"account_basics_tier_upgrade_button": "Frissíts Pro verzióra",
|
||||
"account_basics_tier_change_button": "Változás",
|
||||
"account_basics_tier_paid_until": "Az előfizetés{{date}}-ig fizetve, és automatikusan megújul",
|
||||
"account_basics_tier_payment_overdue": "A fizetési határidő lejárt. Kérjük, frissítse a fizetési módját, ellenkező esetben fiókját hamarosan alacsonyabb szintre soroljuk át.",
|
||||
"account_basics_tier_canceled_subscription": "Előfizetését töröltük, és{{date}}-tól ingyenes fiókra váltunk.",
|
||||
"account_basics_tier_manage_billing_button": "Számlázás kezelése",
|
||||
"account_usage_messages_title": "Közzétett üzenetek",
|
||||
"account_usage_emails_title": "Elküldött e-mailek",
|
||||
"account_usage_calls_title": "Kezdeményezett telefonhívások",
|
||||
"account_usage_calls_none": "Ebből a fiókból nem lehet telefonálni",
|
||||
"account_usage_reservations_title": "Fenntartott témák",
|
||||
"account_usage_reservations_none": "Ehhez a fiókhoz nincs fenntartott téma",
|
||||
"account_usage_attachment_storage_title": "Mellékletek tárolása",
|
||||
"account_usage_attachment_storage_description": "{{filesize}}fájlonként, törlésre kerül{{expiry}}után",
|
||||
"account_usage_basis_ip_description": "A fiók használati statisztikái és korlátai az Ön IP-címén alapulnak, ezért előfordulhat, hogy más felhasználókkal is megosztásra kerülnek. A fent feltüntetett korlátok a jelenlegi sávszélesség-korlátozások alapján számított hozzávetőleges értékek.",
|
||||
"account_usage_cannot_create_portal_session": "A számlázási portál nem nyitható meg",
|
||||
"account_delete_title": "Fiók törlése",
|
||||
"account_delete_description": "Fiókjának végleges törlése",
|
||||
"account_delete_dialog_description": "Ezzel véglegesen törlöd a fiókodat, beleértve a szerveren tárolt összes adatot is. A törlés után a felhasználóneved 7 napig nem lesz elérhető. Ha biztosan folytatni szeretnéd, kérjük, erősítsd meg a jelszavadat az alábbi mezőben.",
|
||||
"account_delete_dialog_label": "Jelszó",
|
||||
"account_delete_dialog_button_cancel": "Mégse",
|
||||
"account_delete_dialog_button_submit": "Fiók végleges törlése",
|
||||
"account_delete_dialog_billing_warning": "A fiók törlésével a fizetési előfizetés is azonnal megszűnik. Ezt követően már nem fogsz hozzáférni a fizetési irányítópanelhez.",
|
||||
"account_upgrade_dialog_title": "Fiókcsomag módosítása",
|
||||
"account_upgrade_dialog_interval_monthly": "Havi",
|
||||
"account_upgrade_dialog_interval_yearly": "Évente",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save": "{{discount}}% mentése",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "akár {{discount}}%-os megtakarítás",
|
||||
"account_upgrade_dialog_cancel_warning": "Ezzel <strong>előfizetése megszűnik</strong>, és fiókja alacsonyabb szintre kerül {{date}}. Ezen a napon a témákhoz tartozó foglalások, valamint a szerveren tárolt üzenetek <strong>törlésre kerülnek</strong>.",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Arányos elszámolás</strong>: Fizetős csomagok közötti áttérés esetén az árkülönbözetet<strong>azonnal felszámítjuk</strong>. Alacsonyabb csomagra való áttérés esetén a fennmaradó egyenleget a jövőbeli számlázási időszakok fedezésére használjuk fel.",
|
||||
"account_upgrade_dialog_reservations_warning_one": "A kiválasztott csomag kevesebb témafoglalást engedélyez, mint a jelenlegi csomagod. A csomagváltás előtt <strong>kérjük, törölj legalább egy foglalást</strong>. A foglalásokat a <Link>Beállítások</Link>menüpontban törölheted.",
|
||||
"account_upgrade_dialog_reservations_warning_other": "A kiválasztott csomag kevesebb témafoglalást engedélyez, mint a jelenlegi csomagod. A csomagváltás előtt <strong>kérjük, törölj legalább {{count}} foglalást</strong>. A foglalásokat a <Link>Beállítások</Link>menüpontban törölheted.",
|
||||
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} fenntartott téma",
|
||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} fenntartott témák",
|
||||
"account_upgrade_dialog_tier_features_no_reservations": "Nincsenek fenntartott témák",
|
||||
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} napi üzenet",
|
||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} napi üzenetek",
|
||||
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} napi hírlevél",
|
||||
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} napi e-mailek",
|
||||
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} napi telefonhívás",
|
||||
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} napi telefonhívás",
|
||||
"account_upgrade_dialog_tier_features_no_calls": "Tilos telefonálni",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} fájlonként",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} teljes tárhely",
|
||||
"account_upgrade_dialog_tier_price_per_month": "hónap",
|
||||
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}}évente. Havonta számlázzuk.",
|
||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} éves számlázás. Megtakarítás: {{save}}.",
|
||||
"account_upgrade_dialog_tier_selected_label": "Kiválasztott",
|
||||
"account_upgrade_dialog_tier_current_label": "Jelenlegi",
|
||||
"account_upgrade_dialog_billing_contact_email": "Számlázással kapcsolatos kérdéseivel kérjük, forduljon közvetlenül hozzánk.",
|
||||
"account_upgrade_dialog_billing_contact_website": "Számlázással kapcsolatos kérdéseivel kérjük, keresse fel a<Link>weboldalunkat</Link>.",
|
||||
"account_upgrade_dialog_button_cancel": "Mégse",
|
||||
"account_upgrade_dialog_button_redirect_signup": "Regisztrálj most",
|
||||
"account_upgrade_dialog_button_pay_now": "Fizessen most, és iratkozzon fel",
|
||||
"account_upgrade_dialog_button_cancel_subscription": "Előfizetés lemondása",
|
||||
"account_upgrade_dialog_button_update_subscription": "Előfizetés frissítése",
|
||||
"account_tokens_title": "Hozzáférési tokenek",
|
||||
"account_tokens_description": "Az ntfy API-n keresztül történő közzététel és feliratkozás során használjon hozzáférési tokeneket, így nem kell megadnia a fiókja bejelentkezési adatait. További információkért tekintse meg a <Link>dokumentációt</Link>.",
|
||||
"account_tokens_table_token_header": "Token",
|
||||
"account_tokens_table_label_header": "Címke",
|
||||
"account_tokens_table_last_access_header": "Utolsó hozzáférés",
|
||||
"account_tokens_table_expires_header": "Lejár",
|
||||
"account_tokens_table_never_expires": "Soha nem jár le",
|
||||
"account_tokens_table_current_session": "Aktuális böngészőmunkamenet",
|
||||
"account_tokens_table_copied_to_clipboard": "Az hozzáférési token másolva",
|
||||
"account_tokens_table_cannot_delete_or_edit": "A jelenlegi munkamenet-tokent nem lehet szerkeszteni vagy törölni",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "A létrehozott token nem szerkeszthető vagy törölhető",
|
||||
"account_tokens_table_create_token_button": "Hozzon létre hozzáférési tokent",
|
||||
"account_tokens_table_last_origin_tooltip": "A{{ip}}IP-címről kattintson a kereséshez",
|
||||
"account_tokens_dialog_title_create": "Hozzon létre hozzáférési tokent",
|
||||
"account_tokens_dialog_title_edit": "Hozzáférési token szerkesztése",
|
||||
"account_tokens_dialog_title_delete": "Hozzáférési token törlése",
|
||||
"account_tokens_dialog_label": "Címke, pl. Radarr értesítések",
|
||||
"account_tokens_dialog_button_create": "Token létrehozása",
|
||||
"account_tokens_dialog_button_update": "Token frissítése",
|
||||
"account_tokens_dialog_button_cancel": "Mégse",
|
||||
"account_tokens_dialog_expires_label": "Az hozzáférési token érvényessége",
|
||||
"account_tokens_dialog_expires_unchanged": "A lejárati dátumot ne módosítsa",
|
||||
"account_tokens_dialog_expires_x_hours": "A token érvényessége {{hours}} óra múlva lejár",
|
||||
"account_tokens_dialog_expires_x_days": "A token érvényessége {{days}} nap múlva lejár",
|
||||
"account_tokens_dialog_expires_never": "A token soha nem jár le",
|
||||
"account_tokens_delete_dialog_title": "Hozzáférési token törlése",
|
||||
"account_tokens_delete_dialog_description": "Mielőtt törölne egy hozzáférési tokent, győződjön meg arról, hogy egyetlen alkalmazás vagy szkript sem használja azt éppen. <strong>Ez a művelet visszafordíthatatlan</strong>.",
|
||||
"account_tokens_delete_dialog_submit_button": "A token végleges törlése",
|
||||
"prefs_notifications_web_push_title": "Háttérben futó értesítések",
|
||||
"prefs_notifications_web_push_enabled_description": "Az értesítések akkor is beérkeznek, ha a webalkalmazás nem fut (Web Push segítségével)",
|
||||
"prefs_notifications_web_push_disabled_description": "Értesítéseket kapunk, amikor a webalkalmazás fut (WebSocket-en keresztül)",
|
||||
"prefs_notifications_web_push_enabled": "Engedélyezve a{{server}}esetében",
|
||||
"prefs_notifications_web_push_disabled": "Fogyatékkal élők",
|
||||
"prefs_users_description_no_sync": "A felhasználónevek és jelszavak nem kerülnek szinkronizálásra a fiókjával.",
|
||||
"prefs_users_table_cannot_delete_or_edit": "A bejelentkezett felhasználót nem lehet törölni vagy szerkeszteni",
|
||||
"prefs_appearance_theme_title": "Téma",
|
||||
"prefs_system_default": "Rendszer alapértelmezett",
|
||||
"prefs_appearance_theme_dark": "Sötét mód",
|
||||
"prefs_appearance_theme_light": "Világos mód",
|
||||
"prefs_reservations_title": "Fenntartott témák",
|
||||
"prefs_reservations_description": "Itt foglalhat le témákat személyes használatra. A téma lefoglalásával tulajdonjogot szerez a témára, és megadhatja a többi felhasználó számára a témához való hozzáférési jogosultságokat.",
|
||||
"prefs_reservations_limit_reached": "Elérted a fenntartott témák számának korlátját.",
|
||||
"prefs_reservations_add_button": "Foglalt téma hozzáadása",
|
||||
"prefs_reservations_edit_button": "Téma szerkesztése",
|
||||
"prefs_reservations_delete_button": "A téma hozzáférésének visszaállítása",
|
||||
"prefs_reservations_table": "Foglalt témák táblázata",
|
||||
"prefs_reservations_table_topic_header": "Téma",
|
||||
"prefs_reservations_table_access_header": "Hozzáférés",
|
||||
"prefs_reservations_table_everyone_deny_all": "Csak én tudok hírleveleket kiadni és feliratkozni rájuk",
|
||||
"prefs_reservations_table_everyone_read_only": "Én is közzétehetek és feliratkozhatok, mindenki feliratkozhat",
|
||||
"prefs_reservations_table_everyone_write_only": "Én is közzétehetek és feliratkozhatok, mindenki közzétehet",
|
||||
"prefs_reservations_table_everyone_read_write": "Bárki közzétehet és feliratkozhat",
|
||||
"prefs_reservations_table_not_subscribed": "Nincs feliratkozva",
|
||||
"prefs_reservations_table_click_to_subscribe": "Kattintson a feliratkozáshoz",
|
||||
"prefs_reservations_dialog_title_add": "Téma elmentése",
|
||||
"prefs_reservations_dialog_title_edit": "Foglalt téma szerkesztése",
|
||||
"prefs_reservations_dialog_title_delete": "Témafoglalás törlése",
|
||||
"prefs_reservations_dialog_description": "A téma lefoglalásával a téma tulajdonjogát szerezheti meg, és meghatározhatja a többi felhasználó hozzáférési jogosultságait a témához.",
|
||||
"prefs_reservations_dialog_topic_label": "Téma",
|
||||
"prefs_reservations_dialog_access_label": "Hozzáférés",
|
||||
"reservation_delete_dialog_description": "A foglalás törlésével lemondasz a téma feletti tulajdonjogodról, és mások is lefoglalhatják azt. A meglévő üzeneteket és mellékleteket megtarthatod vagy törölheted.",
|
||||
"reservation_delete_dialog_action_keep_title": "A gyorsítótárban tárolt üzenetek és mellékletek megőrzése",
|
||||
"reservation_delete_dialog_action_keep_description": "A szerveren gyorsítótárba mentett üzenetek és mellékletek nyilvánosan láthatóvá válnak azok számára, akik ismerik a téma nevét.",
|
||||
"reservation_delete_dialog_action_delete_title": "A gyorsítótárban tárolt üzenetek és mellékletek törlése",
|
||||
"reservation_delete_dialog_action_delete_description": "A gyorsítótárban tárolt üzenetek és mellékletek véglegesen törlésre kerülnek. Ez a művelet visszafordíthatatlan.",
|
||||
"reservation_delete_dialog_submit_button": "Foglalás törlése",
|
||||
"error_boundary_button_reload_ntfy": "Töltsd be nekem",
|
||||
"web_push_subscription_expiring_title": "Az értesítések felfüggesztésre kerülnek",
|
||||
"web_push_subscription_expiring_body": "Nyissa meg az ntfy alkalmazást, hogy továbbra is értesítéseket kapjon",
|
||||
"web_push_unknown_notification_title": "Ismeretlen értesítés érkezett a szerverről",
|
||||
"web_push_unknown_notification_body": "Előfordulhat, hogy a webalkalmazás megnyitásával frissítenie kell az ntfy-t",
|
||||
"common_close": "Bezár",
|
||||
"common_refresh": "Frissítés",
|
||||
"email_verify_progress_title": "E-mail címed ellenőrzése...",
|
||||
"email_verify_success_title": "E-mail cím ellenőrizve",
|
||||
"email_verify_success_description": "Az e-mail címedet ellenőriztük és hozzáadtuk a fiókodhoz.",
|
||||
"email_verify_error_title": "Az ellenőrzés sikertelen",
|
||||
"email_verify_error_description": "Ez az ellenőrző link érvénytelen vagy lejárt. Újat kérhet a fiókbeállításaiban.",
|
||||
"email_verify_button_account": "Ugrás a fiókhoz",
|
||||
"version_update_available_title": "Új verzió elérhető",
|
||||
"version_update_available_description": "Az ntfy szerver frissült. Kérjük, frissítse az oldalt.",
|
||||
"signup_form_email": "E-mail cím (opcionális, a fiók helyreállításához)",
|
||||
"login_link_forgot_password": "Elfelejtett jelszó",
|
||||
"reset_password_request_title": "Jelszó visszaállítása",
|
||||
"reset_password_request_description": "Add meg a felhasználóneved vagy e-mail címed. Ha van fiókod, akkor e-mailben kapsz egy linket a jelszavad visszaállításához.",
|
||||
"reset_password_request_primary_required": "Ez csak akkor működik, ha már hozzáadott egy elsődleges e-mail címet, és ellenőrizte azt.",
|
||||
"reset_password_request_identifier_label": "Felhasználónév vagy e-mail cím",
|
||||
"reset_password_request_button_submit": "Visszaállítási link küldése",
|
||||
"reset_password_sent_title": "Ellenőrizd a beérkező leveleidet",
|
||||
"reset_password_sent_description": "Ha létezik fiók, e-mailben elküldtük a jelszó visszaállításához szükséges linket.",
|
||||
"reset_password_back_to_login": "Vissza a bejelentkezéshez",
|
||||
"reset_password_disabled": "Jelszó-visszaállítás le van tiltva",
|
||||
"reset_password_title": "Új jelszó beállítása",
|
||||
"reset_password_form_password": "Új jelszó",
|
||||
"reset_password_form_confirm": "Új jelszó megerősítése",
|
||||
"reset_password_form_button_submit": "Jelszó beállítása",
|
||||
"reset_password_form_error_invalid": "Ez a visszaállító link érvénytelen vagy lejárt. Kérjen újat.",
|
||||
"reset_password_success_title": "Jelszó megváltozott",
|
||||
"reset_password_success_description": "A jelszava megváltozott. Most már bejelentkezhet az új jelszavával.",
|
||||
"action_bar_reload": "Alkalmazás újratöltése",
|
||||
"account_basics_emails_title": "E-mail címek",
|
||||
"account_basics_emails_description": "E-mail értesítésekért és jelszó-visszaállításért",
|
||||
"account_basics_emails_no_emails_yet": "Még nincsenek e-mailek",
|
||||
"account_basics_emails_copied_to_clipboard": "E-mail cím másolva a vágólapra",
|
||||
"account_basics_emails_chip_actions_primary": "Elsődleges cím, amelyet alapértelmezett e-mail címként használunk. Kattintson a műveletekhez.",
|
||||
"account_basics_emails_chip_actions_verified": "Elsődleges cím, amelyet alapértelmezett e-mail címként használunk. Kattintson a művelethez.",
|
||||
"account_basics_emails_chip_actions_unverified": "Értesítésekhez használható. Kattintson a műveletekért.",
|
||||
"account_basics_emails_unverified": "ellenőrizetlen",
|
||||
"account_basics_emails_set_primary": "Beállítás elsődleges e-mail címként",
|
||||
"account_basics_emails_delete": "Cím eltávolítása",
|
||||
"account_basics_emails_resend": "Ellenőrző e-mail újraküldése",
|
||||
"account_basics_emails_resent": "Megerősítő e-mail elküldve, ellenőrizze a beérkező leveleit",
|
||||
"account_basics_emails_primary_elsewhere": "Ez az e-mail cím egy másik fiók elsődleges címként van használatban.",
|
||||
"account_basics_emails_no_recovery_warning": "Adjon meg legalább egy e-mail címet, hogy biztosan vissza tudja állítani fiókját, ha elveszíti jelszavát.",
|
||||
"account_basics_emails_no_primary_warning": "Adjon meg egy elsődleges e-mail címet, hogy biztosan vissza tudja állítani fiókját, ha elveszíti jelszavát.",
|
||||
"account_basics_emails_dialog_title": "E-mail cím hozzáadása",
|
||||
"account_basics_emails_dialog_description": "Add meg az e-mail címedet, hogy hozzáadhasd a fiókodhoz. Egy ellenőrző linket fogsz küldeni, hogy megerősítsd, hogy a címed a tiéd.",
|
||||
"account_basics_emails_dialog_email_label": "Email cím",
|
||||
"account_basics_emails_dialog_email_placeholder": "pl. user@example.com",
|
||||
"account_basics_emails_dialog_verify_button": "Ellenőrző link küldése",
|
||||
"account_basics_emails_dialog_check_inbox": "Ellenőrizd a beérkező leveleidet, és kattints a megerősítő linkre az e-mail cím megerősítéséhez. Amíg ezt nem teszed meg, addig ellenőrizetlenként fog megjelenni.",
|
||||
"account_basics_tier_provisioned": "Kiépített",
|
||||
"prefs_users_dialog_base_url_exists": "Már létezik egy felhasználó ehhez a szolgáltatás URL címéhez.",
|
||||
"account_usage_emails_none": "Ezzel a fiókkal nem küldhetők e-mail értesítések",
|
||||
"prefs_users_dialog_base_url_invalid": "érvénytelen URL-formátum. http:// vagy https:// előtaggal kell kezdődnie"
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@
|
||||
"account_usage_limits_reset_daily": "Batasan penggunaan diatur ulang setiap hari di tengah malam (UTC)",
|
||||
"account_basics_tier_title": "Jenis akun",
|
||||
"account_basics_tier_description": "Tingkat daya akun Anda",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(tidak ada peringkat)",
|
||||
"account_basics_tier_admin_suffix_no_tier": "tanpa tingkatan",
|
||||
"account_basics_tier_basic": "Dasaran",
|
||||
"account_basics_tier_change_button": "Ubah",
|
||||
"account_basics_tier_paid_until": "Langganan dibayar sampai {{date}}, dan akan dibayar secara otomatis",
|
||||
@@ -299,7 +299,7 @@
|
||||
"prefs_reservations_dialog_title_edit": "Sunting reservasi topik",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Buat nama",
|
||||
"account_basics_title": "Akun",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(dengan peringkat {{tier}})",
|
||||
"account_basics_tier_admin_suffix_with_tier": "dengan tingkatan {{tier}}",
|
||||
"account_basics_tier_free": "Gratis",
|
||||
"account_tokens_dialog_expires_label": "Token akses kedaluwarsa dalam",
|
||||
"account_basics_username_description": "Hei, itu Anda ❤",
|
||||
@@ -399,12 +399,66 @@
|
||||
"prefs_notifications_web_push_enabled": "Diaktifkan untuk {{server}}",
|
||||
"prefs_notifications_web_push_disabled": "Dinonaktifkan",
|
||||
"prefs_appearance_theme_dark": "Mode gelap",
|
||||
"prefs_appearance_theme_system": "Sistem (bawaan)",
|
||||
"prefs_system_default": "Sistem bawaan",
|
||||
"prefs_appearance_theme_light": "Mode terang",
|
||||
"web_push_subscription_expiring_title": "Notifikasi akan dijeda",
|
||||
"web_push_subscription_expiring_body": "Buka ntfy untuk terus menerima notifikasi",
|
||||
"web_push_unknown_notification_title": "Notifikasi yang tidak diketahui diterima dari server",
|
||||
"web_push_unknown_notification_body": "Anda mungkin harus memperbarui ntfy dengan membuka aplikasi web",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Pengguna yang telah ditetapkan tidak dapat diedit atau dihapus",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Tidak dapat mengedit atau menghapus token yang disediakan"
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Tidak dapat mengedit atau menghapus token yang disediakan",
|
||||
"common_close": "Tutup",
|
||||
"common_refresh": "Segarkan",
|
||||
"email_verify_progress_title": "Memverifikasi alamat email Anda...",
|
||||
"email_verify_success_title": "Email telah diverifikasi",
|
||||
"email_verify_success_description": "Alamat email Anda telah diverifikasi dan ditambahkan ke akun Anda.",
|
||||
"email_verify_error_title": "Verifikasi gagal",
|
||||
"email_verify_error_description": "Tautan verifikasi ini tidak valid atau sudah kedaluwarsa. Anda dapat meminta tautan baru dari pengaturan akun Anda.",
|
||||
"email_verify_button_account": "Pergi ke akun",
|
||||
"version_update_available_title": "Versi baru sudah tersedia",
|
||||
"version_update_available_description": "Server ntfy telah diperbarui. Silakan segarkan halaman ini.",
|
||||
"signup_form_email": "Email (opsional, untuk pemulihan akun)",
|
||||
"login_link_forgot_password": "Lupa kata sandi",
|
||||
"reset_password_request_title": "Atur ulang kata sandi",
|
||||
"reset_password_request_description": "Masukkan nama pengguna atau alamat email Anda. Jika akun tersebut sudah ada, tautan untuk mengatur ulang kata sandi akan dikirimkan melalui email.",
|
||||
"reset_password_request_primary_required": "Fitur ini hanya berfungsi jika Anda sudah menambahkan alamat email utama dan memverifikasinya.",
|
||||
"reset_password_request_identifier_label": "Nama pengguna atau email",
|
||||
"reset_password_request_button_submit": "Kirim tautan atur ulang",
|
||||
"reset_password_sent_title": "Periksa kotak masuk Anda",
|
||||
"reset_password_sent_description": "Jika akun tersebut sudah ada, tautan untuk mengatur ulang kata sandi Anda telah dikirimkan melalui email.",
|
||||
"reset_password_back_to_login": "Kembali ke halaman masuk",
|
||||
"reset_password_disabled": "Pengaturan ulang kata sandi dinonaktifkan",
|
||||
"reset_password_title": "Atur kata sandi baru",
|
||||
"reset_password_form_password": "Kata sandi baru",
|
||||
"reset_password_form_confirm": "Konfirmasi kata sandi baru",
|
||||
"reset_password_form_button_submit": "Atur kata sandi",
|
||||
"reset_password_form_error_invalid": "Tautan pengaturan ulang ini tidak sah atau sudah kedaluwarsa. Silakan minta tautan baru.",
|
||||
"reset_password_success_title": "Kata sandi diubah",
|
||||
"reset_password_success_description": "Kata sandi Anda telah diubah. Sekarang Anda dapat masuk menggunakan kata sandi baru Anda.",
|
||||
"action_bar_reload": "Muat ulang aplikasi",
|
||||
"account_basics_emails_title": "Alamat email",
|
||||
"account_basics_emails_description": "Untuk pemberitahuan melalui email dan pengaturan ulang kata sandi",
|
||||
"account_basics_emails_no_emails_yet": "Belum ada email",
|
||||
"account_basics_emails_copied_to_clipboard": "Alamat email telah disalin ke papan klip",
|
||||
"account_basics_emails_chip_actions_primary": "Alamat utama, yang digunakan sebagai alamat email utama Anda. Klik untuk melakukan tindakan.",
|
||||
"account_basics_emails_chip_actions_verified": "Dapat digunakan untuk pemberitahuan. Klik untuk melakukan tindakan.",
|
||||
"account_basics_emails_chip_actions_unverified": "Alamat yang belum diverifikasi, silakan periksa kotak masuk Anda untuk melakukan verifikasi. Klik untuk melakukan tindakan.",
|
||||
"account_basics_emails_unverified": "belum diverifikasi",
|
||||
"account_basics_emails_set_primary": "Tetapkan sebagai email utama",
|
||||
"account_basics_emails_delete": "Hapus alamat",
|
||||
"account_basics_emails_resend": "Kirim ulang email verifikasi",
|
||||
"account_basics_emails_resent": "Email verifikasi telah dikirim, silakan periksa kotak masuk Anda",
|
||||
"account_basics_emails_primary_elsewhere": "Alamat email ini digunakan sebagai alamat utama pada akun lain",
|
||||
"account_basics_emails_no_recovery_warning": "Tambahkan setidaknya satu alamat email agar Anda dapat memulihkan akun Anda jika lupa kata sandi.",
|
||||
"account_basics_emails_no_primary_warning": "Tambahkan alamat email utama agar Anda dapat memulihkan akun Anda jika lupa kata sandi.",
|
||||
"account_basics_emails_dialog_title": "Tambahkan alamat email",
|
||||
"account_basics_emails_dialog_description": "Masukkan alamat email untuk menambahkannya ke akun Anda. Tautan verifikasi akan dikirimkan untuk memastikan bahwa alamat email tersebut memang milik Anda.",
|
||||
"account_basics_emails_dialog_email_label": "Alamat email",
|
||||
"account_basics_emails_dialog_email_placeholder": "misalnya user@example.com",
|
||||
"account_basics_emails_dialog_verify_button": "Kirim tautan verifikasi",
|
||||
"account_basics_emails_dialog_check_inbox": "Periksa kotak masuk Anda dan klik tautan verifikasi untuk mengonfirmasi alamat email ini. Alamat email tersebut akan tetap berstatus belum diverifikasi sampai Anda melakukannya.",
|
||||
"account_basics_tier_provisioned": "Telah disediakan",
|
||||
"account_usage_emails_none": "Tidak ada pemberitahuan email yang dapat dikirim melalui akun ini",
|
||||
"prefs_users_dialog_base_url_invalid": "Format URL tidak sah. Harus diawali dengan http:// atau https://",
|
||||
"prefs_users_dialog_base_url_exists": "Pengguna dengan URL layanan ini sudah ada"
|
||||
}
|
||||
|
||||
@@ -252,8 +252,8 @@
|
||||
"account_basics_tier_title": "Tipo di account",
|
||||
"account_basics_tier_description": "Permessi del tuo account",
|
||||
"account_basics_tier_admin": "Amministratore",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(con livello {{tier}})",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(nessun livello)",
|
||||
"account_basics_tier_admin_suffix_with_tier": "con livello {{tier}}",
|
||||
"account_basics_tier_admin_suffix_no_tier": "nessun livello",
|
||||
"account_basics_tier_basic": "Base",
|
||||
"account_basics_tier_free": "Gratuito",
|
||||
"account_usage_emails_title": "Email inviate",
|
||||
@@ -390,7 +390,7 @@
|
||||
"prefs_notifications_web_push_disabled": "Disabilitato",
|
||||
"prefs_users_table_cannot_delete_or_edit": "Impossibile eliminare o modificare l'utente registrato",
|
||||
"prefs_appearance_theme_title": "Tema",
|
||||
"prefs_appearance_theme_system": "Sistema (predefinito)",
|
||||
"prefs_system_default": "Sistema predefinito",
|
||||
"prefs_appearance_theme_dark": "Modalità scura",
|
||||
"prefs_appearance_theme_light": "Modalità chiara",
|
||||
"prefs_reservations_table_topic_header": "Argomento",
|
||||
@@ -405,5 +405,59 @@
|
||||
"account_tokens_dialog_expires_x_hours": "Il token scade tra {{hours}} ore",
|
||||
"prefs_reservations_table": "Tabella argomenti riservati",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Un utente autorizzato non può essere modificato o eliminato",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Impossibile modificare o eliminare il token fornito"
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Impossibile modificare o eliminare il token fornito",
|
||||
"common_close": "Chiudi",
|
||||
"common_refresh": "Aggiorna",
|
||||
"email_verify_progress_title": "Verificando il tuo indirizzo di posta elettronica...",
|
||||
"email_verify_success_title": "Indirizzo di posta elettronica verificato",
|
||||
"email_verify_success_description": "Il tuo indirizzo di posta elettronica è stato verificato ed aggiunto al tuo profilo.",
|
||||
"email_verify_error_title": "Verifica non riuscita",
|
||||
"email_verify_error_description": "Questo indirizzo di verifica è invalido o scaduto. Puoi richiederne un'altro dalle impostazioni del profilo.",
|
||||
"email_verify_button_account": "Vai al tuo profilo",
|
||||
"version_update_available_title": "Nuova versione disponibile",
|
||||
"version_update_available_description": "Il server ntfy è stato aggiornato. Ricarica la pagina.",
|
||||
"signup_form_email": "Indirizzo di posta elettronica (opzionale, per il recupero del profilo)",
|
||||
"login_link_forgot_password": "Password dimenticata",
|
||||
"reset_password_request_title": "Reimposta la password",
|
||||
"reset_password_request_description": "Inserisci il tuo nome utente e indirizzo di posta elettronica. Se un profilo esiste, un indirizzo per reimpostare la password sarà inviato tramite posta elettronica.",
|
||||
"reset_password_request_primary_required": "Questa procedura funziona solamente se hai già aggiunto un indirizzo di posta elettronica primario e lo hai verificato.",
|
||||
"reset_password_request_identifier_label": "Nome utente o indirizzo di posta elettronica",
|
||||
"reset_password_request_button_submit": "Invia collegamento per la reimpostazione della password",
|
||||
"reset_password_sent_title": "Controlla la tua casella di posta elettronica in arrivo",
|
||||
"reset_password_sent_description": "Se un profilo esiste, un indirizzo per reimpostare la password è stato inviato tramite posta elettronica.",
|
||||
"reset_password_back_to_login": "Ritorna all'accesso",
|
||||
"reset_password_disabled": "La reimpostazione della password è disabilitata",
|
||||
"reset_password_title": "Imposta una nuova password",
|
||||
"reset_password_form_password": "Nuova password",
|
||||
"reset_password_form_confirm": "Conferma nuova password",
|
||||
"reset_password_form_button_submit": "Imposta la password",
|
||||
"reset_password_form_error_invalid": "Questo indirizzo di reimpostazione delle credenziali è invalido o scaduto. Richiederne uno nuovo.",
|
||||
"reset_password_success_title": "Password modificata",
|
||||
"reset_password_success_description": "La tua password è stata modificata. Ora puoi accedere con la tua nuova password.",
|
||||
"action_bar_reload": "Ricarica app",
|
||||
"account_basics_emails_title": "Indirizzo di posta elettronica",
|
||||
"account_basics_emails_description": "Per notifiche tramite posta elettronica e reimpostazione della password",
|
||||
"account_basics_emails_no_emails_yet": "Ancora nessun messaggio di posta elettronica",
|
||||
"account_basics_emails_copied_to_clipboard": "Indirizzo di posta elettronica copiato negli appunti",
|
||||
"account_basics_emails_chip_actions_primary": "Indirizzo primario, utilizzato come indirizzo di posta elettronica predefinito. Clicca per azioni.",
|
||||
"account_basics_emails_chip_actions_verified": "Può essere usato per le notifiche. Clicca per azioni.",
|
||||
"account_basics_emails_chip_actions_unverified": "Indirizzo non verificato, controlla la tua casella di posta elettronica in arrivo per verificare. Clicca per azioni.",
|
||||
"account_basics_emails_unverified": "non verificato",
|
||||
"account_basics_emails_set_primary": "Imposta come indirizzo di posta elettronica primario",
|
||||
"account_basics_emails_delete": "Rimuovi indirizzo",
|
||||
"account_basics_emails_resend": "Reinvia messaggio di posta elettronica di verifica",
|
||||
"account_basics_emails_resent": "Messaggio di posta elettronica di verifica inviato, controlla la tua casella di posta in arrivo",
|
||||
"account_basics_emails_primary_elsewhere": "L'indirizzo di posta elettronica è utilizzato come indirizzo primario su un profilo diverso",
|
||||
"account_basics_emails_no_recovery_warning": "Aggiungi almeno un indirizzo di posta elettronica per assicurarti di poter recuperare il tuo profilo nel caso perdessi la tua password.",
|
||||
"account_basics_emails_no_primary_warning": "Aggiungi un indirizzo di posta elettronica primario per assicurarti di poter recuperare il tuo profilo nel caso dimenticassi la tua password.",
|
||||
"account_basics_emails_dialog_title": "Aggiungi un indirizzo di posta elettronica",
|
||||
"account_basics_emails_dialog_description": "Inserisci un indirizzo di posta elettronica da aggiungere al tuo profilo. Un indirizzo di verifica ti verrà inviato per confermare che ti appartenga.",
|
||||
"account_basics_emails_dialog_email_label": "Indirizzo di posta elettronica",
|
||||
"account_basics_emails_dialog_email_placeholder": "p.es. utente@esempio.com",
|
||||
"account_basics_emails_dialog_verify_button": "Invia indirizzo di verifica",
|
||||
"account_basics_emails_dialog_check_inbox": "Controlla la tua casella di posta elettronica in arrivo e clicca sull'indirizzo di verifica per confermare questo indirizzo di posta elettronica. Apparirà come non verificato finchè questa procedura non verrà completata.",
|
||||
"account_basics_tier_provisioned": "Predisposto",
|
||||
"account_usage_emails_none": "Le notifiche tramite posta elettronica non possono essere inviate con questo profilo",
|
||||
"prefs_users_dialog_base_url_invalid": "Formato dell'indirizzo invalido. Deve iniziare con http:// o https://",
|
||||
"prefs_users_dialog_base_url_exists": "Un utente per questo indirizzo di servizio è già presente"
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@
|
||||
"account_basics_title": "アカウント",
|
||||
"account_basics_tier_description": "アカウントのパワーレベル",
|
||||
"account_basics_tier_admin": "管理者",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(ティア {{tier}})",
|
||||
"account_basics_tier_admin_suffix_with_tier": "ティア {{tier}}",
|
||||
"account_basics_tier_free": "無料",
|
||||
"account_usage_attachment_storage_description": "1ファイルあたり{{filesize}}、{{expiry}}を過ぎると削除",
|
||||
"account_usage_basis_ip_description": "アカウントの使用量統計および制限はあなたのIPアドレスに基づいているため、他のユーザーと共有される可能性があります。上記制限は既存のレート制限に基づく概算値です。",
|
||||
@@ -320,7 +320,7 @@
|
||||
"account_basics_password_dialog_button_submit": "パスワードを変更",
|
||||
"account_usage_title": "使用量",
|
||||
"account_basics_tier_title": "アカウントタイプ",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(ティアなし)",
|
||||
"account_basics_tier_admin_suffix_no_tier": "ティアなし",
|
||||
"account_basics_tier_change_button": "変更",
|
||||
"account_basics_tier_payment_overdue": "支払期限を過ぎています。支払い方法を更新しないと、近日中にアカウントはダウングレードされます。",
|
||||
"account_basics_tier_canceled_subscription": "あなたのサブスクリプションはキャンセルされ{{date}}に無料アカウントにダウングレードされます。",
|
||||
@@ -400,10 +400,64 @@
|
||||
"prefs_notifications_web_push_enabled": "{{server}}で有効",
|
||||
"prefs_notifications_web_push_disabled": "無効",
|
||||
"prefs_appearance_theme_title": "テーマ",
|
||||
"prefs_appearance_theme_system": "システム (既定)",
|
||||
"prefs_system_default": "システム 既定",
|
||||
"prefs_appearance_theme_dark": "ダークモード",
|
||||
"web_push_unknown_notification_title": "不明な通知を受信しました",
|
||||
"web_push_unknown_notification_body": "ウェブアプリを開いてntfyをアップデートする必要があります",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "自動作成されたユーザーの編集や削除はできません",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "自動作成されたトークンは編集や削除はできません"
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "自動作成されたトークンは編集や削除はできません",
|
||||
"common_close": "閉じる",
|
||||
"common_refresh": "更新",
|
||||
"email_verify_progress_title": "メールアドレスを確認中…",
|
||||
"email_verify_success_title": "メールアドレスを確認しました",
|
||||
"email_verify_success_description": "メールアドレスが確認されアカウントに追加されました。",
|
||||
"email_verify_error_title": "確認失敗",
|
||||
"email_verify_error_description": "確認リンクが不正か期限切れです。アカウント設定から再度リクエストが可能です。",
|
||||
"email_verify_button_account": "アカウント設定へ",
|
||||
"version_update_available_title": "新しいバージョンが利用可能",
|
||||
"version_update_available_description": "ntfyサーバーがアップデートされました。ページを更新して下さい。",
|
||||
"signup_form_email": "メールアドレス(任意、アカウント復元用)",
|
||||
"login_link_forgot_password": "パスワードを忘れた",
|
||||
"reset_password_request_title": "パスワードをリセット",
|
||||
"reset_password_request_description": "ユーザー名かメールアドレスを入力してください。アカウントが存在していれば、パスワードリセット用のリンクがメールで送信されます。",
|
||||
"reset_password_request_primary_required": "この機能はプライマリメールアドレスが設定され確認されている場合にのみ使用できます。",
|
||||
"reset_password_request_identifier_label": "ユーザー名またはメールアドレス",
|
||||
"reset_password_request_button_submit": "リセット用リンクを送信する",
|
||||
"reset_password_sent_title": "メールボックスを確認して下さい",
|
||||
"reset_password_sent_description": "もしアカウントが存在していれば、パスワードリセット用のリンクがメールで送信されています。",
|
||||
"reset_password_back_to_login": "サインイン画面に戻る",
|
||||
"reset_password_disabled": "パスワードリセットは無効化されています",
|
||||
"reset_password_title": "新しいパスワードを入力",
|
||||
"reset_password_form_password": "新しいパスワード",
|
||||
"reset_password_form_confirm": "新しいパスワードを確認",
|
||||
"reset_password_form_button_submit": "パスワードを設定",
|
||||
"reset_password_form_error_invalid": "このリセット用リンクは不正あるいは期限切れです。再度申請して下さい。",
|
||||
"reset_password_success_title": "パスワードが変更されました",
|
||||
"reset_password_success_description": "パスワードが変更されました。新しいパスワードでサインインして下さい。",
|
||||
"action_bar_reload": "アプリをリロード",
|
||||
"account_basics_emails_title": "メールアドレス",
|
||||
"account_basics_emails_description": "メール通知とパスワードリセット用",
|
||||
"account_basics_emails_no_emails_yet": "まだメールアドレスはありません",
|
||||
"account_basics_emails_copied_to_clipboard": "メールアドレスをクリップボードにコピーしました",
|
||||
"account_basics_emails_chip_actions_primary": "主アドレス、デフォルトのメールアドレスとして使用します。クリックして操作を表示。",
|
||||
"account_basics_emails_chip_actions_verified": "通知として使えます。クリックして操作を表示。",
|
||||
"account_basics_emails_chip_actions_unverified": "未確認のメールアドレス、メールボックスを確認してください。クリックして操作を表示。",
|
||||
"account_basics_emails_unverified": "未確認",
|
||||
"account_basics_emails_set_primary": "プライマリメールアドレスに設定",
|
||||
"account_basics_emails_delete": "メールアドレスを削除",
|
||||
"account_basics_emails_resend": "確認メールを再送信",
|
||||
"account_basics_emails_resent": "確認メールを送信しました、メールボックスを確認してください",
|
||||
"account_basics_emails_primary_elsewhere": "このメールアドレスは他のアカウントでプライマリアドレスとして使用されています",
|
||||
"account_basics_emails_no_recovery_warning": "パスワードを忘れた時にアカウントを復活させられるように最低ひとつのメールアドレスを追加して下さい。",
|
||||
"account_basics_emails_no_primary_warning": "パスワードを忘れた時にアカウントを復活させられるようにプライマリメールアドレスを追加して下さい。",
|
||||
"account_basics_emails_dialog_title": "メールアドレスを追加",
|
||||
"account_basics_emails_dialog_description": "メールアドレスを入力してアカウントに追加します。あなたのものである事を確認するために確認リンクが送信されます。",
|
||||
"account_basics_emails_dialog_email_label": "メールアドレス",
|
||||
"account_basics_emails_dialog_email_placeholder": "例: user@example.com",
|
||||
"account_basics_emails_dialog_verify_button": "確認リンクを送信",
|
||||
"account_basics_emails_dialog_check_inbox": "メールアドレスを確認するためにメールボックスの確認リンクをクリックして下さい。それまでは未確認として表示されます。",
|
||||
"account_basics_tier_provisioned": "事前割り当て",
|
||||
"account_usage_emails_none": "このアカウントではメール通知を送信できません",
|
||||
"prefs_users_dialog_base_url_invalid": "URL形式が不正です。 http:// あるいは https:// から始まる必要があります",
|
||||
"prefs_users_dialog_base_url_exists": "このサービスURLにはユーザーが既に存在します"
|
||||
}
|
||||
|
||||
@@ -266,7 +266,7 @@
|
||||
"account_upgrade_dialog_button_cancel_subscription": "Avslutt abonnement",
|
||||
"account_tokens_description": "Bruk tilgangstokener når du publiserer og abonnerer via ntfy-APIet, slik at du ikke trenger å sende innloggingsinformasjon for kontoen din. Se <Link>dokumentasjonen</Link> for å lære mer.",
|
||||
"account_tokens_table_current_session": "Nåværende nettleserøkt",
|
||||
"prefs_appearance_theme_system": "System (standard)",
|
||||
"prefs_system_default": "System standard",
|
||||
"prefs_notifications_web_push_disabled_description": "Varslinger mottas når web-appen kjører (via WebSocket)",
|
||||
"prefs_appearance_theme_title": "Tema",
|
||||
"prefs_appearance_theme_dark": "Mørk modus",
|
||||
|
||||
@@ -397,7 +397,7 @@
|
||||
"prefs_notifications_web_push_disabled": "Uitgezet",
|
||||
"prefs_notifications_web_push_enabled_description": "Notificaties worden ontvangen, ook als de webapplicatie niet geopend is (via Web Push)",
|
||||
"prefs_appearance_theme_title": "Thema",
|
||||
"prefs_appearance_theme_system": "Systeem (standaard)",
|
||||
"prefs_system_default": "Systeem standaard",
|
||||
"prefs_appearance_theme_dark": "Donkere modus",
|
||||
"prefs_appearance_theme_light": "Lichte modus",
|
||||
"error_boundary_button_reload_ntfy": "Herlaad ntfy",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user