Compare commits

...
Author SHA1 Message Date
binwiederhier c9105dad09 Release notes 2026-06-26 16:01:18 -04:00
binwiederhier 5765be7892 Root ref 2026-06-26 15:59:34 -04:00
binwiederhier 7a99bfc717 COndense comments, better prefcache use for theme 2026-06-26 07:58:28 -04:00
binwiederhier 156ad4ae92 Merge branch 'main' into transitions 2026-06-26 06:21:21 -04:00
binwiederhier a6dc691672 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-06-26 06:21:15 -04:00
binwiederhier 9114d1e996 Pref context 2026-06-26 06:20:58 -04:00
Rui Barbosa 55d871aeb2 Translated using Weblate (Portuguese)
Currently translated at 94.3% (433 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt/
2026-06-26 05:16:49 +02:00
Hosted Weblate user 54392 a5318a4312 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 88.2% (405 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hans/
2026-06-26 05:16:49 +02:00
109247019824 f77d299aa3 Translated using Weblate (Bulgarian)
Currently translated at 88.2% (405 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2026-06-26 05:16:49 +02:00
binwiederhier af66cc3ddc Undo theme weirdness 2026-06-25 22:40:34 -04:00
binwiederhier a47eef5006 Centralize fade out 2026-06-25 22:33:25 -04:00
binwiederhier 0ac7cbd4fe Merge branch 'main' of github.com:binwiederhier/ntfy into transitions 2026-06-25 21:52:58 -04:00
binwiederhier 72ac95148b Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-06-25 21:52:31 -04:00
Rui Barbosa 817a0ece7e Translated using Weblate (Portuguese)
Currently translated at 94.3% (433 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt/
2026-06-26 03:52:11 +02:00
Hosted Weblate user 54392 e617c5f453 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 88.2% (405 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hans/
2026-06-26 03:52:09 +02:00
109247019824 a04a12eb5f Translated using Weblate (Bulgarian)
Currently translated at 88.2% (405 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2026-06-26 03:52:09 +02:00
binwiederhier 46799db753 German translations 2026-06-25 21:44:34 -04:00
binwiederhier 5e32f05302 Read /account from primary 2026-06-25 20:24:50 -04:00
binwiederhier e3889746c4 Merge branch 'main' of github.com:binwiederhier/ntfy into transitions 2026-06-24 20:17:48 -04:00
binwiederhier df6f1f3ee1 Bump 2026-06-24 19:16:09 -04:00
binwiederhier 047a1258f1 Transitions 2026-06-24 17:08:32 -04:00
binwiederhier 2b1f2d6b9a WIP: Web app transitions 2026-06-24 15:27:40 -04:00
binwiederhier 87e9dc9da4 Back link 2026-06-24 12:21:41 -04:00
binwiederhier 5d96888f0e Release notes 2026-06-24 12:15:11 -04:00
binwiederhier 65e409e9e4 PWA reload icon, lazy loading: EmojiPicker and MarkdownContent 2026-06-24 11:44:38 -04:00
binwiederhier 8022401728 Fix import loop in web app 2026-06-24 07:17:34 -04:00
binwiederhier 6d86574a38 Update release notes 2026-06-24 06:53:54 -04:00
binwiederhier 6a4f68897a Fix topic reservation icon in nav bar 2026-06-24 06:48:51 -04:00
binwiederhier b0411e5c92 More web tests 2026-06-24 05:56:48 -04:00
binwiederhier c5e5be0746 Merge branch 'main' of github.com:binwiederhier/ntfy into 1511-ampersand 2026-06-24 05:42:12 -04:00
binwiederhier ab2966e70f Add basic JS tests 2026-06-24 05:18:22 -04:00
binwiederhier 3a960b1b89 Release notes 2026-06-24 04:32:08 -04:00
Philipp C. Heckel ad45f70fd1 Merge pull request #1772 from mitya12342/http-sound-playback
Play notification sounds from the page ignoring Notification API support
2026-06-24 04:28:12 -04:00
binwiederhier 4b070546b5 Dependency bumps 2026-06-24 04:17:59 -04:00
binwiederhier e6a201bc11 Fix passwords with spaces 2026-06-23 13:10:17 -04:00
binwiederhier 8327047b71 Merge branch '1771-delete-clear-via-get' 2026-06-23 11:52:27 -04:00
binwiederhier c44575d7a1 Typo 2026-06-23 11:52:18 -04:00
binwiederhier f121f2ba8d Merge branch 'main' of github.com:binwiederhier/ntfy into 1771-delete-clear-via-get 2026-06-23 11:51:39 -04:00
binwiederhier 0c313906ef Release notes 2026-06-23 11:34:41 -04:00
binwiederhier ab1e170a20 Fix S3 scheme backwards compat 2026-06-22 21:43:29 -04:00
binwiederhier fb75a65885 Merge remote-tracking branch 'origin/main' into s3-scheme 2026-06-22 21:41:41 -04:00
binwiederhier 74240328dc Changelog, constant time compare 2026-06-22 21:21:48 -04:00
Philipp C. Heckel 3bfb9f334b Merge pull request #1795 from lmorchard/fix/sync-topic-deny-all
Grant users access to their own sync topic under deny-all (fixes #733)
2026-06-22 21:17:37 -04:00
binwiederhier 7b37f2a3eb Bump 2026-06-22 21:10:24 -04:00
binwiederhier d5b13a925e Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-06-22 21:08:32 -04:00
Philipp C. Heckel 50ac2c1925 Merge pull request #1785 from binwiederhier/password-reset
Password reset
2026-06-22 21:08:16 -04:00
Les OrchardandClaude Opus 4.8 cac3b2986a Grant users access to their own sync topic under deny-all
Manager.Authorize had no special case for a user's own sync topic, so with
auth-default-access=deny-all the per-account sync topic (st_...) fell through
to the default access and was denied. This broke web app account sync for
non-admin users: wss://.../st_<id>/ws returned 403. Admin-role users were
unaffected via the existing role bypass.

Allow a user full access to their own SyncTopic in Authorize. This fixes
existing users without a migration, since it needs no ACL row.

Fixes #733

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 11:58:29 -07:00
Sven Skender 19af0b65cc Preserve the original S3 endpoint scheme 2026-06-17 15:45:08 +02:00
cyvnrs 5834f667b2 Added translation using Weblate (Telugu) 2026-06-15 11:25:58 +02:00
Philipp C. Heckel fd0f0657b9 Merge pull request #1784 from rubixvi/patch-2
chores(docs): addition of WordPress ntfy integration
2026-06-12 11:24:38 -04:00
Vincent Vu a0775701c1 chores(docs): addition of WordPress ntfy integration
Add WordPress Rubix Notify - ntfy integration plugin.
2026-06-11 12:33:33 +10:00
Philipp C. Heckel d824f1a11a Merge pull request #1781 from binwiederhier/dependabot/github_actions/all-6a98abd9ac
Bump actions/checkout from 6.0.2 to 6.0.3 in the all group
2026-06-10 20:06:39 -04:00
dependabot[bot] 2d7faac0ea Bump actions/checkout from 6.0.2 to 6.0.3 in the all group
Bumps the all group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-09 21:44:37 +00:00
Hunter Kehoe 737163ba59 update release notes 2026-06-06 10:42:25 -06:00
Hunter Kehoe 26bc28ae24 support clear|read via GET 2026-06-06 10:38:09 -06:00
Hunter Kehoe 88fff8264b support delete via GET 2026-06-06 10:37:46 -06:00
Dimitriy Goloborodko 9cc6124f18 Play notification sounds from the page ignoring Notification API support 2026-06-06 20:06:02 +10:00
Hunter Kehoe fb89b87efb do not escape JSON response 2025-12-07 14:15:36 -07:00
81 changed files with 3874 additions and 1538 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
+2 -2
View File
@@ -9,10 +9,10 @@ jobs:
steps:
-
name: Checkout ntfy code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
-
name: Checkout docs pages code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
repository: binwiederhier/ntfy-docs.github.io
path: build/ntfy-docs.github.io
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
+21 -9
View File
@@ -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,7 +87,7 @@ help:
# Building everything
clean: .PHONY
clean: FORCE
rm -rf dist build server/docs server/site
build: web docs cli
@@ -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
@@ -269,13 +277,17 @@ check: test web-fmt-check fmt-check vet web-lint lint staticcheck
checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck
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:
@@ -307,7 +319,7 @@ lint:
which golint || go install golang.org/x/lint/golint@latest
go list ./... | grep -v /vendor/ | 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
+9
View File
@@ -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()
+38 -38
View File
@@ -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.25.0/ntfy_2.25.0_linux_amd64.tar.gz
tar zxvf ntfy_2.25.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.25.0_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.25.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.25.0/ntfy_2.25.0_linux_armv6.tar.gz
tar zxvf ntfy_2.25.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.25.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.25.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.25.0/ntfy_2.25.0_linux_armv7.tar.gz
tar zxvf ntfy_2.25.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.25.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.25.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.25.0/ntfy_2.25.0_linux_arm64.tar.gz
tar zxvf ntfy_2.25.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.25.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.25.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.25.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.25.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.25.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.25.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.25.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.25.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.25.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.25.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.25.0/ntfy_2.25.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.25.0/ntfy_2.25.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.25.0/ntfy_2.25.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.25.0/ntfy_2.25.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.25.0/ntfy_2.25.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.25.0/ntfy_2.25.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.25.0/ntfy_2.25.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.25.0/ntfy_2.25.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.25.0/ntfy_2.25.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.25.0/ntfy_2.25.0_darwin_all.tar.gz > ntfy_2.25.0_darwin_all.tar.gz
tar zxvf ntfy_2.25.0_darwin_all.tar.gz
sudo cp -a ntfy_2.25.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.25.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.25.0/ntfy_2.25.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`
+1
View File
@@ -189,6 +189,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
+39 -5
View File
@@ -2815,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"
@@ -4143,26 +4147,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"
@@ -4197,25 +4216,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"
+44 -22
View File
@@ -4,15 +4,50 @@ 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.25.0 | June 24, 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.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
* 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,25 +1983,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet
### ntfy server v2.25.0 (UNRELEASED)
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 emails on a regular basis.
**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
### ntfy server v2.26.x (UNRELEASED)
**Bug fixes + maintenance:**
* Generate access tokens, IDs, and magic-link tokens with a cryptographically secure RNG (`crypto/rand`) instead of a clock-seeded PRNG
* `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
* 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
### ntfy Android v1.25.x (UNRELEASED)
+18 -18
View File
@@ -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.62.3 // 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.286.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.38.0
)
require (
@@ -48,9 +48,9 @@ require (
cloud.google.com/go/longrunning v1.0.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
@@ -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/common v0.69.0 // indirect
github.com/prometheus/procfs v0.20.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,11 +94,11 @@ 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/genproto v0.0.0-20260622175928-b703f567277d // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+38 -38
View File
@@ -18,8 +18,8 @@ cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMh
cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM=
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.62.3 h1:SZq1t23NCI+e96dH77Dg3PEfsNNEjqO8zE5AnD8gVD0=
cloud.google.com/go/storage v1.62.3/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
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=
@@ -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/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.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
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=
@@ -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.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
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,16 +274,16 @@ 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.286.0 h1:TdTXMvzYKnWV1/lPbCdbXRqBrkDqjPto22H2xeZZ8LI=
google.golang.org/api v0.286.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ=
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/genproto v0.0.0-20260622175928-b703f567277d h1:CP5omUq8AJTiWMrPKM1WRLJ7zZeXd9OPcQD3TbBNAyY=
google.golang.org/genproto v0.0.0-20260622175928-b703f567277d/go.mod h1:DrwuGJgFSEVNpv3S5Q5VxhRTvdnjauw9GtvwVOEARfA=
google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d h1:xr2lwHI91bn3UiXcnyzRMQjp2LRiM8wEHzwUaE0YhTs=
google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d/go.mod h1:O0ZOWSrfWfJ+Z5HbwZ+wNtHsg/vk1k2C/w67eww8PfQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/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/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+29
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+10 -1
View File
@@ -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"
+6 -8
View File
@@ -90,6 +90,7 @@ 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
webAppConfigPath = "/config.js"
@@ -654,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)
@@ -1529,7 +1530,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
@@ -1540,7 +1541,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 {
@@ -2404,10 +2405,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) {
+4 -8
View File
@@ -180,10 +180,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
if err != nil {
return err
}
primaryEmail, err := s.userManager.PrimaryEmail(u.ID)
if err != nil {
return err
}
pendingEmails, err := s.userManager.PendingEmails(u.ID)
if err != nil {
return err
@@ -191,7 +187,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
// 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, Primary: email == primaryEmail})
emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email.Address, Primary: email.Primary})
}
for _, email := range pendingEmails {
emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email, Pending: true})
@@ -667,7 +663,7 @@ func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v
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
}
// Rate limit (counts against the user's email quota)
@@ -888,7 +884,7 @@ func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHT
if err != nil {
return "", errHTTPInternalError
} else if len(emails) > 0 {
return emails[0], nil
return emails[0].Address, nil
}
return "", errHTTPBadRequestEmailAddressNotVerified
}
@@ -903,7 +899,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 util.Contains(emails, email) {
} else if emails.Contains(email) {
return email, nil
}
return "", errHTTPBadRequestEmailAddressNotVerified
+1 -4
View File
@@ -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)
}
+1 -1
View File
@@ -72,7 +72,7 @@ func TestPayments_Checkout_SendsBillingEmailVerification(t *testing.T) {
emails, err := s.userManager.Emails(u.ID)
require.Nil(t, err)
require.Equal(t, []string{"billing@example.com"}, emails)
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)
+96
View File
@@ -4139,6 +4139,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()
@@ -4192,6 +4226,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()
@@ -4399,6 +4460,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 -16
View File
@@ -2,6 +2,7 @@
package user
import (
"crypto/subtle"
"database/sql"
"encoding/json"
"errors"
@@ -676,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
@@ -933,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) {
@@ -1196,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
}
@@ -1409,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
}
@@ -1457,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) {
@@ -1500,7 +1515,8 @@ func (a *Manager) RemoveEmail(userID, email string) error {
// the user has not designated one.
func (a *Manager) PrimaryEmail(userID string) (string, error) {
var email sql.NullString
err := a.db.ReadOnly().QueryRow(a.queries.selectPrimaryEmail, userID).Scan(&email)
// 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 {
@@ -1526,7 +1542,8 @@ func (a *Manager) UserIDByPrimaryEmail(email string) (string, error) {
// 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) {
rows, err := a.db.ReadOnly().Query(a.queries.selectPendingEmails, string(MagicLinkKindEmailVerify), userID)
// 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
}
@@ -1748,17 +1765,18 @@ func (a *Manager) deleteExpiredMagicLinks() error {
return err
}
func (a *Manager) readEmail(rows *sql.Rows) (string, error) {
var email string
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
+1 -1
View File
@@ -217,7 +217,7 @@ 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`
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`
+1 -1
View File
@@ -214,7 +214,7 @@ 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`
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 = ?`
+107 -6
View File
@@ -130,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*,
@@ -1150,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)
@@ -2694,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)
})
}
@@ -2864,7 +2871,7 @@ func TestUser_MagicLink_VerifyEmail_SetsPrimary(t *testing.T) {
emails, err = a.Emails(phil.ID)
require.Nil(t, err)
require.Equal(t, []string{"phil@example.com"}, emails)
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)
@@ -2897,7 +2904,7 @@ func TestUser_MagicLink_VerifyEmail_SecondStaysSecondary(t *testing.T) {
// 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)
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)
@@ -2926,7 +2933,7 @@ func TestUser_MagicLink_PrimaryGlobalUniqueness(t *testing.T) {
require.Nil(t, err)
emails, err := a.Emails(ben.ID)
require.Nil(t, err)
require.Equal(t, []string{"shared@example.com"}, emails)
require.Equal(t, []string{"shared@example.com"}, emails.Strings())
primary, err = a.PrimaryEmail(ben.ID)
require.Nil(t, err)
require.Equal(t, "", primary)
@@ -3161,7 +3168,7 @@ func TestUser_MagicLink_VerifyEmail_ProvisionedGetsPrimary(t *testing.T) {
emails, err := a.Emails(prov.ID)
require.Nil(t, err)
require.Equal(t, []string{"prov@example.com"}, emails)
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)
@@ -3212,3 +3219,97 @@ func TestUser_MagicLink_UserIDByPrimaryEmail_NotFound(t *testing.T) {
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)
}
+30
View File
@@ -182,6 +182,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
+10
View File
@@ -359,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.
//
+8
View File
@@ -1,6 +1,7 @@
package util
import (
"bytes"
"errors"
"io"
"net/netip"
@@ -299,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
View File
@@ -1 +1,2 @@
src/app/emojis.js
src/app/emojis.js
src/app/emojisMapped.js
+1
View File
@@ -2,3 +2,4 @@ build/
dist/
public/static/langs/
src/app/emojis.js
src/app/emojisMapped.js
+85 -1
View File
@@ -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>
+1524 -996
View File
File diff suppressed because it is too large Load Diff
+13 -10
View File
@@ -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-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",
@@ -40,8 +42,9 @@
"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"
"vite": "^8.0.16",
"vite-plugin-pwa": "^1.0.0",
"vitest": "^4.1.9"
},
"browserslist": {
"production": [

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+13
View File
@@ -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

+3 -1
View File
@@ -405,5 +405,7 @@
"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": "Презареждане"
}
+55 -1
View File
@@ -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"
}
+1
View File
@@ -67,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",
+32 -2
View File
@@ -215,7 +215,7 @@
"action_bar_reservation_add": "Reservar tópico",
"action_bar_sign_up": "Registar",
"nav_button_account": "Conta",
"common_copy_to_clipboard": "Copiar à área de transferência",
"common_copy_to_clipboard": "Copiar para a área de transferência",
"nav_upgrade_banner_label": "Upgrade para ntfy Pro",
"alert_not_supported_context_description": "As notificações são apenas suportadas através de HTTPS. Isto é uma limitação da <mdnLink>Notifications API</mdnLink>.",
"display_name_dialog_title": "Alterar o nome público",
@@ -406,5 +406,35 @@
"web_push_unknown_notification_title": "Notificação desconhecida recebida do servidor",
"web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo a aplicação da Web",
"account_basics_cannot_edit_or_delete_provisioned_user": "Não se pode editar ou eliminar um usuário predefinido",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Não se pode editar ou eliminar um token predefinido"
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Não se pode editar ou eliminar um token predefinido",
"common_close": "Fechar",
"common_refresh": "Atualizar",
"email_verify_progress_title": "A verificar o email...",
"email_verify_success_title": "Email verificado",
"email_verify_success_description": "O seu email foi verificado e adicionado à sua conta.",
"email_verify_error_title": "Verificação falhada",
"email_verify_error_description": "Este link de verificação é inválido ou expirou. Pode pedir um novo nas definições da sua conta.",
"email_verify_button_account": "Ir para conta",
"version_update_available_title": "Nova sessão disponível",
"version_update_available_description": "O servidor ntfy foi atualizado. Por favor atualiza a página.",
"signup_form_email": "Email (opcional, para recuperação de conta)",
"login_link_forgot_password": "Esqueci a palavra-passe",
"reset_password_request_title": "Redefinir palavra-passe",
"reset_password_request_description": "Introduza o seu nome de utilizador ou email. Se a conta existir, um link de redefinição de palavra-passe será enviado para o email.",
"reset_password_request_primary_required": "Isto apenas funciona se adicionar e verificar um email principal.",
"reset_password_request_identifier_label": "Nome de utilizador ou email",
"reset_password_request_button_submit": "Enviar link de redefinição",
"reset_password_sent_title": "Verifique a caixa de entrada",
"reset_password_sent_description": "Se a conta existir, um link de redefinição de palavra-passe foi enviado.",
"reset_password_back_to_login": "Voltar a início de sessão",
"reset_password_disabled": "Redefinição de palavra-passe desativada",
"reset_password_title": "Definir uma nova palavra-passe",
"reset_password_form_password": "Nova palavra-passe",
"reset_password_form_confirm": "Confirme a nova palavra-passe",
"reset_password_form_button_submit": "Definir palavra-passe",
"reset_password_form_error_invalid": "O link de redefinição é invalido ou expirou. Por favor peça um novo.",
"reset_password_success_title": "Palavra-passe alterada",
"reset_password_success_description": "A sua palavra passe foi alterada. Pode agora iniciar sessão com a nova palavra passe.",
"action_bar_reload": "Reiniciar aplicação",
"account_basics_emails_title": "Endereços de email"
}
+1
View File
@@ -0,0 +1 @@
{}
+2 -2
View File
@@ -247,8 +247,8 @@
"account_basics_tier_title": "账户类型",
"account_basics_tier_description": "您账户的权限级别",
"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_basic": "基础版",
"account_basics_tier_free": "免费",
"account_basics_tier_upgrade_button": "升级到专业版",
+207
View File
@@ -0,0 +1,207 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import session from "./Session";
import prefs from "./Prefs";
import subscriptionManager from "./SubscriptionManager";
import accountApi from "./AccountApi";
// AccountApi.js leans on several singletons; mock them so we can drive token() and assert the
// side effects of sync(), and so importing it doesn't drag in the component/Dexie/i18n trees.
// (vi.mock is hoisted above the imports by vitest, so those imports receive the mocks.)
vi.mock("./Session", () => ({
default: { token: vi.fn(() => "test-token"), setLastExtendedAtAsync: vi.fn(), resetAndRedirect: vi.fn() },
}));
vi.mock("./SubscriptionManager", () => ({ default: { syncFromRemote: vi.fn() } }));
vi.mock("./Prefs", () => ({
default: { setSound: vi.fn(), setDeleteAfter: vi.fn(), setMinPriority: vi.fn() },
THEME: { DARK: "dark", LIGHT: "light", SYSTEM: "system" },
}));
vi.mock("i18next", () => ({ default: { changeLanguage: vi.fn() } }));
vi.mock("../components/routes", () => ({ default: { login: "/login" } }));
let fetchMock;
// fetchOrThrow treats anything other than HTTP 200 as an error, so a fake response just needs a
// status and a json() method.
const ok = (body = {}) => ({ status: 200, json: async () => body });
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "log").mockImplementation(() => {});
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
session.token.mockReturnValue("test-token");
accountApi.tiers = null; // reset the billing-tiers cache between tests
accountApi.listener = null;
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("AccountApi.login", () => {
it("POSTs basic auth to the token URL and returns the token", async () => {
fetchMock.mockResolvedValue(ok({ token: "tk_returned" }));
const token = await accountApi.login({ username: "phil", password: "secret" });
expect(token).toBe("tk_returned");
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe("https://ntfy.sh/v1/account/token");
expect(options.method).toBe("POST");
expect(options.headers.Authorization).toBe(`Basic ${btoa("phil:secret")}`);
});
it("throws when the server response has no token", async () => {
fetchMock.mockResolvedValue(ok({}));
await expect(accountApi.login({ username: "phil", password: "secret" })).rejects.toThrow("Cannot find token");
});
});
describe("AccountApi.create", () => {
it("POSTs username/password and defaults email to an empty string", async () => {
fetchMock.mockResolvedValue(ok());
await accountApi.create("phil", "pw");
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/v1/account",
expect.objectContaining({ method: "POST", body: JSON.stringify({ username: "phil", password: "pw", email: "" }) })
);
});
});
describe("AccountApi.get", () => {
it("returns the parsed account and notifies the listener", async () => {
fetchMock.mockResolvedValue(ok({ username: "phil" }));
const listener = vi.fn();
accountApi.registerListener(listener);
const account = await accountApi.get();
expect(account).toEqual({ username: "phil" });
expect(listener).toHaveBeenCalledWith({ username: "phil" });
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe("https://ntfy.sh/v1/account");
expect(options.headers.Authorization).toBe("Bearer test-token");
});
});
describe("AccountApi.changePassword", () => {
it("POSTs the current and new passwords", async () => {
fetchMock.mockResolvedValue(ok());
await accountApi.changePassword("old", "new");
const [, options] = fetchMock.mock.calls[0];
expect(options.body).toBe(JSON.stringify({ password: "old", new_password: "new" }));
});
});
describe("AccountApi.createToken", () => {
it("converts a positive expiry into an absolute unix timestamp", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); // 1767225600 seconds
fetchMock.mockResolvedValue(ok());
await accountApi.createToken("my label", 3600);
const [, options] = fetchMock.mock.calls[0];
expect(JSON.parse(options.body)).toEqual({ label: "my label", expires: 1767225600 + 3600 });
});
it("sends expires=0 for a non-expiring token", async () => {
fetchMock.mockResolvedValue(ok());
await accountApi.createToken("forever", 0);
const [, options] = fetchMock.mock.calls[0];
expect(JSON.parse(options.body).expires).toBe(0);
});
});
describe("AccountApi.deleteToken", () => {
it("passes the target token in the X-Token header alongside the bearer token", async () => {
fetchMock.mockResolvedValue(ok());
await accountApi.deleteToken("tk_target");
const [, options] = fetchMock.mock.calls[0];
expect(options.method).toBe("DELETE");
expect(options.headers["X-Token"]).toBe("tk_target");
expect(options.headers.Authorization).toBe("Bearer test-token");
});
});
describe("AccountApi subscriptions", () => {
it("addSubscription POSTs base_url/topic and returns the parsed subscription", async () => {
fetchMock.mockResolvedValue(ok({ id: "sub_1" }));
const subscription = await accountApi.addSubscription("https://ntfy.sh", "mytopic");
expect(subscription).toEqual({ id: "sub_1" });
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe("https://ntfy.sh/v1/account/subscription");
expect(options.method).toBe("POST");
expect(JSON.parse(options.body)).toEqual({ base_url: "https://ntfy.sh", topic: "mytopic" });
});
it("deleteSubscription passes baseUrl/topic via headers", async () => {
fetchMock.mockResolvedValue(ok());
await accountApi.deleteSubscription("https://ntfy.sh", "mytopic");
const [, options] = fetchMock.mock.calls[0];
expect(options.method).toBe("DELETE");
expect(options.headers["X-BaseURL"]).toBe("https://ntfy.sh");
expect(options.headers["X-Topic"]).toBe("mytopic");
});
});
describe("AccountApi.billingTiers", () => {
it("caches the tiers and only fetches once", async () => {
fetchMock.mockResolvedValue(ok([{ code: "pro" }]));
const first = await accountApi.billingTiers();
const second = await accountApi.billingTiers();
expect(first).toEqual([{ code: "pro" }]);
expect(second).toBe(first);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
describe("AccountApi.requestPasswordReset", () => {
it("POSTs the identifier without any auth header", async () => {
fetchMock.mockResolvedValue(ok());
await accountApi.requestPasswordReset("phil");
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe("https://ntfy.sh/v1/account/password/reset/request");
expect(options.body).toBe(JSON.stringify({ identifier: "phil" }));
expect(options.headers).toBeUndefined();
});
});
describe("AccountApi.sync", () => {
it("returns null and makes no request when there is no token", async () => {
session.token.mockReturnValue(null);
expect(await accountApi.sync()).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it("resets the session and redirects to login on an unauthorized response", async () => {
fetchMock.mockResolvedValue({ status: 401, json: async () => ({}) });
const result = await accountApi.sync();
expect(result).toBeUndefined();
expect(session.resetAndRedirect).toHaveBeenCalledWith("/login");
});
it("applies language, notification prefs and subscriptions from the fetched account", async () => {
fetchMock.mockResolvedValue(
ok({
language: "de",
notification: { sound: "ding", delete_after: 3600, min_priority: 3 },
subscriptions: [{ topic: "t" }],
reservations: [{ topic: "t" }],
})
);
await accountApi.sync();
expect(i18n.changeLanguage).toHaveBeenCalledWith("de");
expect(prefs.setSound).toHaveBeenCalledWith("ding");
expect(prefs.setDeleteAfter).toHaveBeenCalledWith(3600);
expect(prefs.setMinPriority).toHaveBeenCalledWith(3);
expect(subscriptionManager.syncFromRemote).toHaveBeenCalledWith([{ topic: "t" }], [{ topic: "t" }]);
});
});
+127
View File
@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import userManager from "./UserManager";
import api from "./Api";
// Api.js talks to the server through the global fetch and looks up credentials via userManager.
// (vi.mock is hoisted above the imports by vitest, so the import above receives the mock.)
vi.mock("./UserManager", () => ({ default: { get: vi.fn() } }));
let fetchMock;
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "log").mockImplementation(() => {});
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
userManager.get.mockResolvedValue(undefined); // anonymous by default
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("Api.poll", () => {
it("parses newline-delimited JSON and skips lines without an id", async () => {
const body = [
JSON.stringify({ id: "1", message: "a" }),
JSON.stringify({ event: "keepalive" }), // no id -> skipped
JSON.stringify({ id: "2", message: "b" }),
].join("\n");
fetchMock.mockResolvedValue(new Response(body));
const messages = await api.poll("https://ntfy.sh", "mytopic");
expect(messages.map((m) => m.id)).toEqual(["1", "2"]);
});
it("uses the plain poll URL without a since cursor", async () => {
fetchMock.mockResolvedValue(new Response(""));
await api.poll("https://ntfy.sh", "mytopic");
expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/mytopic/json?poll=1", expect.anything());
});
it("uses the since URL when a cursor is given", async () => {
fetchMock.mockResolvedValue(new Response(""));
await api.poll("https://ntfy.sh", "mytopic", 12345);
expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/mytopic/json?poll=1&since=12345", expect.anything());
});
});
describe("Api.publish", () => {
it("PUTs the message body to the base URL", async () => {
fetchMock.mockResolvedValue({ status: 200 });
await api.publish("https://ntfy.sh", "mytopic", "Hello", { priority: 5, tags: ["warning"] });
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh",
expect.objectContaining({
method: "PUT",
body: JSON.stringify({ topic: "mytopic", message: "Hello", priority: 5, tags: ["warning"] }),
})
);
});
it("attaches basic auth when the user has a password", async () => {
userManager.get.mockResolvedValue({ username: "phil", password: "secret" });
fetchMock.mockResolvedValue({ status: 200 });
await api.publish("https://ntfy.sh", "mytopic", "Hi");
const [, options] = fetchMock.mock.calls[0];
expect(options.headers.Authorization).toBe(`Basic ${btoa("phil:secret")}`);
});
it("attaches bearer auth when the user has a token", async () => {
userManager.get.mockResolvedValue({ token: "tk_abc" });
fetchMock.mockResolvedValue({ status: 200 });
await api.publish("https://ntfy.sh", "mytopic", "Hi");
const [, options] = fetchMock.mock.calls[0];
expect(options.headers.Authorization).toBe("Bearer tk_abc");
});
});
describe("Api.topicAuth", () => {
it("returns true for a 2xx response", async () => {
fetchMock.mockResolvedValue({ status: 200 });
expect(await api.topicAuth("https://ntfy.sh", "mytopic")).toBe(true);
});
it("returns false for 401/403", async () => {
fetchMock.mockResolvedValue({ status: 403 });
expect(await api.topicAuth("https://ntfy.sh", "mytopic")).toBe(false);
});
it("throws for any other status", async () => {
fetchMock.mockResolvedValue({ status: 500 });
await expect(api.topicAuth("https://ntfy.sh", "mytopic")).rejects.toThrow("Unexpected server response 500");
});
});
describe("Api web push", () => {
const pushSubscription = {
endpoint: "https://push.example/abc",
keys: { auth: "AUTH", p256dh: "P256" },
};
it("updateWebPush POSTs endpoint, keys and topics to the web push URL", async () => {
fetchMock.mockResolvedValue({ status: 200 });
await api.updateWebPush(pushSubscription, ["topicA", "topicB"]);
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/v1/webpush",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ endpoint: "https://push.example/abc", auth: "AUTH", p256dh: "P256", topics: ["topicA", "topicB"] }),
})
);
});
it("deleteWebPush DELETEs just the endpoint", async () => {
fetchMock.mockResolvedValue({ status: 200 });
await api.deleteWebPush(pushSubscription);
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/v1/webpush",
expect.objectContaining({
method: "DELETE",
body: JSON.stringify({ endpoint: "https://push.example/abc" }),
})
);
});
});
+2 -2
View File
@@ -11,12 +11,12 @@ class Notifier {
lastSoundPlayedAt = 0;
async notify(subscription, notification) {
await this.playSound();
if (!this.supported()) {
return;
}
await this.playSound();
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
const defaultTitle = topicDisplayName(subscription);
+14 -5
View File
@@ -6,6 +6,15 @@ export const THEME = {
SYSTEM: "system",
};
// Default values the getters return when a pref is unset; also used by PrefCache (PrefCache.jsx).
export const PREF_DEFAULTS = {
sound: "ding",
minPriority: 1,
deleteAfter: 604800, // one week
theme: THEME.SYSTEM,
webPushEnabled: false,
};
class Prefs {
constructor(dbImpl) {
this.db = dbImpl;
@@ -17,7 +26,7 @@ class Prefs {
async sound() {
const sound = await this.db.prefs.get("sound");
return sound ? sound.value : "ding";
return sound ? sound.value : PREF_DEFAULTS.sound;
}
async setMinPriority(minPriority) {
@@ -26,7 +35,7 @@ class Prefs {
async minPriority() {
const minPriority = await this.db.prefs.get("minPriority");
return minPriority ? Number(minPriority.value) : 1;
return minPriority ? Number(minPriority.value) : PREF_DEFAULTS.minPriority;
}
async setDeleteAfter(deleteAfter) {
@@ -35,12 +44,12 @@ class Prefs {
async deleteAfter() {
const deleteAfter = await this.db.prefs.get("deleteAfter");
return deleteAfter ? Number(deleteAfter.value) : 604800; // Default is one week
return deleteAfter ? Number(deleteAfter.value) : PREF_DEFAULTS.deleteAfter;
}
async webPushEnabled() {
const webPushEnabled = await this.db.prefs.get("webPushEnabled");
return webPushEnabled?.value;
return webPushEnabled?.value ?? PREF_DEFAULTS.webPushEnabled;
}
async setWebPushEnabled(enabled) {
@@ -49,7 +58,7 @@ class Prefs {
async theme() {
const theme = await this.db.prefs.get("theme");
return theme?.value ?? THEME.SYSTEM;
return theme?.value ?? PREF_DEFAULTS.theme;
}
async setTheme(mode) {
+7 -1
View File
@@ -1,4 +1,5 @@
import Dexie from "dexie";
import { fadeOut } from "./transition";
/**
* Manages the logged-in user's session and access token.
@@ -42,7 +43,12 @@ class Session {
localStorage.setItem("token", token);
}
async resetAndRedirect(url) {
async resetAndRedirect(url, { fade = false } = {}) {
// User-initiated exits (logout, account deletion) fade out first, while data's intact, so the
// wipe + reload doesn't flash a broken UI. Error redirects pass no options and cut straight through.
if (fade) {
await fadeOut();
}
await this.db.delete();
localStorage.removeItem("user");
localStorage.removeItem("token");
+20 -5
View File
@@ -6,7 +6,7 @@ import { topicUrl } from "./utils";
import { messageWithSequenceId } from "./notificationUtils";
import { EVENT_MESSAGE, EVENT_MESSAGE_CLEAR, EVENT_MESSAGE_DELETE } from "./events";
class SubscriptionManager {
export class SubscriptionManager {
constructor(dbImpl) {
this.db = dbImpl;
}
@@ -65,23 +65,36 @@ class SubscriptionManager {
}
/**
* Upsert a subscription: create it if it doesn't exist yet, or merge the given fields into the
* existing one. Merging matters for account sync, which passes the remote display name and
* reservation -- without it, reserving/unreserving a topic you're already subscribed to would
* never be reflected locally until the database is recreated (e.g. a fresh login). Local-only
* state such as mutedUntil and last is preserved on merge.
*
* @param {string} baseUrl
* @param {string} topic
* @param {object} opts
* @param {boolean} opts.internal
* @returns
*/
async add(baseUrl, topic, opts = {}) {
async upsert(baseUrl, topic, opts = {}) {
const id = topicUrl(baseUrl, topic);
const existingSubscription = await this.get(id);
if (existingSubscription) {
return existingSubscription;
// Avoid a needless write (and the resulting Dexie live-query churn) when nothing changed.
const changed = Object.keys(opts).some((key) => existingSubscription[key] !== opts[key]);
if (!changed) {
return existingSubscription;
}
const updatedSubscription = { ...existingSubscription, ...opts };
await this.db.subscriptions.put(updatedSubscription);
return updatedSubscription;
}
const subscription = {
...opts,
id: topicUrl(baseUrl, topic),
id,
baseUrl,
topic,
mutedUntil: 0,
@@ -101,7 +114,9 @@ class SubscriptionManager {
remoteSubscriptions.map(async (remote) => {
const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null;
const local = await this.add(remote.base_url, remote.topic, {
// upsert(): for topics that already exist locally this merges in the latest remote
// display name and reservation (see upsert() for why this matters).
const local = await this.upsert(remote.base_url, remote.topic, {
displayName: remote.display_name, // May be undefined
reservation, // May be null!
});
+111
View File
@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// SubscriptionManager pulls in a handful of browser/Dexie-heavy singletons at import time. Mock
// them so the module imports cleanly under the node test environment; the tests construct their
// own SubscriptionManager with an in-memory fake db, so the real db singleton is never used.
vi.mock("./Api", () => ({ default: {} }));
vi.mock("./Notifier", () => ({ default: {} }));
vi.mock("./Prefs", () => ({ default: {} }));
vi.mock("./db", () => ({ default: () => ({}) }));
const { SubscriptionManager } = await import("./SubscriptionManager");
// Minimal in-memory stand-in for the Dexie "subscriptions" table, implementing just the surface
// that syncFromRemote() (and the upsert/remove/update helpers it calls) touches.
const fakeDb = () => {
const rows = new Map();
return {
rows,
subscriptions: {
get: async (id) => rows.get(id),
put: async (sub) => {
rows.set(sub.id, sub);
},
update: async (id, changes) => {
const existing = rows.get(id);
if (existing) {
rows.set(id, { ...existing, ...changes });
}
},
delete: async (id) => {
rows.delete(id);
},
toArray: async () => Array.from(rows.values()),
},
};
};
const baseUrl = "https://ntfy.sh";
beforeEach(() => {
vi.spyOn(console, "log").mockImplementation(() => {});
});
describe("SubscriptionManager.upsert", () => {
it("merges fields into an existing subscription without clobbering local-only state", async () => {
const db = fakeDb();
const manager = new SubscriptionManager(db);
await manager.upsert(baseUrl, "mytopic");
await manager.setMutedUntil("https://ntfy.sh/mytopic", 123);
const reservation = { topic: "mytopic", everyone: "deny-all" };
await manager.upsert(baseUrl, "mytopic", { displayName: "My Topic", reservation });
const stored = db.rows.get("https://ntfy.sh/mytopic");
expect(stored.reservation).toEqual(reservation);
expect(stored.displayName).toBe("My Topic");
expect(stored.mutedUntil).toBe(123); // local-only state preserved
});
it("does not write when an existing subscription would not change", async () => {
const db = fakeDb();
const manager = new SubscriptionManager(db);
await manager.upsert(baseUrl, "mytopic", { internal: true });
const putSpy = vi.spyOn(db.subscriptions, "put");
const result = await manager.upsert(baseUrl, "mytopic", { internal: true });
expect(putSpy).not.toHaveBeenCalled();
expect(result.topic).toBe("mytopic");
});
});
describe("SubscriptionManager.syncFromRemote", () => {
it("persists a reservation onto a subscription that already exists locally", async () => {
const db = fakeDb();
const manager = new SubscriptionManager(db);
// Topic was subscribed to before it was reserved, so it already exists locally without a
// reservation -- exactly the state when a user clicks "Reserve topic" in the navbar.
await manager.upsert(baseUrl, "mytopic");
expect(db.rows.get("https://ntfy.sh/mytopic").reservation).toBeFalsy();
const reservation = { topic: "mytopic", everyone: "deny-all" };
await manager.syncFromRemote([{ base_url: baseUrl, topic: "mytopic" }], [reservation]);
expect(db.rows.get("https://ntfy.sh/mytopic").reservation).toEqual(reservation);
});
it("clears the reservation when the remote no longer reports one", async () => {
const db = fakeDb();
const manager = new SubscriptionManager(db);
await manager.upsert(baseUrl, "mytopic", { reservation: { topic: "mytopic", everyone: "deny-all" } });
await manager.syncFromRemote([{ base_url: baseUrl, topic: "mytopic" }], []);
expect(db.rows.get("https://ntfy.sh/mytopic").reservation).toBeNull();
});
it("updates the display name on an existing subscription", async () => {
const db = fakeDb();
const manager = new SubscriptionManager(db);
await manager.upsert(baseUrl, "mytopic");
await manager.syncFromRemote([{ base_url: baseUrl, topic: "mytopic", display_name: "My Topic" }], []);
expect(db.rows.get("https://ntfy.sh/mytopic").displayName).toBe("My Topic");
});
});
File diff suppressed because one or more lines are too long
+93
View File
@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
UnauthorizedError,
UserExistsError,
TopicReservedError,
AccountActionLimitReachedError,
IncorrectPasswordError,
EmailVerificationCodeInvalidError,
EmailPrimaryElsewhereError,
throwAppError,
fetchOrThrow,
} from "./errors";
// A minimal stand-in for a fetch Response: just the bits errors.js reads.
const fakeResponse = (status, body) => ({
status,
json: async () => {
if (body === undefined) throw new Error("no body");
return body;
},
});
beforeEach(() => {
// errors.js logs to console.log on every error path; keep test output clean.
vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("error classes", () => {
it("carry the server error codes from errors.go", () => {
expect(UserExistsError.CODE).toBe(40901);
expect(TopicReservedError.CODE).toBe(40902);
expect(AccountActionLimitReachedError.CODE).toBe(42906);
expect(IncorrectPasswordError.CODE).toBe(40026);
expect(EmailVerificationCodeInvalidError.CODE).toBe(40051);
expect(EmailPrimaryElsewhereError.CODE).toBe(40908);
});
it("are Error subclasses with human-readable messages", () => {
expect(new UnauthorizedError()).toBeInstanceOf(Error);
expect(new UnauthorizedError().message).toBe("Unauthorized");
expect(new UserExistsError().message).toBe("Username already exists");
});
});
describe("throwAppError", () => {
it("maps 401 and 403 to UnauthorizedError", async () => {
await expect(throwAppError(fakeResponse(401))).rejects.toBeInstanceOf(UnauthorizedError);
await expect(throwAppError(fakeResponse(403))).rejects.toBeInstanceOf(UnauthorizedError);
});
it("maps known ntfy error codes to their specific error classes", async () => {
await expect(throwAppError(fakeResponse(409, { code: UserExistsError.CODE }))).rejects.toBeInstanceOf(UserExistsError);
await expect(throwAppError(fakeResponse(409, { code: TopicReservedError.CODE }))).rejects.toBeInstanceOf(TopicReservedError);
await expect(throwAppError(fakeResponse(429, { code: AccountActionLimitReachedError.CODE }))).rejects.toBeInstanceOf(
AccountActionLimitReachedError
);
});
it("wraps an unknown code that carries an error message in a generic Error", async () => {
await expect(throwAppError(fakeResponse(400, { code: 12345, error: "boom" }))).rejects.toThrow("Error 12345: boom");
});
it("throws a generic 'Unexpected response' error when there is no ntfy error body", async () => {
await expect(throwAppError(fakeResponse(500))).rejects.toThrow("Unexpected response 500");
});
});
describe("fetchOrThrow", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("returns the response unchanged on HTTP 200", async () => {
const response = { status: 200 };
vi.stubGlobal(
"fetch",
vi.fn(async () => response)
);
await expect(fetchOrThrow("https://ntfy.sh/mytopic/json")).resolves.toBe(response);
});
it("throws on a non-200 response", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => fakeResponse(401))
);
await expect(fetchOrThrow("https://ntfy.sh/mytopic/json")).rejects.toBeInstanceOf(UnauthorizedError);
});
});
+1 -1
View File
@@ -49,7 +49,7 @@ export const isImage = (attachment) => {
};
export const icon = "/static/images/ntfy.png";
export const badge = "/static/images/mask-icon.svg";
export const badge = "/static/images/ntfy-mask.svg";
/**
* Computes a unique notification tag scoped by baseUrl, topic, and sequence ID.
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, it } from "vitest";
import emojisMapped from "./emojisMapped";
import { formatTitle, formatMessage, isImage, notificationTag, toNotificationParams, messageWithSequenceId } from "./notificationUtils";
// Pick a real alias/emoji pair so we don't hardcode a specific glyph that could change upstream.
const [emojiAlias, emojiChar] = Object.entries(emojisMapped)[0];
describe("formatTitle", () => {
it("returns the bare title when there are no emoji tags", () => {
expect(formatTitle({ title: "Hello", tags: ["not-an-emoji"] })).toBe("Hello");
expect(formatTitle({ title: "Hello" })).toBe("Hello");
});
it("prepends mapped emoji for emoji tags", () => {
expect(formatTitle({ title: "Hello", tags: [emojiAlias] })).toBe(`${emojiChar} Hello`);
});
});
describe("formatMessage", () => {
it("returns the message untouched when the notification has a title", () => {
expect(formatMessage({ title: "T", message: "Body", tags: [emojiAlias] })).toBe("Body");
});
it("prepends emoji to the message when there is no title", () => {
expect(formatMessage({ message: "Body", tags: [emojiAlias] })).toBe(`${emojiChar} Body`);
});
it("falls back to an empty string when message is missing", () => {
expect(formatMessage({})).toBe("");
});
});
describe("isImage", () => {
it("trusts an explicit image MIME type", () => {
expect(isImage({ type: "image/png", name: "whatever.txt" })).toBe(true);
expect(isImage({ type: "application/pdf", name: "x.png" })).toBe(false);
});
it("falls back to the file extension when there is no type", () => {
expect(isImage({ name: "photo.JPEG" })).toBeTruthy();
expect(isImage({ url: "https://ntfy.sh/file/x.webp" })).toBeTruthy();
expect(isImage({ name: "notes.txt" })).toBeFalsy();
});
it("returns false for a missing attachment", () => {
expect(isImage(undefined)).toBe(false);
});
});
describe("notificationTag", () => {
it("scopes the tag by baseUrl, topic and sequence id", () => {
expect(notificationTag("https://ntfy.sh", "mytopic", 42)).toBe("https://ntfy.sh/mytopic/42");
});
});
describe("toNotificationParams", () => {
const baseMessage = {
id: "msg-1",
time: 1700000000,
title: "Title",
message: "Body",
actions: [
{ action: "view", label: "Open" },
{ action: "http", label: "Send" },
{ action: "broadcast", label: "Cast" },
],
};
it("builds the [title, options] tuple consumed by the Notifications API", () => {
const [title, options] = toNotificationParams({
message: baseMessage,
defaultTitle: "fallback",
topicRoute: "/mytopic",
baseUrl: "https://ntfy.sh",
topic: "mytopic",
});
expect(title).toBe("Title");
expect(options.body).toBe("Body");
expect(options.tag).toBe("https://ntfy.sh/mytopic/msg-1");
expect(options.timestamp).toBe(baseMessage.time * 1000);
expect(options.data.subscriptionId).toBe("https://ntfy.sh/mytopic");
expect(options.data.topicRoute).toBe("/mytopic");
});
it("keeps only view/http actions and maps them to {action,title}", () => {
const [, options] = toNotificationParams({
message: baseMessage,
defaultTitle: "fallback",
topicRoute: "/mytopic",
baseUrl: "https://ntfy.sh",
topic: "mytopic",
});
expect(options.actions).toEqual([
{ action: "Open", title: "Open" },
{ action: "Send", title: "Send" },
]);
});
it("uses the default title when the message has none", () => {
const [title] = toNotificationParams({
message: { id: "x", time: 1, message: "Body" },
defaultTitle: "fallback",
topicRoute: "/mytopic",
baseUrl: "https://ntfy.sh",
topic: "mytopic",
});
expect(title).toBe("fallback");
});
it("prefers sequence_id over id for the notification tag", () => {
const [, options] = toNotificationParams({
message: { ...baseMessage, sequence_id: 99 },
defaultTitle: "fallback",
topicRoute: "/mytopic",
baseUrl: "https://ntfy.sh",
topic: "mytopic",
});
expect(options.tag).toBe("https://ntfy.sh/mytopic/99");
});
});
describe("messageWithSequenceId", () => {
it("derives sequenceId from sequence_id when absent", () => {
expect(messageWithSequenceId({ id: "a", sequence_id: 7 }).sequenceId).toBe(7);
});
it("falls back to id when sequence_id is missing", () => {
expect(messageWithSequenceId({ id: "a" }).sequenceId).toBe("a");
});
it("returns the message unchanged when sequenceId already set", () => {
const m = { id: "a", sequenceId: 1 };
expect(messageWithSequenceId(m)).toBe(m);
});
});
+58
View File
@@ -0,0 +1,58 @@
// Fades out and removes the static splash (see web/index.html), called once the app has mounted and
// its data is ready. Idempotent.
// Minimum time the splash stays up, so it doesn't flash-and-vanish on warm-cache loads.
const MIN_VISIBLE_MS = 1000;
// Hide in two phases: fade the logo out, then fade the background away to reveal the app.
// APP_FADE_MS must match the #splash opacity transition in index.html.
const LOGO_FADE_MS = 300;
const APP_FADE_MS = 100;
let removed = false;
const fadeOutAndRemove = () => {
const splash = document.getElementById("splash");
if (!splash) {
return;
}
// Phase 1: freeze the pulse at its current opacity (else stopping the animation snaps to full),
// then fade the logo to 0.
const img = splash.querySelector("img");
if (img) {
const current = getComputedStyle(img).opacity;
img.style.opacity = current;
img.style.animation = "none";
img.getBoundingClientRect(); // force reflow so the fade starts from `current`
img.style.transition = `opacity ${LOGO_FADE_MS}ms ease-out`;
img.style.opacity = "0";
}
// Phase 2: lift the background to fade the app in, then remove the node.
setTimeout(() => {
splash.classList.add("splash-hidden");
const remove = () => splash.remove();
// Ignore the logo's bubbling transitionend; only the background's own fade should remove it.
const onEnd = (event) => {
if (event.target === splash) {
splash.removeEventListener("transitionend", onEnd);
remove();
}
};
splash.addEventListener("transitionend", onEnd);
setTimeout(remove, APP_FADE_MS + 100); // fallback if transitionend never fires
}, LOGO_FADE_MS);
};
const hideSplash = () => {
if (removed) {
return;
}
removed = true;
// performance.now() ~= how long the splash has been visible; hold until MIN_VISIBLE_MS elapses.
const remaining = Math.max(0, MIN_VISIBLE_MS - performance.now());
setTimeout(fadeOutAndRemove, remaining);
};
export default hideSplash;
+49
View File
@@ -0,0 +1,49 @@
// Fade transitions between the app and the auth pages (login/signup/reset): fade #root out, then
// navigate client-side (fading back in) or full-reload (the splash fades the next page in).
const FADE_MS = 150;
const rootNode = () => document.getElementById("root");
// Fade #root out, resolving when done. Exported so callers can await it before their own
// teardown + reload (e.g. resetAndRedirect wiping IndexedDB).
export const fadeOut = () =>
new Promise((resolve) => {
const node = rootNode();
if (!node) {
resolve();
return;
}
node.style.transition = `opacity ${FADE_MS}ms ease-out`;
node.style.opacity = "0";
setTimeout(resolve, FADE_MS);
});
// Fade #root back in, then strip the inline styles (a lingering `transition` would animate future
// opacity changes). setTimeout, not rAF, so a backgrounded tab can't strand #root at opacity 0.
const fadeInRoot = () => {
const node = rootNode();
if (!node) {
return;
}
node.style.opacity = "1";
setTimeout(() => {
node.style.transition = "";
node.style.opacity = "";
}, FADE_MS);
};
// Fade out, navigate client-side, fade the new page in (app -> login/signup, no reload).
export const fadeNavigate = (navigate, to) => {
fadeOut().then(() => {
navigate(to);
fadeInRoot();
});
};
// Fade out, then full-reload to `url` (login/signup -> app needs a reload for the per-user DB).
// The splash fades it back in.
export const fadeReload = (url) => {
fadeOut().then(() => {
window.location.href = url;
});
};
+153
View File
@@ -0,0 +1,153 @@
import { describe, expect, it } from "vitest";
import { THEME } from "./Prefs";
import {
topicUrl,
topicUrlWs,
topicUrlJsonPollWithSince,
accountUrl,
accountTokenUrl,
shortUrl,
expandUrl,
expandSecureUrl,
validUrl,
validTopic,
disallowedTopic,
encodeBase64,
encodeBase64Url,
bearerAuth,
basicAuth,
withBearerAuth,
maybeWithAuth,
splitNoEmpty,
hashCode,
formatBytes,
formatNumber,
formatPrice,
formatShortDuration,
getKebabCaseLangStr,
darkModeEnabled,
urlB64ToUint8Array,
} from "./utils";
describe("URL builders", () => {
it("build topic URLs", () => {
expect(topicUrl("https://ntfy.sh", "mytopic")).toBe("https://ntfy.sh/mytopic");
expect(topicUrlJsonPollWithSince("https://ntfy.sh", "mytopic", 123)).toBe("https://ntfy.sh/mytopic/json?poll=1&since=123");
});
it("rewrite the scheme for websocket URLs", () => {
expect(topicUrlWs("https://ntfy.sh", "mytopic")).toBe("wss://ntfy.sh/mytopic/ws");
expect(topicUrlWs("http://localhost:8080", "mytopic")).toBe("ws://localhost:8080/mytopic/ws");
});
it("build account URLs", () => {
expect(accountUrl("https://ntfy.sh")).toBe("https://ntfy.sh/v1/account");
expect(accountTokenUrl("https://ntfy.sh")).toBe("https://ntfy.sh/v1/account/token");
});
});
describe("url helpers", () => {
it("strip the scheme with shortUrl", () => {
expect(shortUrl("https://ntfy.sh/mytopic")).toBe("ntfy.sh/mytopic");
});
it("expand a bare host to both schemes", () => {
expect(expandUrl("ntfy.sh")).toEqual(["https://ntfy.sh", "http://ntfy.sh"]);
expect(expandSecureUrl("ntfy.sh")).toBe("https://ntfy.sh");
});
it("validate http(s) URLs", () => {
expect(validUrl("https://ntfy.sh")).toBeTruthy();
expect(validUrl("http://ntfy.sh")).toBeTruthy();
expect(validUrl("ftp://ntfy.sh")).toBeFalsy();
});
});
describe("topic validation", () => {
it("rejects disallowed topics (from config.disallowed_topics)", () => {
expect(disallowedTopic("app")).toBe(true);
expect(disallowedTopic("mytopic")).toBe(false);
expect(validTopic("app")).toBe(false);
});
it("accepts well-formed topic names and rejects malformed ones", () => {
expect(validTopic("valid_Topic-123")).toBeTruthy();
expect(validTopic("bad/slash")).toBeFalsy();
expect(validTopic("with space")).toBeFalsy();
expect(validTopic("")).toBeFalsy();
});
});
describe("base64 + auth headers", () => {
it("encodes base64 and base64url", () => {
expect(encodeBase64("hello")).toBe("aGVsbG8=");
expect(encodeBase64Url("hello")).toBe("aGVsbG8");
});
it("builds bearer and basic auth headers", () => {
expect(bearerAuth("tok")).toBe("Bearer tok");
expect(basicAuth("phil", "secret")).toBe(`Basic ${encodeBase64("phil:secret")}`);
expect(withBearerAuth({ "Content-Type": "application/json" }, "tok")).toEqual({
"Content-Type": "application/json",
Authorization: "Bearer tok",
});
});
it("picks the right scheme in maybeWithAuth", () => {
expect(maybeWithAuth({}, { username: "u", password: "p" }).Authorization).toBe(basicAuth("u", "p"));
expect(maybeWithAuth({}, { token: "tok" }).Authorization).toBe(bearerAuth("tok"));
expect(maybeWithAuth({ a: 1 }, undefined)).toEqual({ a: 1 });
});
});
describe("misc pure helpers", () => {
it("splitNoEmpty trims and drops empty entries", () => {
expect(splitNoEmpty("a, b, ,c ", ",")).toEqual(["a", "b", "c"]);
expect(splitNoEmpty("", ",")).toEqual([]);
});
it("hashCode is deterministic", () => {
expect(hashCode("")).toBe(0);
expect(hashCode("a")).toBe(97);
expect(hashCode("hello")).toBe(hashCode("hello"));
});
it("formatBytes is human readable", () => {
expect(formatBytes(0)).toBe("0 bytes");
expect(formatBytes(1024)).toBe("1 KB");
expect(formatBytes(1536)).toBe("1.5 KB");
});
it("formatNumber abbreviates round thousands", () => {
expect(formatNumber(0)).toBe(0);
expect(formatNumber(1000)).toBe("1k");
expect(formatNumber(1500)).toBe((1500).toLocaleString());
});
it("formatPrice renders cents as dollars", () => {
expect(formatPrice(100)).toBe("$1");
expect(formatPrice(150)).toBe("$1.5");
});
it("formatShortDuration picks the largest fitting unit", () => {
expect(formatShortDuration(60000, "en")).toContain("minute");
expect(formatShortDuration(3600000, "en")).toContain("hour");
});
it("getKebabCaseLangStr normalizes language tags", () => {
expect(getKebabCaseLangStr("en_US")).toBe("en-US");
expect(getKebabCaseLangStr(undefined)).toBe("en");
expect(getKebabCaseLangStr("")).toBe("en");
});
it("darkModeEnabled honors the theme preference", () => {
expect(darkModeEnabled(false, THEME.DARK)).toBe(true);
expect(darkModeEnabled(true, THEME.LIGHT)).toBe(false);
expect(darkModeEnabled(true, THEME.SYSTEM)).toBe(true);
expect(darkModeEnabled(false, THEME.SYSTEM)).toBe(false);
});
it("urlB64ToUint8Array decodes web push keys", () => {
expect(Array.from(urlB64ToUint8Array("AQID"))).toEqual([1, 2, 3]);
});
});
+12 -13
View File
@@ -41,14 +41,15 @@ import {
} from "@mui/material";
import EditIcon from "@mui/icons-material/Edit";
import { Trans, useTranslation } from "react-i18next";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlineOutlined";
import StarIcon from "@mui/icons-material/Star";
import StarBorderIcon from "@mui/icons-material/StarBorder";
import RefreshIcon from "@mui/icons-material/Refresh";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import CelebrationIcon from "@mui/icons-material/Celebration";
import CloseIcon from "@mui/icons-material/Close";
import { ContentCopy, Public } from "@mui/icons-material";
import ContentCopy from "@mui/icons-material/ContentCopy";
import Public from "@mui/icons-material/Public";
import AddIcon from "@mui/icons-material/Add";
import routes from "./routes";
import { copyToClipboard, formatBytes, formatShortDate, formatShortDateTime, formatShortDuration, openUrl } from "../app/utils";
@@ -56,7 +57,7 @@ import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus
import { Pref, PrefGroup } from "./Pref";
import db from "../app/db";
import UpgradeDialog from "./UpgradeDialog";
import { AccountContext } from "./App";
import AccountContext from "./AccountContext";
import DialogFooter from "./DialogFooter";
import { Paragraph } from "./styles";
import { EmailPrimaryElsewhereError, IncorrectPasswordError, UnauthorizedError } from "../app/errors";
@@ -190,7 +191,7 @@ const ChangePasswordDialog = (props) => {
};
return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
<DialogTitle>{t("account_basics_password_dialog_title")}</DialogTitle>
<DialogContent>
<TextField
@@ -401,13 +402,12 @@ const Emails = () => {
setSnack(t("account_basics_emails_copied_to_clipboard"));
};
// runEmailAction wraps an account API call with the shared error handling (redirect on
// unauthorized, surface a message otherwise). On success it refetches the account so the email
// list reflects the change immediately, rather than waiting for the async sync event.
// runEmailAction wraps an email account action with the shared error handling used by the
// delete/set-primary/resend handlers (redirect on unauthorized, surface a message otherwise).
// The account view refreshes via the server's sync event, so there's no explicit refetch here.
const runEmailAction = async (fn, errorMessage) => {
try {
await fn();
await accountApi.sync();
} catch (e) {
console.log(`[Account] Email action failed`, e);
if (e instanceof UnauthorizedError) {
@@ -568,7 +568,6 @@ const AddEmailDialog = (props) => {
setSending(true);
setError(""); // Clear any error from a previous attempt
await accountApi.startEmailVerification(email);
await accountApi.sync(); // Refresh so the new "(unverified)" address shows up immediately
setSent(true);
} catch (e) {
console.log(`[Account] Error starting email verification`, e);
@@ -773,7 +772,7 @@ const AddPhoneNumberDialog = (props) => {
};
return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
<DialogTitle>{t("account_basics_phone_numbers_dialog_title")}</DialogTitle>
<DialogContent>
<DialogContentText>{t("account_basics_phone_numbers_dialog_description")}</DialogContentText>
@@ -787,7 +786,7 @@ const AddPhoneNumberDialog = (props) => {
type="tel"
value={phoneNumber}
onChange={(ev) => setPhoneNumber(ev.target.value)}
inputProps={{ inputMode: "tel", pattern: "+[0-9]*" }}
slotProps={{ htmlInput: { inputMode: "tel", pattern: "+[0-9]*" } }}
variant="standard"
sx={{ flexGrow: 1 }}
/>
@@ -818,7 +817,7 @@ const AddPhoneNumberDialog = (props) => {
value={code}
onChange={(ev) => setCode(ev.target.value)}
fullWidth
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
slotProps={{ htmlInput: { inputMode: "numeric", pattern: "[0-9]*" } }}
variant="standard"
/>
)}
@@ -1397,7 +1396,7 @@ const DeleteAccountDialog = (props) => {
await accountApi.delete(password);
await db().delete();
console.debug(`[Account] Account deleted`);
await session.resetAndRedirect(routes.app);
await session.resetAndRedirect(routes.app, { fade: true });
} catch (e) {
console.log(`[Account] Error deleting account`, e);
if (e instanceof IncorrectPasswordError) {
+5
View File
@@ -0,0 +1,5 @@
import { createContext } from "react";
const AccountContext = createContext(null);
export default AccountContext;
+45 -4
View File
@@ -6,15 +6,19 @@ import { useLocation, useNavigate } from "react-router-dom";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import NotificationsIcon from "@mui/icons-material/Notifications";
import NotificationsOffIcon from "@mui/icons-material/NotificationsOff";
import RefreshIcon from "@mui/icons-material/Refresh";
import { useTranslation } from "react-i18next";
import AccountCircleIcon from "@mui/icons-material/AccountCircle";
import { Logout, Person, Settings } from "@mui/icons-material";
import Logout from "@mui/icons-material/Logout";
import Person from "@mui/icons-material/Person";
import Settings from "@mui/icons-material/Settings";
import session from "../app/Session";
import logo from "../img/ntfy.svg";
import subscriptionManager from "../app/SubscriptionManager";
import routes from "./routes";
import db from "../app/db";
import { topicDisplayName } from "../app/utils";
import { fadeNavigate } from "../app/transition";
import Navigation from "./Navigation";
import accountApi from "../app/AccountApi";
import PopupMenu from "./PopupMenu";
@@ -88,6 +92,7 @@ const ActionBar = (props) => {
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
{title}
</Typography>
{isLaunchedPWA && <ReloadIcon />}
{props.selected && <SettingsIcons subscription={props.selected} onUnsubscribe={props.onUnsubscribe} />}
<ProfileIcon />
</Toolbar>
@@ -124,6 +129,31 @@ const SettingsIcons = (props) => {
);
};
// ReloadIcon hard-refreshes the app. A plain reload would just serve the precached PWA shell,
// so we first purge the service worker caches to force fresh assets from the network.
const ReloadIcon = () => {
const { t } = useTranslation();
const handleReload = async () => {
try {
if ("caches" in window) {
const keys = await caches.keys();
await Promise.all(keys.map((key) => caches.delete(key)));
}
} catch (e) {
console.warn("[ActionBar] Error clearing caches during reload", e);
} finally {
window.location.reload();
}
};
return (
<IconButton color="inherit" size="large" edge="end" onClick={handleReload} aria-label={t("action_bar_reload")}>
<RefreshIcon />
</IconButton>
);
};
const ProfileIcon = () => {
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState(null);
@@ -143,7 +173,7 @@ const ProfileIcon = () => {
await accountApi.logout();
await db().delete();
} finally {
await session.resetAndRedirect(routes.app);
await session.resetAndRedirect(routes.app, { fade: true });
}
};
@@ -155,12 +185,23 @@ const ProfileIcon = () => {
</IconButton>
)}
{!session.exists() && config.enable_login && (
<Button color="inherit" variant="text" onClick={() => navigate(routes.login)} sx={{ m: 1 }} aria-label={t("action_bar_sign_in")}>
<Button
color="inherit"
variant="text"
onClick={() => fadeNavigate(navigate, routes.login)}
sx={{ m: 1 }}
aria-label={t("action_bar_sign_in")}
>
{t("action_bar_sign_in")}
</Button>
)}
{!session.exists() && config.enable_signup && (
<Button color="inherit" variant="outlined" onClick={() => navigate(routes.signup)} aria-label={t("action_bar_sign_up")}>
<Button
color="inherit"
variant="outlined"
onClick={() => fadeNavigate(navigate, routes.signup)}
aria-label={t("action_bar_sign_up")}
>
{t("action_bar_sign_up")}
</Button>
)}
+66 -31
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { createContext, Suspense, useContext, useEffect, useState, useMemo } from "react";
import { Suspense, useContext, useEffect, useState, useMemo } from "react";
import { Box, Toolbar, CssBaseline, Backdrop, CircularProgress, useMediaQuery, ThemeProvider, createTheme } from "@mui/material";
import { useLiveQuery } from "dexie-react-hooks";
import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom";
@@ -27,11 +27,12 @@ import initI18n from "../app/i18n"; // Translations!
import prefs from "../app/Prefs";
import RTLCacheProvider from "./RTLCacheProvider";
import session from "../app/Session";
import AccountContext from "./AccountContext";
import { PrefCacheProvider } from "./PrefCache";
import hideSplash from "../app/splash";
initI18n();
export const AccountContext = createContext(null);
const App = () => {
const { i18n } = useTranslation();
const languageDir = i18n.dir();
@@ -39,16 +40,29 @@ const App = () => {
const accountMemo = useMemo(() => ({ account, setAccount }), [account, setAccount]);
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
const themePreference = useLiveQuery(() => prefs.theme());
const theme = React.useMemo(
() => createTheme({ ...(darkModeEnabled(prefersDarkMode, themePreference) ? darkTheme : lightTheme), direction: languageDir }),
[prefersDarkMode, themePreference, languageDir]
);
const isDark = darkModeEnabled(prefersDarkMode, themePreference);
const theme = React.useMemo(() => createTheme({ ...(isDark ? darkTheme : lightTheme), direction: languageDir }), [isDark, languageDir]);
useEffect(() => {
document.documentElement.setAttribute("lang", getKebabCaseLangStr(i18n.language));
document.dir = languageDir;
}, [i18n.language, languageDir]);
// Keep the <html> background in sync with the theme once loaded. The splash script sets the
// initial class; don't override it while the preference is still loading.
useEffect(() => {
if (themePreference === undefined) {
return;
}
document.documentElement.classList.toggle("dark", isDark);
}, [isDark, themePreference]);
// Safety net: hide the splash even if no route does (e.g. an unmatched path).
useEffect(() => {
const timer = setTimeout(() => hideSplash(), 3000);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
if (!session.exists() && config.require_login && window.location.pathname !== routes.login) {
window.location.href = routes.login;
@@ -64,11 +78,13 @@ const App = () => {
<CssBaseline />
<ErrorBoundary>
<Routes>
<Route path={routes.login} element={<Login />} />
<Route path={routes.signup} element={<Signup />} />
<Route path={routes.passwordResetRequest} element={<PasswordResetRequest />} />
<Route path={routes.passwordReset} element={<PasswordReset />} />
<Route path={routes.emailVerify} element={<EmailVerify />} />
<Route element={<AuthLayout />}>
<Route path={routes.login} element={<Login />} />
<Route path={routes.signup} element={<Signup />} />
<Route path={routes.passwordResetRequest} element={<PasswordResetRequest />} />
<Route path={routes.passwordReset} element={<PasswordReset />} />
<Route path={routes.emailVerify} element={<EmailVerify />} />
</Route>
<Route element={<Layout />}>
<Route path={routes.app} element={<AllSubscriptions />} />
<Route path={routes.account} element={<Account />} />
@@ -92,6 +108,12 @@ const updateTitle = (newNotificationsCount) => {
updateFavicon(newNotificationsCount);
};
// Auth pages render synchronously, so the splash can be removed on mount.
const AuthLayout = () => {
useEffect(() => hideSplash(), []);
return <Outlet />;
};
const Layout = () => {
const params = useParams();
const { account, setAccount } = useContext(AccountContext);
@@ -99,6 +121,9 @@ const Layout = () => {
const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
const users = useLiveQuery(() => userManager.all());
const subscriptions = useLiveQuery(() => subscriptionManager.all());
// Preloaded here so the All view (and single topics, via filter) have data on mount -- no empty
// frame when switching.
const allNotifications = useLiveQuery(() => subscriptionManager.getAllNotifications());
const webPushTopics = useWebPushTopics();
const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal);
const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;
@@ -113,27 +138,37 @@ const Layout = () => {
useBackgroundProcesses();
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
// Hide the splash only once subscriptions have loaded, so the nav/list don't pop in empty-then-filled.
useEffect(() => {
if (subscriptions !== undefined) {
hideSplash();
}
}, [subscriptions]);
return (
<Box sx={{ display: "flex" }}>
<ActionBar selected={selected} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} />
<Navigation
subscriptions={subscriptionsWithoutInternal}
selectedSubscription={selected}
mobileDrawerOpen={mobileDrawerOpen}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)}
/>
<Main>
<Toolbar />
<Outlet
context={{
subscriptions: subscriptionsWithoutInternal,
selected,
}}
<PrefCacheProvider>
<Box sx={{ display: "flex" }}>
<ActionBar selected={selected} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} />
<Navigation
subscriptions={subscriptionsWithoutInternal}
selectedSubscription={selected}
mobileDrawerOpen={mobileDrawerOpen}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)}
/>
</Main>
<Messaging selected={selected} dialogOpenMode={sendDialogOpenMode} onDialogOpenModeChange={setSendDialogOpenMode} />
</Box>
<Main>
<Toolbar />
<Outlet
context={{
subscriptions: subscriptionsWithoutInternal,
selected,
allNotifications,
}}
/>
</Main>
<Messaging selected={selected} dialogOpenMode={sendDialogOpenMode} onDialogOpenModeChange={setSendDialogOpenMode} />
</Box>
</PrefCacheProvider>
);
};
+28 -6
View File
@@ -1,6 +1,9 @@
import * as React from "react";
import { Avatar, Box, styled } from "@mui/material";
import { useNavigate } from "react-router-dom";
import logo from "../img/ntfy-filled.svg";
import routes from "./routes";
import { fadeNavigate } from "../app/transition";
const AvatarBoxContainer = styled(Box)`
display: flex;
@@ -13,11 +16,30 @@ const AvatarBoxContainer = styled(Box)`
max-width: min(400px, 90dvw);
margin: auto;
`;
const AvatarBox = (props) => (
<AvatarBoxContainer>
<Avatar sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }} src={logo} variant="rounded" />
{props.children}
</AvatarBoxContainer>
);
const AvatarBox = (props) => {
const navigate = useNavigate();
const avatar = <Avatar sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }} src={logo} variant="rounded" />;
// Fade back to the app instead of a hard cut. Let modifier-clicks (open in new tab, etc.) through.
const handleLogoClick = (ev) => {
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) {
return;
}
ev.preventDefault();
fadeNavigate(navigate, routes.app);
};
return (
<AvatarBoxContainer>
{/* The logo links back to the app, unless login is forced (no app to go back to without signing in) */}
{config.require_login ? (
avatar
) : (
<Box component="a" href={routes.app} onClick={handleLogoClick} sx={{ cursor: "pointer", lineHeight: 0 }}>
{avatar}
</Box>
)}
{props.children}
</AvatarBoxContainer>
);
};
export default AvatarBox;
+2 -2
View File
@@ -1,8 +1,8 @@
import * as React from "react";
import { useEffect, useRef, useState } from "react";
import { Typography, Button, Box, CircularProgress } from "@mui/material";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutlineOutlined";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlineOutlined";
import { useParams, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import accountApi from "../app/AccountApi";
+15 -13
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import { useRef, useState } from "react";
import { Typography, Box, TextField, ClickAwayListener, Fade, InputAdornment, styled, IconButton, Popper } from "@mui/material";
import { Close } from "@mui/icons-material";
import Close from "@mui/icons-material/Close";
import { useTranslation } from "react-i18next";
import { splitNoEmpty } from "../app/utils";
import { rawEmojis } from "../app/emojis";
@@ -71,18 +71,20 @@ const EmojiPicker = (props) => {
variant="standard"
fullWidth
sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }}
inputProps={{
role: "searchbox",
"aria-label": t("emoji_picker_search_placeholder"),
}}
InputProps={{
endAdornment: (
<InputAdornment position="end" sx={{ display: search ? "" : "none" }}>
<IconButton size="small" onClick={handleSearchClear} edge="end" aria-label={t("emoji_picker_search_clear")}>
<Close />
</IconButton>
</InputAdornment>
),
slotProps={{
htmlInput: {
role: "searchbox",
"aria-label": t("emoji_picker_search_placeholder"),
},
input: {
endAdornment: (
<InputAdornment position="end" sx={{ display: search ? "" : "none" }}>
<IconButton size="small" onClick={handleSearchClear} edge="end" aria-label={t("emoji_picker_search_clear")}>
<Close />
</IconButton>
</InputAdornment>
),
},
}}
/>
<Box
+20 -16
View File
@@ -4,12 +4,14 @@ import { Typography, TextField, Button, Box, IconButton, InputAdornment } from "
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import { NavLink } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Visibility, VisibilityOff } from "@mui/icons-material";
import Visibility from "@mui/icons-material/Visibility";
import VisibilityOff from "@mui/icons-material/VisibilityOff";
import accountApi from "../app/AccountApi";
import AvatarBox from "./AvatarBox";
import session from "../app/Session";
import routes from "./routes";
import { UnauthorizedError } from "../app/errors";
import { fadeReload } from "../app/transition";
const Login = () => {
const { t } = useTranslation();
@@ -25,7 +27,7 @@ const Login = () => {
const token = await accountApi.login(user);
console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`);
await session.store(user.username, token);
window.location.href = routes.app;
fadeReload(routes.app);
} catch (e) {
console.log(`[Login] User auth for user ${user.username} failed`, e);
if (e instanceof UnauthorizedError) {
@@ -66,21 +68,23 @@ const Login = () => {
type={showPassword ? "text" : "password"}
id="password"
value={password}
onChange={(ev) => setPassword(ev.target.value.trim())}
onChange={(ev) => setPassword(ev.target.value)}
autoComplete="current-password"
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={t("signup_form_toggle_password_visibility")}
onClick={() => setShowPassword(!showPassword)}
onMouseDown={(ev) => ev.preventDefault()}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={t("signup_form_toggle_password_visibility")}
onClick={() => setShowPassword(!showPassword)}
onMouseDown={(ev) => ev.preventDefault()}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
},
}}
/>
<Button type="submit" fullWidth variant="contained" disabled={username === "" || password === ""} sx={{ mt: 2, mb: 2 }}>
+62
View File
@@ -0,0 +1,62 @@
import * as React from "react";
import { useEffect } from "react";
import { useRemark } from "react-remark";
import styled from "@emotion/styled";
const MarkdownContainer = styled("div")`
line-height: 1;
h1,
h2,
h3,
h4,
h5,
h6,
p,
pre,
ul,
ol,
blockquote {
margin: 0;
}
p {
line-height: 1.5;
}
blockquote,
pre {
border-radius: 3px;
background: ${(props) => (props.theme.palette.mode === "light" ? "#f5f5f5" : "#333")};
}
pre {
overflow-x: scroll;
padding: 0.9rem;
}
ul,
ol,
blockquote {
padding-inline: 1rem;
}
img {
max-width: 100%;
}
`;
// Renders text/markdown notification bodies. react-remark (and its remark/unified
// dependency stack) is heavy, so this component lives in its own module and is loaded
// lazily by Notifications.jsx -- it only ships when a markdown message is actually shown.
const MarkdownContent = ({ content }) => {
const [reactContent, setMarkdownSource] = useRemark();
useEffect(() => {
setMarkdownSource(content);
}, [content]);
return <MarkdownContainer>{reactContent}</MarkdownContainer>;
};
export default MarkdownContent;
+6 -3
View File
@@ -22,12 +22,15 @@ import {
} from "@mui/material";
import * as React from "react";
import { useContext, useState } from "react";
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutlineOutlined";
import Person from "@mui/icons-material/Person";
import SettingsIcon from "@mui/icons-material/Settings";
import AddIcon from "@mui/icons-material/Add";
import { useLocation, useNavigate } from "react-router-dom";
import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material";
import ChatBubble from "@mui/icons-material/ChatBubble";
import MoreVert from "@mui/icons-material/MoreVert";
import NotificationsOffOutlined from "@mui/icons-material/NotificationsOffOutlined";
import Send from "@mui/icons-material/Send";
import ArticleIcon from "@mui/icons-material/Article";
import { Trans, useTranslation } from "react-i18next";
import CelebrationIcon from "@mui/icons-material/Celebration";
@@ -41,7 +44,7 @@ import config from "../app/config";
import session from "../app/Session";
import accountApi, { Permission, Role } from "../app/AccountApi";
import UpgradeDialog from "./UpgradeDialog";
import { AccountContext } from "./App";
import AccountContext from "./AccountContext";
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
import { SubscriptionPopup } from "./SubscriptionPopup";
import { useNotificationPermissionListener, useVersionChangeListener } from "./hooks";
+38 -76
View File
@@ -17,15 +17,12 @@ import {
Button,
} from "@mui/material";
import * as React from "react";
import { useEffect, useState } from "react";
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
import CheckIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close";
import { useLiveQuery } from "dexie-react-hooks";
import InfiniteScroll from "react-infinite-scroll-component";
import { Trans, useTranslation } from "react-i18next";
import { useOutletContext } from "react-router-dom";
import { useRemark } from "react-remark";
import styled from "@emotion/styled";
import {
copyToClipboard,
formatBytes,
@@ -57,28 +54,25 @@ const priorityFiles = {
};
export const AllSubscriptions = () => {
const { subscriptions } = useOutletContext();
if (!subscriptions) {
return <Loading />;
// allNotifications is preloaded in Layout, so this view has its data on mount (no empty frame on switch).
const { subscriptions, allNotifications } = useOutletContext();
if (!subscriptions || allNotifications === null || allNotifications === undefined) {
return <DeferredLoading />;
}
return <AllSubscriptionsList subscriptions={subscriptions} />;
return <AllSubscriptionsList subscriptions={subscriptions} notifications={allNotifications} />;
};
export const SingleSubscription = () => {
const { subscriptions, selected } = useOutletContext();
const { subscriptions, selected, allNotifications } = useOutletContext();
useAutoSubscribe(subscriptions, selected);
if (!selected) {
return <Loading />;
if (!selected || allNotifications === null || allNotifications === undefined) {
return <DeferredLoading />;
}
return <SingleSubscriptionList subscription={selected} />;
return <SingleSubscriptionList subscription={selected} allNotifications={allNotifications} />;
};
const AllSubscriptionsList = (props) => {
const { subscriptions } = props;
const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []);
if (notifications === null || notifications === undefined) {
return <Loading />;
}
const { subscriptions, notifications } = props;
if (subscriptions.length === 0) {
return <NoSubscriptions />;
}
@@ -89,11 +83,13 @@ const AllSubscriptionsList = (props) => {
};
const SingleSubscriptionList = (props) => {
const { subscription } = props;
const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]);
if (notifications === null || notifications === undefined) {
return <Loading />;
}
const { subscription, allNotifications } = props;
// Filter the preloaded allNotifications instead of a per-topic query (getNotifications(id) ==
// getAllNotifications() filtered by id), so topic switches are instant.
const notifications = useMemo(
() => allNotifications.filter((notification) => notification.subscriptionId === subscription.id),
[allNotifications, subscription.id]
);
if (notifications.length === 0) {
return <NoNotifications subscription={subscription} />;
}
@@ -172,64 +168,19 @@ const autolink = (s) => {
return <>{parts}</>;
};
const MarkdownContainer = styled("div")`
line-height: 1;
h1,
h2,
h3,
h4,
h5,
h6,
p,
pre,
ul,
ol,
blockquote {
margin: 0;
}
p {
line-height: 1.5;
}
blockquote,
pre {
border-radius: 3px;
background: ${(props) => (props.theme.palette.mode === "light" ? "#f5f5f5" : "#333")};
}
pre {
overflow-x: scroll;
padding: 0.9rem;
}
ul,
ol,
blockquote {
padding-inline: 1rem;
}
img {
max-width: 100%;
}
`;
const MarkdownContent = ({ content }) => {
const [reactContent, setMarkdownSource] = useRemark();
useEffect(() => {
setMarkdownSource(content);
}, [content]);
return <MarkdownContainer>{reactContent}</MarkdownContainer>;
};
// Loaded lazily so the heavy react-remark/unified markdown stack is only fetched when a
// text/markdown notification is actually rendered (see MarkdownContent.jsx).
const MarkdownContent = lazy(() => import("./MarkdownContent"));
const NotificationBody = ({ notification }) => {
const displayAsMarkdown = notification.content_type === "text/markdown";
const formatted = formatMessage(notification);
if (displayAsMarkdown) {
return <MarkdownContent content={formatted} />;
return (
<Suspense fallback={null}>
<MarkdownContent content={formatted} />
</Suspense>
);
}
return autolink(formatted);
};
@@ -462,7 +413,7 @@ const Image = (props) => {
cursor: "pointer",
}}
/>
<Modal open={open} onClose={() => setOpen(false)} BackdropComponent={LightboxBackdrop}>
<Modal open={open} onClose={() => setOpen(false)} slots={{ backdrop: LightboxBackdrop }}>
<Fade in={open}>
<Box
component="img"
@@ -716,3 +667,14 @@ const Loading = () => {
</VerticallyCenteredContainer>
);
};
// Render nothing until a load takes at least `delayMs`, so the centered spinner only shows on
// genuinely slow loads -- normal sub-frame IndexedDB reads don't flash it on every remount.
const DeferredLoading = ({ delayMs = 250 }) => {
const [show, setShow] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setShow(true), delayMs);
return () => clearTimeout(timer);
}, [delayMs]);
return show ? <Loading /> : null;
};
+3 -3
View File
@@ -2,7 +2,7 @@ import * as React from "react";
import { useEffect, useRef, useState } from "react";
import { Typography, TextField, Button, Box } from "@mui/material";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutlineOutlined";
import { useParams, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import accountApi from "../app/AccountApi";
@@ -71,7 +71,7 @@ const PasswordReset = () => {
type="password"
id="password"
value={password}
onChange={(ev) => setPassword(ev.target.value.trim())}
onChange={(ev) => setPassword(ev.target.value)}
autoComplete="new-password"
autoFocus
/>
@@ -84,7 +84,7 @@ const PasswordReset = () => {
type="password"
id="confirm"
value={confirm}
onChange={(ev) => setConfirm(ev.target.value.trim())}
onChange={(ev) => setConfirm(ev.target.value)}
autoComplete="new-password"
/>
<Button
+1 -1
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import { useState } from "react";
import { TextField, Button, Box, Typography } from "@mui/material";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutlineOutlined";
import { NavLink } from "react-router-dom";
import { useTranslation } from "react-i18next";
import accountApi from "../app/AccountApi";
+26 -24
View File
@@ -10,30 +10,32 @@ const PopupMenu = (props) => {
open={props.open}
onClose={props.onClose}
onClick={props.onClose}
TransitionComponent={Fade}
PaperProps={{
elevation: 0,
sx: {
overflow: "visible",
filter: "drop-shadow(0px 2px 8px rgba(0,0,0,0.32))",
mt: 1.5,
"& .MuiAvatar-root": {
width: 32,
height: 32,
ml: -0.5,
mr: 1,
},
"&:before": {
content: '""',
display: "block",
position: "absolute",
top: 0,
width: 10,
height: 10,
bgcolor: "background.paper",
transform: "translateY(-50%) rotate(45deg)",
zIndex: 0,
...arrow,
slots={{ transition: Fade }}
slotProps={{
paper: {
elevation: 0,
sx: {
overflow: "visible",
filter: "drop-shadow(0px 2px 8px rgba(0,0,0,0.32))",
mt: 1.5,
"& .MuiAvatar-root": {
width: 32,
height: 32,
ml: -0.5,
mr: 1,
},
"&:before": {
content: '""',
display: "block",
position: "absolute",
top: 0,
width: 10,
height: 10,
bgcolor: "background.paper",
transform: "translateY(-50%) rotate(45deg)",
zIndex: 0,
...arrow,
},
},
},
}}
+54
View File
@@ -0,0 +1,54 @@
import * as React from "react";
import { createContext, useContext, useEffect } from "react";
import { useLiveQuery } from "dexie-react-hooks";
import prefs, { PREF_DEFAULTS } from "../app/Prefs";
// A CACHE of the user's prefs -- not the source of truth (that's the `prefs` IndexedDB table, via
// Prefs.js). Preloaded once in Layout so Settings renders instantly, and written through to
// localStorage on every change so (a) Settings is instant even on a cold load and (b) the inline
// splash script in index.html can read the theme synchronously before the bundle loads. The
// "prefcache" key is duplicated in that script -- keep them in sync.
const PREFCACHE_LOCALSTORAGE_KEY = "prefcache";
const PrefCacheContext = createContext(undefined);
// Synchronous fallback before the live query resolves. Merged over PREF_DEFAULTS so a newly-added
// pref still has a value.
const readPersistedCache = () => {
try {
const raw = localStorage.getItem(PREFCACHE_LOCALSTORAGE_KEY);
if (raw) {
return { ...PREF_DEFAULTS, ...JSON.parse(raw) };
}
} catch (e) {
// malformed or unavailable storage -- fall back to defaults
}
return PREF_DEFAULTS;
};
export const PrefCacheProvider = ({ children }) => {
const cache = useLiveQuery(async () => ({
sound: await prefs.sound(),
minPriority: await prefs.minPriority(),
deleteAfter: await prefs.deleteAfter(),
theme: await prefs.theme(),
webPushEnabled: await prefs.webPushEnabled(),
}));
// Write through to localStorage on change (prefs change rarely).
useEffect(() => {
if (cache !== undefined) {
try {
localStorage.setItem(PREFCACHE_LOCALSTORAGE_KEY, JSON.stringify(cache));
} catch (e) {
// localStorage may be unavailable (private mode) -- the cache just isn't persisted
}
}
}, [cache]);
return <PrefCacheContext.Provider value={cache}>{children}</PrefCacheContext.Provider>;
};
// Live context once resolved; else the synchronous localStorage snapshot (instant on cold load);
// else defaults.
export const usePrefCache = () => useContext(PrefCacheContext) ?? readPersistedCache();
+9 -19
View File
@@ -33,7 +33,7 @@ import CloseIcon from "@mui/icons-material/Close";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import { useLiveQuery } from "dexie-react-hooks";
import { useTranslation } from "react-i18next";
import { Info } from "@mui/icons-material";
import Info from "@mui/icons-material/Info";
import { useOutletContext } from "react-router-dom";
import userManager from "../app/UserManager";
import { playSound, shortUrl, shuffle, sounds, validUrl } from "../app/utils";
@@ -41,7 +41,7 @@ import session from "../app/Session";
import routes from "./routes";
import accountApi, { Permission, Role } from "../app/AccountApi";
import { Pref, PrefGroup } from "./Pref";
import { AccountContext } from "./App";
import AccountContext from "./AccountContext";
import { Paragraph } from "./styles";
import prefs, { THEME } from "../app/Prefs";
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
@@ -50,6 +50,7 @@ import { UnauthorizedError } from "../app/errors";
import { subscribeTopic } from "./SubscribeDialog";
import notifier from "../app/Notifier";
import { useIsLaunchedPWA, useNotificationPermissionListener } from "./hooks";
import { usePrefCache } from "./PrefCache";
const maybeUpdateAccountSettings = async (payload) => {
if (!session.exists()) {
@@ -99,7 +100,7 @@ const Notifications = () => {
const Sound = () => {
const { t } = useTranslation();
const labelId = "prefSound";
const sound = useLiveQuery(async () => prefs.sound());
const { sound } = usePrefCache();
const handleChange = async (ev) => {
await prefs.setSound(ev.target.value);
await maybeUpdateAccountSettings({
@@ -108,9 +109,6 @@ const Sound = () => {
},
});
};
if (!sound) {
return null; // While loading
}
let description;
if (sound === "none") {
description = t("prefs_notifications_sound_description_none");
@@ -143,7 +141,7 @@ const Sound = () => {
const MinPriority = () => {
const { t } = useTranslation();
const labelId = "prefMinPriority";
const minPriority = useLiveQuery(async () => prefs.minPriority());
const { minPriority } = usePrefCache();
const handleChange = async (ev) => {
await prefs.setMinPriority(ev.target.value);
await maybeUpdateAccountSettings({
@@ -152,9 +150,6 @@ const MinPriority = () => {
},
});
};
if (!minPriority) {
return null; // While loading
}
const priorities = {
1: t("priority_min"),
2: t("priority_low"),
@@ -191,7 +186,7 @@ const MinPriority = () => {
const DeleteAfter = () => {
const { t } = useTranslation();
const labelId = "prefDeleteAfter";
const deleteAfter = useLiveQuery(async () => prefs.deleteAfter());
const { deleteAfter } = usePrefCache();
const handleChange = async (ev) => {
await prefs.setDeleteAfter(ev.target.value);
await maybeUpdateAccountSettings({
@@ -201,11 +196,6 @@ const DeleteAfter = () => {
});
};
if (deleteAfter === null || deleteAfter === undefined) {
// !deleteAfter will not work with "0"
return null; // While loading
}
const description = (() => {
switch (deleteAfter) {
case 0:
@@ -241,7 +231,7 @@ const DeleteAfter = () => {
const Theme = () => {
const { t } = useTranslation();
const labelId = "prefTheme";
const theme = useLiveQuery(async () => prefs.theme());
const { theme } = usePrefCache();
const handleChange = async (ev) => {
await prefs.setTheme(ev.target.value);
};
@@ -249,7 +239,7 @@ const Theme = () => {
return (
<Pref labelId={labelId} title={t("prefs_appearance_theme_title")}>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select value={theme ?? THEME.SYSTEM} onChange={handleChange} aria-labelledby={labelId}>
<Select value={theme} onChange={handleChange} aria-labelledby={labelId}>
<MenuItem value={THEME.SYSTEM}>{t("prefs_appearance_theme_system")}</MenuItem>
<MenuItem value={THEME.DARK}>{t("prefs_appearance_theme_dark")}</MenuItem>
<MenuItem value={THEME.LIGHT}>{t("prefs_appearance_theme_light")}</MenuItem>
@@ -262,7 +252,7 @@ const Theme = () => {
const WebPushEnabled = () => {
const { t } = useTranslation();
const labelId = "prefWebPushEnabled";
const enabled = useLiveQuery(async () => prefs.webPushEnabled());
const { webPushEnabled: enabled } = usePrefCache();
const handleChange = async (ev) => {
await prefs.setWebPushEnabled(ev.target.value);
};
+67 -36
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { useContext, useEffect, useRef, useState } from "react";
import { Suspense, lazy, useContext, useEffect, useRef, useState } from "react";
import {
Checkbox,
Chip,
@@ -22,7 +22,7 @@ import {
useTheme,
} from "@mui/material";
import InsertEmoticonIcon from "@mui/icons-material/InsertEmoticon";
import { Close } from "@mui/icons-material";
import Close from "@mui/icons-material/Close";
import { Trans, useTranslation } from "react-i18next";
import priority1 from "../img/priority-1.svg";
import priority2 from "../img/priority-2.svg";
@@ -35,12 +35,15 @@ import AttachmentIcon from "./AttachmentIcon";
import DialogFooter from "./DialogFooter";
import api from "../app/Api";
import userManager from "../app/UserManager";
import EmojiPicker from "./EmojiPicker";
import session from "../app/Session";
import routes from "./routes";
import accountApi from "../app/AccountApi";
import { UnauthorizedError } from "../app/errors";
import { AccountContext } from "./App";
import AccountContext from "./AccountContext";
// Loaded lazily so the full emoji dataset (~300 KB) is only fetched when the publish
// dialog is opened, not in the initial app bundle (see EmojiPicker.jsx).
const EmojiPicker = lazy(() => import("./EmojiPicker"));
const PublishDialog = (props) => {
const theme = useTheme();
@@ -289,7 +292,7 @@ const PublishDialog = (props) => {
return (
<>
{dropZone && <DropArea onDrop={handleAttachFileDrop} onDragLeave={handleAttachFileDragLeave} />}
<Dialog maxWidth="md" open={open} onClose={props.onCancel} fullScreen={fullScreen}>
<Dialog maxWidth="md" open={open} onClose={props.onClose} fullScreen={fullScreen}>
<DialogTitle>
{baseUrl && topic
? t("publish_dialog_title_topic", {
@@ -320,8 +323,10 @@ const PublishDialog = (props) => {
type="url"
variant="standard"
sx={{ flexGrow: 1, marginRight: 1 }}
inputProps={{
"aria-label": t("publish_dialog_base_url_label"),
slotProps={{
htmlInput: {
"aria-label": t("publish_dialog_base_url_label"),
},
}}
/>
<TextField
@@ -335,8 +340,10 @@ const PublishDialog = (props) => {
variant="standard"
autoFocus={!messageFocused}
sx={{ flexGrow: 1 }}
inputProps={{
"aria-label": t("publish_dialog_topic_label"),
slotProps={{
htmlInput: {
"aria-label": t("publish_dialog_topic_label"),
},
}}
/>
</ClosableRow>
@@ -351,8 +358,10 @@ const PublishDialog = (props) => {
type="text"
fullWidth
variant="standard"
inputProps={{
"aria-label": t("publish_dialog_title_label"),
slotProps={{
htmlInput: {
"aria-label": t("publish_dialog_title_label"),
},
}}
/>
<TextField
@@ -368,8 +377,10 @@ const PublishDialog = (props) => {
autoFocus={messageFocused}
fullWidth
multiline
inputProps={{
"aria-label": t("publish_dialog_message_label"),
slotProps={{
htmlInput: {
"aria-label": t("publish_dialog_message_label"),
},
}}
onPaste={handlePaste}
/>
@@ -381,14 +392,18 @@ const PublishDialog = (props) => {
size="small"
checked={markdownEnabled}
onChange={(ev) => setMarkdownEnabled(ev.target.checked)}
inputProps={{
"aria-label": t("publish_dialog_checkbox_markdown"),
slotProps={{
input: {
"aria-label": t("publish_dialog_checkbox_markdown"),
},
}}
/>
}
/>
<div style={{ display: "flex" }}>
<EmojiPicker anchorEl={emojiPickerAnchorEl} onEmojiPick={handleEmojiPick} onClose={handleEmojiClose} />
<Suspense fallback={null}>
<EmojiPicker anchorEl={emojiPickerAnchorEl} onEmojiPick={handleEmojiPick} onClose={handleEmojiClose} />
</Suspense>
<DialogIconButton disabled={disabled} onClick={handleEmojiClick} aria-label={t("publish_dialog_emoji_picker_show")}>
<InsertEmoticonIcon />
</DialogIconButton>
@@ -402,8 +417,10 @@ const PublishDialog = (props) => {
type="text"
variant="standard"
sx={{ flexGrow: 1, marginRight: 1 }}
inputProps={{
"aria-label": t("publish_dialog_tags_label"),
slotProps={{
htmlInput: {
"aria-label": t("publish_dialog_tags_label"),
},
}}
/>
<FormControl variant="standard" margin="dense" sx={{ minWidth: 170, maxWidth: 300, flexGrow: 1 }}>
@@ -460,8 +477,10 @@ const PublishDialog = (props) => {
type="url"
fullWidth
variant="standard"
inputProps={{
"aria-label": t("publish_dialog_click_label"),
slotProps={{
htmlInput: {
"aria-label": t("publish_dialog_click_label"),
},
}}
/>
</ClosableRow>
@@ -485,8 +504,10 @@ const PublishDialog = (props) => {
type="email"
variant="standard"
fullWidth
inputProps={{
"aria-label": t("publish_dialog_email_label"),
slotProps={{
htmlInput: {
"aria-label": t("publish_dialog_email_label"),
},
}}
/>
</ClosableRow>
@@ -556,8 +577,10 @@ const PublishDialog = (props) => {
type="url"
variant="standard"
sx={{ flexGrow: 5, marginRight: 1 }}
inputProps={{
"aria-label": t("publish_dialog_attach_label"),
slotProps={{
htmlInput: {
"aria-label": t("publish_dialog_attach_label"),
},
}}
/>
<TextField
@@ -573,8 +596,10 @@ const PublishDialog = (props) => {
type="text"
variant="standard"
sx={{ flexGrow: 1 }}
inputProps={{
"aria-label": t("publish_dialog_filename_label"),
slotProps={{
htmlInput: {
"aria-label": t("publish_dialog_filename_label"),
},
}}
/>
</ClosableRow>
@@ -617,8 +642,10 @@ const PublishDialog = (props) => {
type="text"
variant="standard"
fullWidth
inputProps={{
"aria-label": t("publish_dialog_delay_label"),
slotProps={{
htmlInput: {
"aria-label": t("publish_dialog_delay_label"),
},
}}
/>
</ClosableRow>
@@ -735,8 +762,10 @@ const PublishDialog = (props) => {
size="small"
checked={publishAnother}
onChange={(ev) => setPublishAnother(ev.target.checked)}
inputProps={{
"aria-label": t("publish_dialog_checkbox_publish_another"),
slotProps={{
input: {
"aria-label": t("publish_dialog_checkbox_publish_another"),
},
}}
/>
}
@@ -867,12 +896,14 @@ const ExpandingTextField = (props) => {
type="text"
variant="standard"
sx={{ width: `${textWidth}px`, borderBottom: "none" }}
InputProps={{
style: { fontSize: theme.typography[props.variant].fontSize },
}}
inputProps={{
style: { paddingBottom: 0, paddingTop: 0 },
"aria-label": props.placeholder,
slotProps={{
input: {
style: { fontSize: theme.typography[props.variant].fontSize },
},
htmlInput: {
style: { paddingBottom: 0, paddingTop: 0 },
"aria-label": props.placeholder,
},
}}
disabled={props.disabled}
/>
+2 -1
View File
@@ -17,7 +17,8 @@ import {
useTheme,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { Check, DeleteForever } from "@mui/icons-material";
import Check from "@mui/icons-material/Check";
import DeleteForever from "@mui/icons-material/DeleteForever";
import { validTopic } from "../app/utils";
import DialogFooter from "./DialogFooter";
import session from "../app/Session";
+2 -1
View File
@@ -1,5 +1,6 @@
import * as React from "react";
import { Lock, Public } from "@mui/icons-material";
import Lock from "@mui/icons-material/Lock";
import Public from "@mui/icons-material/Public";
import { Box } from "@mui/material";
export const PermissionReadWrite = React.forwardRef((props, ref) => <PermissionInternal icon={Public} ref={ref} {...props} />);
+36 -30
View File
@@ -4,12 +4,14 @@ import { TextField, Button, Box, Typography, InputAdornment, IconButton } from "
import { NavLink } from "react-router-dom";
import { useTranslation } from "react-i18next";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import { Visibility, VisibilityOff } from "@mui/icons-material";
import Visibility from "@mui/icons-material/Visibility";
import VisibilityOff from "@mui/icons-material/VisibilityOff";
import accountApi from "../app/AccountApi";
import AvatarBox from "./AvatarBox";
import session from "../app/Session";
import routes from "./routes";
import { AccountActionLimitReachedError, UserExistsError } from "../app/errors";
import { fadeReload } from "../app/transition";
const Signup = () => {
const { t } = useTranslation();
@@ -29,7 +31,7 @@ const Signup = () => {
const token = await accountApi.login(user);
console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`);
await session.store(user.username, token);
window.location.href = routes.app;
fadeReload(routes.app);
} catch (e) {
console.log(`[Signup] Signup for user ${user.username} failed`, e);
if (e instanceof UserExistsError) {
@@ -87,20 +89,22 @@ const Signup = () => {
id="password"
autoComplete="new-password"
value={password}
onChange={(ev) => setPassword(ev.target.value.trim())}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={t("signup_form_toggle_password_visibility")}
onClick={() => setShowPassword(!showPassword)}
onMouseDown={(ev) => ev.preventDefault()}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
onChange={(ev) => setPassword(ev.target.value)}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={t("signup_form_toggle_password_visibility")}
onClick={() => setShowPassword(!showPassword)}
onMouseDown={(ev) => ev.preventDefault()}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
},
}}
/>
<TextField
@@ -113,20 +117,22 @@ const Signup = () => {
id="confirm"
autoComplete="new-password"
value={confirm}
onChange={(ev) => setConfirm(ev.target.value.trim())}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={t("signup_form_toggle_password_visibility")}
onClick={() => setShowConfirm(!showConfirm)}
onMouseDown={(ev) => ev.preventDefault()}
edge="end"
>
{showConfirm ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
onChange={(ev) => setConfirm(ev.target.value)}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={t("signup_form_toggle_password_visibility")}
onClick={() => setShowConfirm(!showConfirm)}
onMouseDown={(ev) => ev.preventDefault()}
edge="end"
>
{showConfirm ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
},
}}
/>
<Button
+23 -13
View File
@@ -26,7 +26,7 @@ import session from "../app/Session";
import routes from "./routes";
import accountApi, { Permission, Role } from "../app/AccountApi";
import ReserveTopicSelect from "./ReserveTopicSelect";
import { AccountContext } from "./App";
import AccountContext from "./AccountContext";
import { TopicReservedError, UnauthorizedError } from "../app/errors";
import { ReserveLimitChip } from "./SubscriptionPopup";
import prefs from "../app/Prefs";
@@ -34,7 +34,7 @@ import prefs from "../app/Prefs";
const publicBaseUrl = "https://ntfy.sh";
export const subscribeTopic = async (baseUrl, topic, opts) => {
const subscription = await subscriptionManager.add(baseUrl, topic, opts);
const subscription = await subscriptionManager.upsert(baseUrl, topic, opts);
if (session.exists()) {
try {
await accountApi.addSubscription(baseUrl, topic);
@@ -179,9 +179,11 @@ const SubscribePage = (props) => {
type="text"
fullWidth
variant="standard"
inputProps={{
maxLength: 64,
"aria-label": t("subscribe_dialog_subscribe_topic_placeholder"),
slotProps={{
htmlInput: {
maxLength: 64,
"aria-label": t("subscribe_dialog_subscribe_topic_placeholder"),
},
}}
/>
<Button
@@ -202,8 +204,10 @@ const SubscribePage = (props) => {
disabled={!reserveTopicEnabled}
checked={reserveTopicVisible}
onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
inputProps={{
"aria-label": t("reserve_dialog_checkbox_label"),
slotProps={{
input: {
"aria-label": t("reserve_dialog_checkbox_label"),
},
}}
/>
}
@@ -224,8 +228,10 @@ const SubscribePage = (props) => {
<Switch
onChange={handleUseAnotherChanged}
checked={anotherServerVisible}
inputProps={{
"aria-label": t("subscribe_dialog_subscribe_use_another_label"),
slotProps={{
input: {
"aria-label": t("subscribe_dialog_subscribe_use_another_label"),
},
}}
/>
}
@@ -303,8 +309,10 @@ const LoginPage = (props) => {
type="text"
fullWidth
variant="standard"
inputProps={{
"aria-label": t("subscribe_dialog_login_username_label"),
slotProps={{
htmlInput: {
"aria-label": t("subscribe_dialog_login_username_label"),
},
}}
/>
<TextField
@@ -316,8 +324,10 @@ const LoginPage = (props) => {
onChange={(ev) => setPassword(ev.target.value)}
fullWidth
variant="standard"
inputProps={{
"aria-label": t("subscribe_dialog_login_password_label"),
slotProps={{
htmlInput: {
"aria-label": t("subscribe_dialog_login_password_label"),
},
}}
/>
</DialogContent>
+25 -25
View File
@@ -19,18 +19,16 @@ import {
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
Clear,
ClearAll,
Edit,
EnhancedEncryption,
Lock,
LockOpen,
Notifications,
NotificationsOff,
RemoveCircle,
Send,
} from "@mui/icons-material";
import Clear from "@mui/icons-material/Clear";
import ClearAll from "@mui/icons-material/ClearAll";
import Edit from "@mui/icons-material/Edit";
import EnhancedEncryption from "@mui/icons-material/EnhancedEncryption";
import Lock from "@mui/icons-material/Lock";
import LockOpen from "@mui/icons-material/LockOpen";
import Notifications from "@mui/icons-material/Notifications";
import NotificationsOff from "@mui/icons-material/NotificationsOff";
import RemoveCircle from "@mui/icons-material/RemoveCircle";
import Send from "@mui/icons-material/Send";
import subscriptionManager from "../app/SubscriptionManager";
import DialogFooter from "./DialogFooter";
import accountApi, { Role } from "../app/AccountApi";
@@ -39,7 +37,7 @@ import routes from "./routes";
import PopupMenu from "./PopupMenu";
import { formatShortDateTime, shuffle } from "../app/utils";
import api from "../app/Api";
import { AccountContext } from "./App";
import AccountContext from "./AccountContext";
import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
import { UnauthorizedError } from "../app/errors";
@@ -326,18 +324,20 @@ const DisplayNameDialog = (props) => {
type="text"
fullWidth
variant="standard"
inputProps={{
maxLength: 64,
"aria-label": t("display_name_dialog_placeholder"),
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setDisplayName("")} edge="end">
<Clear />
</IconButton>
</InputAdornment>
),
slotProps={{
htmlInput: {
maxLength: 64,
"aria-label": t("display_name_dialog_placeholder"),
},
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setDisplayName("")} edge="end">
<Clear />
</IconButton>
</InputAdornment>
),
},
}}
/>
</DialogContent>
+3 -2
View File
@@ -24,11 +24,12 @@ import {
useTheme,
} from "@mui/material";
import { Trans, useTranslation } from "react-i18next";
import { Check, Close } from "@mui/icons-material";
import Check from "@mui/icons-material/Check";
import Close from "@mui/icons-material/Close";
import { NavLink } from "react-router-dom";
import { UnauthorizedError } from "../app/errors";
import { formatBytes, formatNumber, formatPrice, formatShortDate } from "../app/utils";
import { AccountContext } from "./App";
import AccountContext from "./AccountContext";
import routes from "./routes";
import session from "../app/Session";
import accountApi, { SubscriptionInterval } from "../app/AccountApi";
+2 -2
View File
@@ -111,7 +111,7 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
if (!account || !account.sync_topic) {
return;
}
subscriptionManager.add(config.base_url, account.sync_topic, { internal: true }); // Dangle!
subscriptionManager.upsert(config.base_url, account.sync_topic, { internal: true }); // Dangle!
}, [account]);
// When subscriptions or users change, refresh the connections
@@ -139,7 +139,7 @@ export const useAutoSubscribe = (subscriptions, selected) => {
const baseUrl = params.baseUrl ? expandSecureUrl(params.baseUrl) : config.base_url;
console.log(`[Hooks] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`);
(async () => {
const subscription = await subscriptionManager.add(baseUrl, params.topic);
const subscription = await subscriptionManager.upsert(baseUrl, params.topic);
if (session.exists()) {
try {
await accountApi.addSubscription(baseUrl, params.topic);
+26
View File
@@ -0,0 +1,26 @@
/* eslint-env node, es2021 */
// Minimal browser-global stubs so the pure-logic and API modules import + run under the node
// environment, without pulling in jsdom. utils.js -> config.js does `const { config } = window;`
// at import time, and config.js falls back to window.location.origin when base_url is empty.
const config = { base_url: "https://ntfy.sh", disallowed_topics: ["app", "account", "settings"] };
globalThis.window = {
location: { origin: "https://ntfy.sh" },
config,
atob: globalThis.atob, // urlB64ToUint8Array uses window.atob; Node provides global atob
};
// In the browser, `window` IS the global object, so `window.config` is also reachable as a bare
// `config` identifier -- Api.js/AccountApi.js/UserManager.js/SubscriptionManager.js rely on that.
// Node's `window` is just a plain object, so expose the same object as a real global too.
globalThis.config = config;
// utils.js -> Prefs.js -> db.js -> Session.username() reads localStorage at module load.
// Node has no localStorage; an in-memory stand-in is enough for the pure-logic tests.
const store = new Map();
globalThis.localStorage = {
getItem: (key) => (store.has(key) ? store.get(key) : null),
setItem: (key, value) => store.set(key, String(value)),
removeItem: (key) => store.delete(key),
clear: () => store.clear(),
};
+14
View File
@@ -0,0 +1,14 @@
/* eslint-disable import/no-extraneous-dependencies */
import { defineConfig } from "vitest/config";
// Standalone config (separate from vite.config.js) so the PWA plugin isn't loaded during tests.
// Tests run in the default "node" environment -- see src/test/setup.js for the minimal window stub
// that lets the pure-logic modules import without jsdom.
export default defineConfig({
test: {
environment: "node",
setupFiles: ["./src/test/setup.js"],
include: ["src/**/*.test.{js,jsx}"],
globals: false, // explicit `import { describe, it, expect } from "vitest"` -> no eslint env change
},
});