mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
Implement push notifications Phase 1 (FCM + project relay)
Backend (Rust): - push_tokens migration, model (PushToken/PushPlatform), and db layer (upsert/list/delete/delete_many) - PUT/DELETE /api/push_tokens endpoints wired into the router + OpenAPI - "push" notification method: relay sender (reqwest) that forwards only the sanitized title/body and prunes dead tokens, plus settings with a default relay_url - 164 tests pass, clippy clean Relay (push_relay/, TypeScript Firebase Cloud Function): - POST /v1/push (firebase-admin sendEach + per-token status mapping), GET /healthz, payload validation, per-IP Firestore rate limiting - Jest tests + README documenting the manual project-owned setup Frontend (Flutter): - oott_api_push.dart (register/unregister), push_service.dart behind a PushService abstraction, and a per-device push toggle in settings - firebase_core/firebase_messaging/flutter_local_notifications deps - 145 tests pass, dart analyze clean Dev shell: - add nodejs_22 to the Nix dev shell so the relay tests/build run locally Remaining (manual, project-owned): create the Firebase project, deploy the relay and set the real default_relay_url, add native Firebase config, and test on real devices. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
fa54361198
commit
4f3fe10332
@@ -20,9 +20,18 @@
|
||||
## Backend
|
||||
|
||||
- [ ] Implement the pushover API call directly to support HTML content and review notification text to use it
|
||||
- [ ] Check for potential dependency upgrades
|
||||
|
||||
## Frontend
|
||||
|
||||
- [ ] In the status screen, the "listening for" property of passive scanners should change to days and months (now its always minutes)
|
||||
- [ ] Check for potential dependency upgrades
|
||||
- [ ] In settings, figure out automatic save
|
||||
|
||||
## Push relay
|
||||
|
||||
- [ ] Add NPM to the devShell to be able to run tests
|
||||
|
||||
## Improve engine
|
||||
|
||||
Passive (low noise, no probing):
|
||||
|
||||
Generated
+321
-13
@@ -71,7 +71,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -82,7 +82,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -642,7 +642,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -890,6 +890,25 @@ dependencies = [
|
||||
"tracing-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes 1.11.0",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http 1.4.0",
|
||||
"indexmap 2.13.0",
|
||||
"slab",
|
||||
"tokio 1.49.0",
|
||||
"tokio-util 0.7.18",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1038,7 +1057,7 @@ dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"h2 0.2.7",
|
||||
"http 0.2.12",
|
||||
"http-body 0.3.1",
|
||||
"httparse",
|
||||
@@ -1062,6 +1081,7 @@ dependencies = [
|
||||
"bytes 1.11.0",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2 0.4.14",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
"httparse",
|
||||
@@ -1071,6 +1091,22 @@ dependencies = [
|
||||
"pin-utils",
|
||||
"smallvec 1.15.1",
|
||||
"tokio 1.49.0",
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||
dependencies = [
|
||||
"http 1.4.0",
|
||||
"hyper 1.8.1",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"tokio 1.49.0",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1086,19 +1122,45 @@ dependencies = [
|
||||
"tokio-tls",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes 1.11.0",
|
||||
"http-body-util",
|
||||
"hyper 1.8.1",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio 1.49.0",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes 1.11.0",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
"hyper 1.8.1",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite 0.2.16",
|
||||
"socket2 0.6.4",
|
||||
"system-configuration",
|
||||
"tokio 1.49.0",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1298,6 +1360,16 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iri-string"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
@@ -1516,7 +1588,7 @@ checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1660,6 +1732,7 @@ dependencies = [
|
||||
"pushover",
|
||||
"r2d2",
|
||||
"r2d2_sqlite",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
"rusqlite_migration",
|
||||
"serde",
|
||||
@@ -2032,7 +2105,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb1e81d1a39ebb153484469f693f9127908565726331a19a24a783a6a31870d4"
|
||||
dependencies = [
|
||||
"error-chain",
|
||||
"reqwest",
|
||||
"reqwest 0.10.10",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio-core",
|
||||
@@ -2165,7 +2238,7 @@ dependencies = [
|
||||
"http 0.2.12",
|
||||
"http-body 0.3.1",
|
||||
"hyper 0.13.10",
|
||||
"hyper-tls",
|
||||
"hyper-tls 0.4.3",
|
||||
"ipnet",
|
||||
"js-sys",
|
||||
"lazy_static",
|
||||
@@ -2187,6 +2260,60 @@ dependencies = [
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes 1.11.0",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"h2 0.4.14",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper 1.8.1",
|
||||
"hyper-rustls",
|
||||
"hyper-tls 0.6.0",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite 0.2.16",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio 1.49.0",
|
||||
"tokio-native-tls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if 1.0.4",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ron"
|
||||
version = "0.12.0"
|
||||
@@ -2307,7 +2434,40 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2337,7 +2497,7 @@ version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2585,7 +2745,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2612,6 +2772,12 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
@@ -2639,6 +2805,9 @@ name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
@@ -2651,6 +2820,27 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.12.0",
|
||||
"core-foundation",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.24.0"
|
||||
@@ -2661,7 +2851,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2790,7 +2980,7 @@ dependencies = [
|
||||
"signal-hook-registry",
|
||||
"socket2 0.6.4",
|
||||
"tokio-macros",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2876,6 +3066,16 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio 1.49.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-reactor"
|
||||
version = "0.1.12"
|
||||
@@ -2895,6 +3095,16 @@ dependencies = [
|
||||
"tokio-sync",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio 1.49.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-sync"
|
||||
version = "0.1.8"
|
||||
@@ -3091,12 +3301,14 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate 1.0.3",
|
||||
"iri-string",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite 0.2.16",
|
||||
"tokio 1.49.0",
|
||||
"tokio-util 0.7.18",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -3192,6 +3404,12 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -3470,7 +3688,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3520,6 +3738,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
@@ -3538,6 +3767,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -3547,6 +3785,70 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.14"
|
||||
@@ -3744,6 +4046,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.3"
|
||||
|
||||
@@ -31,3 +31,4 @@ utoipa-swagger-ui = { version = "9", features = ["axum"] }
|
||||
simple-dns = "0.11.3"
|
||||
socket2 = "0.6.4"
|
||||
csnmp = "0.6.0"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE push_tokens(
|
||||
id INTEGER PRIMARY KEY,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
platform TEXT NOT NULL COLLATE NOCASE,
|
||||
created_on TEXT NOT NULL,
|
||||
last_seen TEXT NOT NULL
|
||||
);
|
||||
@@ -2,6 +2,7 @@ pub mod device_events;
|
||||
pub mod devices;
|
||||
pub mod error;
|
||||
pub mod notifications;
|
||||
pub mod push_tokens;
|
||||
|
||||
use include_dir::{Dir, include_dir};
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
use chrono::Utc;
|
||||
use log::{debug, error};
|
||||
use rusqlite::params;
|
||||
|
||||
use crate::db;
|
||||
use crate::db::error::DbError;
|
||||
use crate::model::push_tokens::{PushPlatform, PushToken};
|
||||
|
||||
// Register a token or refresh an existing one. A token is unique, so re-registering the same token
|
||||
// (e.g. on app launch or after an FCM token refresh) updates its platform and `last_seen` rather
|
||||
// than inserting a duplicate. `created_on` is preserved on update so it keeps recording first sight.
|
||||
pub fn upsert(token: &str, platform: PushPlatform) -> Result<(), DbError> {
|
||||
let conn = db::get_db_connection()?;
|
||||
let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Nanos, false);
|
||||
|
||||
match conn.execute(
|
||||
"INSERT INTO push_tokens (token, platform, created_on, last_seen) VALUES (?1, ?2, ?3, ?3)
|
||||
ON CONFLICT(token) DO UPDATE SET platform = excluded.platform, last_seen = excluded.last_seen",
|
||||
params![token, platform, now],
|
||||
) {
|
||||
Ok(_) => {
|
||||
debug!("Push token upserted (platform={platform})");
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
error!("Error upserting push token into database: {error}");
|
||||
Err(DbError::from(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list() -> Result<Vec<PushToken>, DbError> {
|
||||
debug!("Listing push tokens");
|
||||
let conn = db::get_db_connection()?;
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, token, platform, created_on, last_seen FROM push_tokens ORDER BY id",
|
||||
)?;
|
||||
|
||||
let tokens: Vec<PushToken> = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(PushToken {
|
||||
id: row.get(0)?,
|
||||
token: row.get(1)?,
|
||||
platform: row.get(2)?,
|
||||
created_on: row.get(3)?,
|
||||
last_seen: row.get(4)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
pub fn delete(token: &str) -> Result<usize, DbError> {
|
||||
let conn = db::get_db_connection()?;
|
||||
|
||||
match conn.execute("DELETE FROM push_tokens WHERE token = ?1", params![token]) {
|
||||
Ok(count) => {
|
||||
debug!("Deleted {count} push token(s)");
|
||||
Ok(count)
|
||||
}
|
||||
Err(error) => {
|
||||
error!("Error deleting push token from database: {error}");
|
||||
Err(DbError::from(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prune a batch of dead tokens reported by the relay (those FCM rejected as unregistered/invalid),
|
||||
// so the table does not accumulate tokens that can never be delivered to again. Deleting one at a
|
||||
// time keeps the call simple and the batches are small (one delivery's worth of tokens).
|
||||
pub fn delete_many(tokens: &[String]) -> Result<usize, DbError> {
|
||||
if tokens.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut conn = db::get_db_connection()?;
|
||||
let tx = conn.transaction()?;
|
||||
let mut deleted = 0;
|
||||
{
|
||||
let mut stmt = tx.prepare("DELETE FROM push_tokens WHERE token = ?1")?;
|
||||
for token in tokens {
|
||||
deleted += stmt.execute(params![token])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
|
||||
debug!("Pruned {deleted} dead push token(s)");
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tests_common;
|
||||
|
||||
// Count how many of the given tokens are currently stored. Scoped to the test's own tokens
|
||||
// because the test database is shared across tests.
|
||||
fn stored(tokens: &[&str]) -> usize {
|
||||
let all = list().unwrap();
|
||||
tokens
|
||||
.iter()
|
||||
.filter(|t| all.iter().any(|stored| &stored.token == *t))
|
||||
.count()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upsert_inserts_then_updates_without_duplicating() {
|
||||
tests_common::setup().await;
|
||||
|
||||
let token = "push-token-upsert-1";
|
||||
upsert(token, PushPlatform::Android).unwrap();
|
||||
|
||||
let after_insert = list().unwrap();
|
||||
let row = after_insert.iter().find(|t| t.token == token).unwrap();
|
||||
assert_eq!(row.platform, PushPlatform::Android);
|
||||
let created_on = row.created_on;
|
||||
let count_after_insert = after_insert.iter().filter(|t| t.token == token).count();
|
||||
assert_eq!(count_after_insert, 1);
|
||||
|
||||
// Re-registering the same token updates platform/last_seen and never duplicates the row.
|
||||
upsert(token, PushPlatform::Ios).unwrap();
|
||||
let after_update = list().unwrap();
|
||||
let rows: Vec<_> = after_update.iter().filter(|t| t.token == token).collect();
|
||||
assert_eq!(rows.len(), 1, "Re-registering must not create a second row");
|
||||
assert_eq!(rows[0].platform, PushPlatform::Ios, "Platform should update");
|
||||
assert_eq!(
|
||||
rows[0].created_on, created_on,
|
||||
"created_on must be preserved across an upsert"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_a_token() {
|
||||
tests_common::setup().await;
|
||||
|
||||
let token = "push-token-delete-1";
|
||||
upsert(token, PushPlatform::Android).unwrap();
|
||||
assert_eq!(stored(&[token]), 1);
|
||||
|
||||
let deleted = delete(token).unwrap();
|
||||
assert_eq!(deleted, 1);
|
||||
assert_eq!(stored(&[token]), 0);
|
||||
|
||||
// Deleting an absent token is a no-op, not an error.
|
||||
assert_eq!(delete(token).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_many_prunes_only_the_listed_tokens() {
|
||||
tests_common::setup().await;
|
||||
|
||||
let dead_a = "push-token-prune-dead-a";
|
||||
let dead_b = "push-token-prune-dead-b";
|
||||
let live = "push-token-prune-live";
|
||||
upsert(dead_a, PushPlatform::Android).unwrap();
|
||||
upsert(dead_b, PushPlatform::Ios).unwrap();
|
||||
upsert(live, PushPlatform::Android).unwrap();
|
||||
|
||||
let pruned = delete_many(&[dead_a.to_string(), dead_b.to_string()]).unwrap();
|
||||
assert_eq!(pruned, 2);
|
||||
assert_eq!(stored(&[dead_a, dead_b]), 0, "Dead tokens should be pruned");
|
||||
assert_eq!(stored(&[live]), 1, "Live token must remain");
|
||||
|
||||
// An empty prune list is a no-op.
|
||||
assert_eq!(delete_many(&[]).unwrap(), 0);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod device_events;
|
||||
pub mod devices;
|
||||
pub mod notifications;
|
||||
pub mod push_tokens;
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
use crate::utils::date_serializer;
|
||||
use chrono::{DateTime, Utc};
|
||||
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{error::Error, fmt, str::FromStr};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
// A device push-notification token registered by an instance of the OOTT mobile app. Tokens are
|
||||
// stored locally by the self-hosted backend; the project-operated relay never persists them.
|
||||
#[derive(Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct PushToken {
|
||||
pub id: i64,
|
||||
pub token: String,
|
||||
pub platform: PushPlatform,
|
||||
#[serde(with = "date_serializer")]
|
||||
#[schema(value_type = String, format = DateTime)]
|
||||
pub created_on: DateTime<Utc>,
|
||||
#[serde(with = "date_serializer")]
|
||||
#[schema(value_type = String, format = DateTime)]
|
||||
pub last_seen: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl fmt::Display for PushToken {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
// The token itself is a credential, so it is never logged in full; only a short suffix is
|
||||
// shown to make log lines correlatable without exposing the value.
|
||||
let suffix = if self.token.len() > 6 {
|
||||
&self.token[self.token.len() - 6..]
|
||||
} else {
|
||||
self.token.as_str()
|
||||
};
|
||||
write!(
|
||||
f,
|
||||
"id={}, platform={}, token=…{}, created_on={}, last_seen={}",
|
||||
self.id, self.platform, suffix, self.created_on, self.last_seen
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The OS push platform a token belongs to. Sent by the app at registration and stored so the relay
|
||||
// payload can be tailored per platform if ever needed.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PushPlatform {
|
||||
Android,
|
||||
Ios,
|
||||
}
|
||||
|
||||
impl fmt::Display for PushPlatform {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
Self::Android => write!(f, "android"),
|
||||
Self::Ios => write!(f, "ios"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PushPlatformParseError;
|
||||
|
||||
impl fmt::Display for PushPlatformParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "Error parsing push platform")
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for PushPlatformParseError {}
|
||||
|
||||
impl FromStr for PushPlatform {
|
||||
type Err = PushPlatformParseError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"android" => Ok(PushPlatform::Android),
|
||||
"ios" => Ok(PushPlatform::Ios),
|
||||
_ => Err(PushPlatformParseError),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToSql for PushPlatform {
|
||||
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
|
||||
Ok(self.to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromSql for PushPlatform {
|
||||
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
||||
value
|
||||
.as_str()?
|
||||
.parse()
|
||||
.map_err(|e| FromSqlError::Other(Box::new(e)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn platform_display_and_parse_round_trip() {
|
||||
for platform in [PushPlatform::Android, PushPlatform::Ios] {
|
||||
let text = platform.to_string();
|
||||
assert_eq!(text.parse::<PushPlatform>().unwrap(), platform);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_parse_is_case_insensitive() {
|
||||
assert_eq!("ANDROID".parse::<PushPlatform>().unwrap(), PushPlatform::Android);
|
||||
assert_eq!("iOS".parse::<PushPlatform>().unwrap(), PushPlatform::Ios);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_parse_rejects_unknown_values() {
|
||||
assert!("windows".parse::<PushPlatform>().is_err());
|
||||
assert!("".parse::<PushPlatform>().is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
mod delivery;
|
||||
mod error;
|
||||
mod push;
|
||||
mod pushover;
|
||||
mod render;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ use tokio::sync::mpsc;
|
||||
|
||||
use crate::settings::get_settings;
|
||||
|
||||
use super::push;
|
||||
use super::pushover;
|
||||
|
||||
// A notification handed to the delivery loop. Delivery (a blocking Pushover HTTP call) runs on a
|
||||
@@ -57,6 +58,15 @@ async fn deliver(request: DeliveryRequest) {
|
||||
);
|
||||
}
|
||||
},
|
||||
"push" => {
|
||||
// The relay URL defaults to the project-operated relay, so the [notifications.push]
|
||||
// section is optional. The send call is async (reqwest), so unlike Pushover it is
|
||||
// awaited directly rather than dispatched to the blocking pool.
|
||||
let config = get_settings().notifications.push.clone().unwrap_or_default();
|
||||
if let Err(err) = push::send(&config, request.title, request.body).await {
|
||||
error!("Failed to deliver notification via the push relay: {err}");
|
||||
}
|
||||
}
|
||||
other => {
|
||||
warn!("Notification method set to '{other}'. Set logs to 'info' to see notifications.");
|
||||
info!("Notification: {}", request.body);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
use std::{error, fmt};
|
||||
|
||||
use crate::db::error::DbError;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DeliveryError {
|
||||
ParsePushover(pushover::Error),
|
||||
Http(reqwest::Error),
|
||||
Db(DbError),
|
||||
}
|
||||
|
||||
impl fmt::Display for DeliveryError {
|
||||
@@ -11,6 +15,12 @@ impl fmt::Display for DeliveryError {
|
||||
DeliveryError::ParsePushover(..) => {
|
||||
write!(f, "Error delivering notification via Pushover")
|
||||
}
|
||||
DeliveryError::Http(..) => {
|
||||
write!(f, "Error delivering notification via the push relay")
|
||||
}
|
||||
DeliveryError::Db(..) => {
|
||||
write!(f, "Database error while delivering a push notification")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +29,8 @@ impl error::Error for DeliveryError {
|
||||
fn source(&self) -> Option<&(dyn error::Error + 'static)> {
|
||||
match *self {
|
||||
DeliveryError::ParsePushover(ref e) => Some(e),
|
||||
DeliveryError::Http(ref e) => Some(e),
|
||||
DeliveryError::Db(ref e) => Some(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,3 +40,15 @@ impl From<pushover::Error> for DeliveryError {
|
||||
DeliveryError::ParsePushover(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for DeliveryError {
|
||||
fn from(err: reqwest::Error) -> DeliveryError {
|
||||
DeliveryError::Http(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DbError> for DeliveryError {
|
||||
fn from(err: DbError) -> DeliveryError {
|
||||
DeliveryError::Db(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
use log::debug;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::db;
|
||||
use crate::notifications::error::DeliveryError;
|
||||
use crate::settings::Push;
|
||||
|
||||
// The relay request body. Only the already-sanitized title/body travels — no `data`, no MAC, no IP
|
||||
// (see push_notifications.md, "No private data in payloads").
|
||||
#[derive(Serialize)]
|
||||
struct RelayNotification {
|
||||
title: String,
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RelayRequest {
|
||||
tokens: Vec<String>,
|
||||
notification: RelayNotification,
|
||||
}
|
||||
|
||||
// Per-token result the relay returns so dead tokens can be pruned. `status` is one of
|
||||
// `ok` / `unregistered` / `invalid`.
|
||||
#[derive(Deserialize)]
|
||||
struct RelayTokenResult {
|
||||
token: String,
|
||||
status: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RelayResponse {
|
||||
#[serde(default)]
|
||||
results: Vec<RelayTokenResult>,
|
||||
}
|
||||
|
||||
// FCM statuses for tokens that can never be delivered to again; the backend prunes these.
|
||||
fn is_dead_status(status: &str) -> bool {
|
||||
matches!(status, "unregistered" | "invalid")
|
||||
}
|
||||
|
||||
fn dead_tokens(results: &[RelayTokenResult]) -> Vec<String> {
|
||||
results
|
||||
.iter()
|
||||
.filter(|result| is_dead_status(&result.status))
|
||||
.map(|result| result.token.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Deliver a notification to all registered devices through the project-operated push relay. Loads
|
||||
/// the stored tokens, forwards only the already-sanitized title/body, and prunes any tokens the
|
||||
/// relay reports as dead. Best-effort: a relay/network failure returns an error (logged by the
|
||||
/// caller) but never loses the event, which is already persisted in the notifications table.
|
||||
pub async fn send(config: &Push, title: String, body: String) -> Result<(), DeliveryError> {
|
||||
let stored = db::run_blocking(db::push_tokens::list).await?;
|
||||
if stored.is_empty() {
|
||||
debug!("No push tokens registered; nothing to deliver via the push relay");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let request = RelayRequest {
|
||||
tokens: stored.into_iter().map(|token| token.token).collect(),
|
||||
notification: RelayNotification { title, body },
|
||||
};
|
||||
debug!(
|
||||
"Delivering push to {} token(s) via relay",
|
||||
request.tokens.len()
|
||||
);
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(config.relay_url.as_str())
|
||||
.json(&request)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
let parsed: RelayResponse = response.json().await?;
|
||||
let dead = dead_tokens(&parsed.results);
|
||||
if !dead.is_empty() {
|
||||
debug!("Pruning {} dead push token(s) reported by the relay", dead.len());
|
||||
db::run_blocking(move || db::push_tokens::delete_many(&dead)).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::push_tokens::PushPlatform;
|
||||
use crate::tests_common;
|
||||
use axum::{Json, Router, routing::post};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn dead_tokens_selects_unregistered_and_invalid_only() {
|
||||
let results = vec![
|
||||
RelayTokenResult {
|
||||
token: "ok-1".into(),
|
||||
status: "ok".into(),
|
||||
},
|
||||
RelayTokenResult {
|
||||
token: "dead-1".into(),
|
||||
status: "unregistered".into(),
|
||||
},
|
||||
RelayTokenResult {
|
||||
token: "dead-2".into(),
|
||||
status: "invalid".into(),
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
dead_tokens(&results),
|
||||
vec!["dead-1".to_string(), "dead-2".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_prunes_tokens_the_relay_reports_dead() {
|
||||
tests_common::setup().await;
|
||||
|
||||
let live = "push-send-live";
|
||||
let dead = "push-send-dead";
|
||||
db::push_tokens::upsert(live, PushPlatform::Android).unwrap();
|
||||
db::push_tokens::upsert(dead, PushPlatform::Ios).unwrap();
|
||||
|
||||
// A mock relay that marks the dead token unregistered and the live one ok.
|
||||
let app = Router::new().route(
|
||||
"/v1/push",
|
||||
post(|Json(_body): Json<serde_json::Value>| async move {
|
||||
Json(json!({
|
||||
"results": [
|
||||
{ "token": "push-send-live", "status": "ok" },
|
||||
{ "token": "push-send-dead", "status": "unregistered" },
|
||||
]
|
||||
}))
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let config = Push {
|
||||
relay_url: format!("http://{addr}/v1/push"),
|
||||
};
|
||||
send(&config, "title".into(), "body".into()).await.unwrap();
|
||||
|
||||
let all = db::push_tokens::list().unwrap();
|
||||
assert!(
|
||||
all.iter().any(|token| token.token == live),
|
||||
"Live token must remain after delivery"
|
||||
);
|
||||
assert!(
|
||||
!all.iter().any(|token| token.token == dead),
|
||||
"A token the relay reports dead must be pruned"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -177,11 +177,35 @@ pub struct Pushover {
|
||||
pub user_key: String,
|
||||
}
|
||||
|
||||
fn default_relay_url() -> String {
|
||||
// The send endpoint of the project-operated push relay. Self-hosters need paste nothing to
|
||||
// enable push: with `method = "push"` and no `[notifications.push]` section, this default is
|
||||
// used. The concrete URL is filled in once the relay Cloud Function is deployed (see
|
||||
// push_relay/README.md); a self-hoster can always override it via `relay_url`.
|
||||
"https://oott-push-relay.example.com/v1/push".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct Push {
|
||||
#[serde(default = "default_relay_url")]
|
||||
pub relay_url: String,
|
||||
}
|
||||
|
||||
impl Default for Push {
|
||||
fn default() -> Self {
|
||||
Push {
|
||||
relay_url: default_relay_url(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct Notifications {
|
||||
pub method: String,
|
||||
// Only required when `method` is "pushover"; other methods leave this section out.
|
||||
pub pushover: Option<Pushover>,
|
||||
// Optional when `method` is "push": with no section the default relay URL is used.
|
||||
pub push: Option<Push>,
|
||||
#[serde(default = "default_notify_when_not_seen_for")]
|
||||
pub notify_when_not_seen_for: DurationString,
|
||||
}
|
||||
@@ -579,6 +603,59 @@ mod tests {
|
||||
assert!(settings.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_method_without_section_uses_default_relay_url() {
|
||||
// method = "push" with no [notifications.push] section: valid, and the default relay URL is
|
||||
// used so self-hosters need paste nothing to enable push.
|
||||
const PUSH_NO_SECTION: &str = r#"
|
||||
[database]
|
||||
path = "./oott.db"
|
||||
[networking]
|
||||
[log]
|
||||
level = "info"
|
||||
[notifications]
|
||||
method = "push"
|
||||
[web_server]
|
||||
api_key = "test"
|
||||
"#;
|
||||
let settings = parse(PUSH_NO_SECTION);
|
||||
assert_eq!(settings.notifications.method, "push");
|
||||
assert!(settings.notifications.push.is_none());
|
||||
assert!(settings.validate().is_ok());
|
||||
// The sender falls back to the default when the section is absent.
|
||||
assert_eq!(settings.notifications.push.unwrap_or_default().relay_url, default_relay_url());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_section_relay_url_is_parsed_when_present() {
|
||||
let toml = format!(
|
||||
"{BASE_CONFIG}
|
||||
[notifications.push]
|
||||
relay_url = \"https://relay.example.test/v1/push\"
|
||||
"
|
||||
);
|
||||
let settings = parse(&toml);
|
||||
let push = settings
|
||||
.notifications
|
||||
.push
|
||||
.expect("push section should be parsed when present");
|
||||
assert_eq!(push.relay_url, "https://relay.example.test/v1/push");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_relay_url_defaults_when_field_omitted() {
|
||||
// The [notifications.push] section is present but empty; relay_url must fall back to the
|
||||
// default rather than fail parsing.
|
||||
let toml = format!(
|
||||
"{BASE_CONFIG}
|
||||
[notifications.push]
|
||||
"
|
||||
);
|
||||
let settings = parse(&toml);
|
||||
let push = settings.notifications.push.expect("push section should parse when present");
|
||||
assert_eq!(push.relay_url, default_relay_url());
|
||||
}
|
||||
|
||||
const NO_PUSHOVER_CONFIG: &str = r#"
|
||||
[database]
|
||||
path = "./oott.db"
|
||||
|
||||
@@ -4,8 +4,10 @@ use std::path::{Path, PathBuf};
|
||||
use crate::model::device_events::{DeviceEvent, DeviceEventScanner, DeviceEventType};
|
||||
use crate::model::devices::{Device, DeviceListResponse, DeviceSummary};
|
||||
use crate::model::notifications::{Notification, NotificationListResponse, NotificationType};
|
||||
use crate::model::push_tokens::{PushPlatform, PushToken};
|
||||
use crate::settings::get_settings;
|
||||
use crate::web_server::devices::{RegisterDevicePayload, UpdateDevicePayload};
|
||||
use crate::web_server::push_tokens::RegisterPushTokenPayload;
|
||||
use crate::web_server::scanner_status::{
|
||||
ActiveScannerStatusResponse, PassiveScannerStatusResponse,
|
||||
};
|
||||
@@ -32,6 +34,7 @@ pub mod devices;
|
||||
pub mod dhcp_scanner;
|
||||
pub mod mdns_scanner;
|
||||
pub mod notifications;
|
||||
pub mod push_tokens;
|
||||
pub mod scanner_status;
|
||||
pub mod snmp_scanner;
|
||||
pub mod ssdp_scanner;
|
||||
@@ -58,6 +61,8 @@ pub mod utils;
|
||||
notifications::read_without_flagging,
|
||||
notifications::mark_as_new,
|
||||
notifications::mark_all_as_old,
|
||||
push_tokens::register,
|
||||
push_tokens::unregister,
|
||||
device_events::list,
|
||||
arp_scanner::status,
|
||||
mdns_scanner::status,
|
||||
@@ -72,6 +77,9 @@ pub mod utils;
|
||||
Notification,
|
||||
NotificationListResponse,
|
||||
NotificationType,
|
||||
PushToken,
|
||||
PushPlatform,
|
||||
RegisterPushTokenPayload,
|
||||
RegisterDevicePayload,
|
||||
UpdateDevicePayload,
|
||||
DeviceEvent,
|
||||
@@ -84,6 +92,7 @@ pub mod utils;
|
||||
tags(
|
||||
(name = "devices", description = "Device management"),
|
||||
(name = "notifications", description = "Notification management"),
|
||||
(name = "push_tokens", description = "Push notification token registration"),
|
||||
(name = "device_events", description = "Device event history"),
|
||||
(name = "arp_scanner", description = "ARP scanner process status"),
|
||||
(name = "mdns_scanner", description = "mDNS/Bonjour scanner process status"),
|
||||
@@ -183,6 +192,11 @@ pub async fn serve() -> Result<(), Box<dyn Error>> {
|
||||
"/api/notifications/{id}/mark_as_new",
|
||||
post(notifications::mark_as_new),
|
||||
)
|
||||
.route("/api/push_tokens", put(push_tokens::register))
|
||||
.route(
|
||||
"/api/push_tokens/{token}",
|
||||
delete(push_tokens::unregister),
|
||||
)
|
||||
.route_layer(axum::middleware::from_fn(auth))
|
||||
.layer(ServiceBuilder::new().layer(cors_layer))
|
||||
// Send visitors straight to the UI; the bare "/" has no content of its own.
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
use axum::extract::Path;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{Json, http::StatusCode};
|
||||
use log::error;
|
||||
use serde::Deserialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::db;
|
||||
use crate::model::push_tokens::PushPlatform;
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/push_tokens",
|
||||
tag = "push_tokens",
|
||||
request_body = RegisterPushTokenPayload,
|
||||
responses(
|
||||
(status = 200, description = "Push token registered or refreshed"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn register(Json(payload): Json<RegisterPushTokenPayload>) -> impl IntoResponse {
|
||||
db::run_blocking(move || match db::push_tokens::upsert(&payload.token, payload.platform) {
|
||||
Ok(_) => (StatusCode::OK, "Push token registered"),
|
||||
Err(err) => {
|
||||
error!("Error registering push token in the database: {err}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Error registering push token in the server, check your logs",
|
||||
)
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/push_tokens/{token}",
|
||||
tag = "push_tokens",
|
||||
params(
|
||||
("token" = String, Path, description = "The push token to unregister"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Push token unregistered"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn unregister(Path(token): Path<String>) -> impl IntoResponse {
|
||||
db::run_blocking(move || match db::push_tokens::delete(&token) {
|
||||
// Deleting an unknown token is a no-op and still reports success, so the app can call
|
||||
// unregister idempotently (e.g. when disabling push) without handling a "not found".
|
||||
Ok(_) => (StatusCode::OK, "Push token unregistered"),
|
||||
Err(err) => {
|
||||
error!("Error unregistering push token in the database: {err}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Error unregistering push token in the server, check your logs",
|
||||
)
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// Payload structs
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct RegisterPushTokenPayload {
|
||||
pub token: String,
|
||||
pub platform: PushPlatform,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tests_common;
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_then_unregister_round_trip() {
|
||||
tests_common::setup().await;
|
||||
|
||||
let token = "api-push-token-1".to_string();
|
||||
|
||||
let response = register(Json(RegisterPushTokenPayload {
|
||||
token: token.clone(),
|
||||
platform: PushPlatform::Android,
|
||||
}))
|
||||
.await
|
||||
.into_response();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
db::push_tokens::list()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|t| t.token == token),
|
||||
"Registered token should be stored"
|
||||
);
|
||||
|
||||
let response = unregister(Path(token.clone())).await.into_response();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
!db::push_tokens::list()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|t| t.token == token),
|
||||
"Unregistered token should be removed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unregister_is_idempotent_for_unknown_tokens() {
|
||||
tests_common::setup().await;
|
||||
|
||||
let response = unregister(Path("api-push-token-never-registered".to_string()))
|
||||
.await
|
||||
.into_response();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@
|
||||
android-tools
|
||||
androidSdk
|
||||
jdk17
|
||||
nodejs_22 # push relay (push_relay/): runs npm install/test/build; bundles npm
|
||||
claude-code
|
||||
clippy # Rust linter
|
||||
pythonEnv
|
||||
|
||||
@@ -7,10 +7,15 @@ import '../theme/app_colors.dart';
|
||||
import '../theme/dimens.dart';
|
||||
import '../utils/oott_api.dart';
|
||||
import '../utils/pref_utils.dart';
|
||||
import '../utils/push_service.dart';
|
||||
import '../utils/ui_snackbars.dart';
|
||||
|
||||
class Settings extends StatefulWidget {
|
||||
const Settings({super.key});
|
||||
const Settings({super.key, this.pushService});
|
||||
|
||||
/// Injectable so widget tests can supply a fake; defaults to the real
|
||||
/// FCM-backed service.
|
||||
final PushService? pushService;
|
||||
|
||||
@override
|
||||
State<Settings> createState() => _SettingsState();
|
||||
@@ -24,6 +29,9 @@ class _SettingsState extends State<Settings> {
|
||||
bool _connectionModified = false;
|
||||
bool _isFirstRun = false;
|
||||
late String _selectedTheme;
|
||||
late final PushService _pushService;
|
||||
bool _pushEnabled = false;
|
||||
bool _pushBusy = false;
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
@@ -36,6 +44,8 @@ class _SettingsState extends State<Settings> {
|
||||
PrefUtil.getValue('api_key', '') as String,
|
||||
);
|
||||
_selectedTheme = context.read<AppState>().themeKey;
|
||||
_pushService = widget.pushService ?? FirebasePushService();
|
||||
_pushEnabled = PrefUtil.getValue('push_enabled', false) as bool;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -70,6 +80,50 @@ class _SettingsState extends State<Settings> {
|
||||
}
|
||||
}
|
||||
|
||||
// Enable or disable push on this specific device. The toggle reflects user
|
||||
// intent (persisted), but enabling can still fail if the OS permission is
|
||||
// declined, in which case the switch falls back to off.
|
||||
Future<void> _togglePush(bool value) async {
|
||||
setState(() => _pushBusy = true);
|
||||
try {
|
||||
if (value) {
|
||||
final enabled = await _pushService.enable();
|
||||
if (!mounted) return;
|
||||
if (enabled) {
|
||||
await PrefUtil.setValue('push_enabled', true);
|
||||
if (!mounted) return;
|
||||
setState(() => _pushEnabled = true);
|
||||
UISnackbars.showSuccess(
|
||||
context,
|
||||
'Push notifications enabled on this device',
|
||||
);
|
||||
} else {
|
||||
setState(() => _pushEnabled = false);
|
||||
UISnackbars.showError(
|
||||
context,
|
||||
'Could not enable push. Check notification permission for OOTT.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await _pushService.disable();
|
||||
if (!mounted) return;
|
||||
await PrefUtil.setValue('push_enabled', false);
|
||||
if (!mounted) return;
|
||||
setState(() => _pushEnabled = false);
|
||||
UISnackbars.showSuccess(
|
||||
context,
|
||||
'Push notifications disabled on this device',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to update push settings: $e');
|
||||
if (!mounted) return;
|
||||
UISnackbars.showError(context, 'Failed to update push settings');
|
||||
} finally {
|
||||
if (mounted) setState(() => _pushBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
try {
|
||||
@@ -191,6 +245,18 @@ class _SettingsState extends State<Settings> {
|
||||
if (value != null) setState(() => _selectedTheme = value);
|
||||
},
|
||||
),
|
||||
if (_pushService.isSupported) ...[
|
||||
const SizedBox(height: Insets.sm),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Push notifications on this device'),
|
||||
subtitle: const Text(
|
||||
'Receive alerts on this device even when the app is closed.',
|
||||
),
|
||||
value: _pushEnabled,
|
||||
onChanged: _pushBusy ? null : _togglePush,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: Insets.lg),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
part of '../oott_api.dart';
|
||||
|
||||
/// Push-token endpoints: register/refresh this device's FCM token so the backend
|
||||
/// can deliver push notifications to it, and unregister it when push is disabled.
|
||||
///
|
||||
/// Only the opaque FCM token and the platform travel here — no device or network
|
||||
/// data. The token is a credential, so the unregister path component is encoded.
|
||||
extension PushApi on BackendAPI {
|
||||
/// Registers or refreshes [token] for [platform] (`android` or `ios`). Safe to
|
||||
/// call repeatedly (e.g. on launch or after an FCM token refresh): the backend
|
||||
/// upserts by token.
|
||||
Future<void> registerPushToken(String token, String platform) async {
|
||||
await _dio.put(
|
||||
'/push_tokens',
|
||||
data: {'token': token, 'platform': platform},
|
||||
);
|
||||
}
|
||||
|
||||
/// Unregisters [token] so this device stops receiving push notifications.
|
||||
/// Idempotent: unregistering an unknown token still succeeds.
|
||||
Future<void> unregisterPushToken(String token) async {
|
||||
await _dio.delete('/push_tokens/${Uri.encodeComponent(token)}');
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export 'api/api_error.dart';
|
||||
part 'api/oott_api_devices.dart';
|
||||
part 'api/oott_api_scanners.dart';
|
||||
part 'api/oott_api_notifications.dart';
|
||||
part 'api/oott_api_push.dart';
|
||||
|
||||
class BackendAPI {
|
||||
static final BackendAPI _instance = BackendAPI._internal();
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
import 'oott_api.dart';
|
||||
|
||||
/// Per-device push enable/disable, behind an interface so the settings UI can be
|
||||
/// driven by a fake in tests without pulling in Firebase. Tapping a push only
|
||||
/// opens the app (no deep-link, no identifier); the in-app notification list
|
||||
/// holds the detail.
|
||||
abstract class PushService {
|
||||
/// Whether push is available on this platform/build (mobile only — FCM/APNs).
|
||||
bool get isSupported;
|
||||
|
||||
/// Requests notification permission, obtains the FCM token, and registers it
|
||||
/// with the backend. Returns true when push is enabled, false when the user
|
||||
/// declined permission or no token could be obtained.
|
||||
Future<bool> enable();
|
||||
|
||||
/// Unregisters this device's token from the backend and clears it locally so
|
||||
/// the device stops receiving push notifications.
|
||||
Future<void> disable();
|
||||
}
|
||||
|
||||
/// FCM-backed [PushService]. Kept separate from the UI so its Firebase
|
||||
/// dependencies never reach widget tests, which use a fake [PushService].
|
||||
class FirebasePushService implements PushService {
|
||||
FirebasePushService({FlutterLocalNotificationsPlugin? localNotifications})
|
||||
: _localNotifications =
|
||||
localNotifications ?? FlutterLocalNotificationsPlugin();
|
||||
|
||||
final FlutterLocalNotificationsPlugin _localNotifications;
|
||||
|
||||
// Android channel used to surface a heads-up notification while the app is in
|
||||
// the foreground (the OS shows backgrounded/terminated notifications itself).
|
||||
static const AndroidNotificationChannel _androidChannel =
|
||||
AndroidNotificationChannel(
|
||||
'oott_alerts',
|
||||
'OOTT alerts',
|
||||
description:
|
||||
'New device, device back online and device changed alerts.',
|
||||
importance: Importance.high,
|
||||
);
|
||||
|
||||
bool _foregroundDisplayWired = false;
|
||||
|
||||
@override
|
||||
bool get isSupported =>
|
||||
!kIsWeb &&
|
||||
(defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS);
|
||||
|
||||
String get _platformName =>
|
||||
defaultTargetPlatform == TargetPlatform.iOS ? 'ios' : 'android';
|
||||
|
||||
Future<void> _ensureFirebase() async {
|
||||
// No options passed: the native google-services.json / GoogleService-Info
|
||||
// .plist added during the one-time project setup provide them.
|
||||
if (Firebase.apps.isEmpty) {
|
||||
await Firebase.initializeApp();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> enable() async {
|
||||
if (!isSupported) return false;
|
||||
await _ensureFirebase();
|
||||
|
||||
final settings = await FirebaseMessaging.instance.requestPermission();
|
||||
if (settings.authorizationStatus == AuthorizationStatus.denied) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final token = await FirebaseMessaging.instance.getToken();
|
||||
if (token == null) return false;
|
||||
|
||||
await BackendAPI.instance.registerPushToken(token, _platformName);
|
||||
|
||||
// Re-register whenever FCM rotates the token so the backend never holds a
|
||||
// stale one.
|
||||
FirebaseMessaging.instance.onTokenRefresh.listen((refreshed) {
|
||||
BackendAPI.instance.registerPushToken(refreshed, _platformName);
|
||||
});
|
||||
|
||||
await _wireForegroundDisplay();
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disable() async {
|
||||
if (!isSupported) return;
|
||||
await _ensureFirebase();
|
||||
final token = await FirebaseMessaging.instance.getToken();
|
||||
if (token != null) {
|
||||
await BackendAPI.instance.unregisterPushToken(token);
|
||||
}
|
||||
await FirebaseMessaging.instance.deleteToken();
|
||||
}
|
||||
|
||||
// Configure the local-notifications plugin and render foreground messages
|
||||
// ourselves (the OS displays them directly when the app is backgrounded or
|
||||
// terminated). Taps just open the app, so no tap handler is wired.
|
||||
Future<void> _wireForegroundDisplay() async {
|
||||
if (_foregroundDisplayWired) return;
|
||||
_foregroundDisplayWired = true;
|
||||
|
||||
await _localNotifications.initialize(
|
||||
const InitializationSettings(
|
||||
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||
iOS: DarwinInitializationSettings(),
|
||||
),
|
||||
);
|
||||
await _localNotifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.createNotificationChannel(_androidChannel);
|
||||
|
||||
FirebaseMessaging.onMessage.listen((message) {
|
||||
final notification = message.notification;
|
||||
if (notification == null) return;
|
||||
_localNotifications.show(
|
||||
notification.hashCode,
|
||||
notification.title,
|
||||
notification.body,
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
_androidChannel.id,
|
||||
_androidChannel.name,
|
||||
channelDescription: _androidChannel.description,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
),
|
||||
iOS: const DarwinNotificationDetails(),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import Foundation
|
||||
|
||||
import connectivity_plus
|
||||
import encrypter
|
||||
import firebase_core
|
||||
import firebase_messaging
|
||||
import flutter_local_notifications
|
||||
import package_info_plus
|
||||
import shared_preferences_foundation
|
||||
import url_launcher_macos
|
||||
@@ -14,6 +17,9 @@ import url_launcher_macos
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||
EncrypterPlugin.register(with: registry.registrar(forPlugin: "EncrypterPlugin"))
|
||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_flutterfire_internals:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _flutterfire_internals
|
||||
sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.59"
|
||||
ansicolor:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -217,6 +225,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
firebase_core:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: firebase_core
|
||||
sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.15.2"
|
||||
firebase_core_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_platform_interface
|
||||
sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.3"
|
||||
firebase_core_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_web
|
||||
sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.24.1"
|
||||
firebase_messaging:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: firebase_messaging
|
||||
sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.10"
|
||||
firebase_messaging_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_messaging_platform_interface
|
||||
sha256: "685e1771b3d1f9c8502771ccc9f91485b376ffe16d553533f335b9183ea99754"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.6.10"
|
||||
firebase_messaging_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_messaging_web
|
||||
sha256: "0d1be17bc89ed3ff5001789c92df678b2e963a51b6fa2bdb467532cc9dbed390"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.10.10"
|
||||
fl_chart:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -246,6 +302,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_local_notifications:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "18.0.1"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.0"
|
||||
flutter_native_splash:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -725,6 +805,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timezone
|
||||
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -21,6 +21,9 @@ dependencies:
|
||||
google_fonts: ^6.2.1
|
||||
url_launcher: ^6.3.0
|
||||
package_info_plus: ^8.0.0
|
||||
firebase_core: ^3.8.0
|
||||
firebase_messaging: ^15.1.6
|
||||
flutter_local_notifications: ^18.0.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frontend/utils/oott_api.dart';
|
||||
import 'package:http_mock_adapter/http_mock_adapter.dart';
|
||||
|
||||
import '../helpers/backend_test_harness.dart';
|
||||
|
||||
void main() {
|
||||
late DioAdapter adapter;
|
||||
|
||||
setUp(() async {
|
||||
adapter = await setUpBackendForTest();
|
||||
});
|
||||
|
||||
test('registerPushToken PUTs the token and platform', () async {
|
||||
adapter.onPut(
|
||||
'/push_tokens',
|
||||
(server) => server.reply(200, null),
|
||||
data: {'token': 'fcm-token-123', 'platform': 'android'},
|
||||
);
|
||||
|
||||
await BackendAPI.instance.registerPushToken('fcm-token-123', 'android');
|
||||
});
|
||||
|
||||
test('unregisterPushToken DELETEs the encoded token path', () async {
|
||||
// A token can contain characters that must be percent-encoded in the path.
|
||||
adapter.onDelete(
|
||||
'/push_tokens/tok%2Fwith%3Aspecial',
|
||||
(server) => server.reply(200, null),
|
||||
);
|
||||
|
||||
await BackendAPI.instance.unregisterPushToken('tok/with:special');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'package:encrypter/encrypter/xor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frontend/settings/settings.dart';
|
||||
import 'package:frontend/utils/pref_utils.dart';
|
||||
import 'package:frontend/utils/push_service.dart';
|
||||
|
||||
import '../helpers/backend_test_harness.dart';
|
||||
import '../helpers/pump_app.dart';
|
||||
|
||||
// A fake so the toggle can be exercised without Firebase.
|
||||
class _FakePushService implements PushService {
|
||||
_FakePushService({this.supported = true, this.enableResult = true});
|
||||
|
||||
final bool supported;
|
||||
final bool enableResult;
|
||||
int enableCalls = 0;
|
||||
int disableCalls = 0;
|
||||
|
||||
@override
|
||||
bool get isSupported => supported;
|
||||
|
||||
@override
|
||||
Future<bool> enable() async {
|
||||
enableCalls++;
|
||||
return enableResult;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disable() async {
|
||||
disableCalls++;
|
||||
}
|
||||
}
|
||||
|
||||
const _toggleText = 'Push notifications on this device';
|
||||
|
||||
void main() {
|
||||
setUp(() async {
|
||||
await setUpBackendForTest(
|
||||
prefs: {
|
||||
'base_url': 'http://my.server/api',
|
||||
'api_key': XOR().xorEncode('topsecret'),
|
||||
'theme': 'catppuccin_mocha',
|
||||
},
|
||||
);
|
||||
// The mock SharedPreferences persist across tests in this isolate, so reset
|
||||
// the per-device push intent to a known-off baseline before each test.
|
||||
await PrefUtil.setValue('push_enabled', false);
|
||||
});
|
||||
|
||||
testWidgets('shows the push toggle on a supported platform', (tester) async {
|
||||
await pumpScreen(tester, Settings(pushService: _FakePushService()));
|
||||
expect(find.text(_toggleText), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hides the push toggle when push is unsupported', (tester) async {
|
||||
await pumpScreen(
|
||||
tester,
|
||||
Settings(pushService: _FakePushService(supported: false)),
|
||||
);
|
||||
expect(find.text(_toggleText), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('enabling push registers and shows a success message', (
|
||||
tester,
|
||||
) async {
|
||||
final service = _FakePushService(enableResult: true);
|
||||
await pumpScreen(tester, Settings(pushService: service));
|
||||
|
||||
await tester.tap(find.byType(SwitchListTile));
|
||||
await pumpUntilFound(
|
||||
tester,
|
||||
find.text('Push notifications enabled on this device'),
|
||||
);
|
||||
|
||||
expect(service.enableCalls, 1);
|
||||
expect(PrefUtil.getValue('push_enabled', false), isTrue);
|
||||
expect(tester.widget<SwitchListTile>(find.byType(SwitchListTile)).value, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('a declined permission leaves the toggle off with an error', (
|
||||
tester,
|
||||
) async {
|
||||
final service = _FakePushService(enableResult: false);
|
||||
await pumpScreen(tester, Settings(pushService: service));
|
||||
|
||||
await tester.tap(find.byType(SwitchListTile));
|
||||
await pumpUntilFound(
|
||||
tester,
|
||||
find.textContaining('Could not enable push'),
|
||||
);
|
||||
|
||||
expect(service.enableCalls, 1);
|
||||
expect(tester.widget<SwitchListTile>(find.byType(SwitchListTile)).value, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('disabling push unregisters and shows a success message', (
|
||||
tester,
|
||||
) async {
|
||||
await PrefUtil.setValue('push_enabled', true);
|
||||
final service = _FakePushService();
|
||||
await pumpScreen(tester, Settings(pushService: service));
|
||||
|
||||
// Starts on because the stored intent is enabled.
|
||||
expect(
|
||||
tester.widget<SwitchListTile>(find.byType(SwitchListTile)).value,
|
||||
isTrue,
|
||||
);
|
||||
|
||||
await tester.tap(find.byType(SwitchListTile));
|
||||
await pumpUntilFound(
|
||||
tester,
|
||||
find.text('Push notifications disabled on this device'),
|
||||
);
|
||||
|
||||
expect(service.disableCalls, 1);
|
||||
expect(PrefUtil.getValue('push_enabled', false), isFalse);
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||
#include <encrypter/encrypter_plugin_c_api.h>
|
||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
@@ -15,6 +16,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||
EncrypterPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("EncrypterPluginCApi"));
|
||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
connectivity_plus
|
||||
encrypter
|
||||
firebase_core
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
lib/
|
||||
*.log
|
||||
.firebase/
|
||||
# Never commit Firebase project credentials or service-account keys (project rule).
|
||||
*serviceAccount*.json
|
||||
.runtimeconfig.json
|
||||
@@ -0,0 +1,77 @@
|
||||
# OOTT Push Relay
|
||||
|
||||
A small, **stateless** relay that forwards OOTT push notifications to the OS push
|
||||
gateways (FCM for Android, APNs for iOS via Firebase). It is deployed as a
|
||||
**Firebase Cloud Function** (2nd gen), so it scales to zero — no idle cost and no
|
||||
server/OS to patch — and the platform provides TLS and a stable HTTPS URL.
|
||||
|
||||
It is part of the push-notification feature described in
|
||||
[`../push_notifications.md`](../push_notifications.md). See that document for the
|
||||
full design and rationale.
|
||||
|
||||
## What it does (and does not do)
|
||||
|
||||
- **Forwards only.** It receives `{ tokens, notification: { title, body } }` from
|
||||
a self-hosted OOTT backend and fans the message out to FCM via the
|
||||
`firebase-admin` `sendEach` API. It returns a per-token result so the backend
|
||||
can prune dead tokens.
|
||||
- **Keeps no state and no PII.** There is no database of users or tokens here; the
|
||||
self-hosted backend owns the tokens. The only thing the relay stores is a
|
||||
per-IP rate-limit counter in Firestore.
|
||||
- **Never sees private data.** The payload carries only an already-sanitized
|
||||
title/body — no `data` payload, no MAC, no IP, no device identifiers.
|
||||
- **Credentials never leave Google.** The function authenticates to FCM via its
|
||||
runtime service account; there is no service-account JSON to hold or rotate.
|
||||
|
||||
## Routes
|
||||
|
||||
- `POST /v1/push` — body `{ "tokens": ["..."], "notification": { "title": "...",
|
||||
"body": "..." } }`. Returns `{ "results": [{ "token": "...", "status":
|
||||
"ok" | "unregistered" | "invalid" | "error" }] }`. The backend prunes tokens
|
||||
reported `unregistered` or `invalid`.
|
||||
- `GET /healthz` — liveness, returns `200 ok`.
|
||||
|
||||
Protection (Phase 1, no shared secret): FCM project scoping (the relay can only
|
||||
reach OOTT app installs), per-source-IP rate limiting, and a billing cap.
|
||||
|
||||
## Develop
|
||||
|
||||
Requires Node 22.
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm test # unit tests (validation, FCM-response mapping, rate limiting)
|
||||
npm run build # type-check + emit to lib/
|
||||
npm run serve # run locally in the Firebase emulator
|
||||
```
|
||||
|
||||
## One-time project-owned setup (manual — no secrets committed)
|
||||
|
||||
These steps are performed once by the project owner. **Do not commit any secrets,
|
||||
service-account keys, or `google-services.json` / `GoogleService-Info.plist`**
|
||||
(project rule).
|
||||
|
||||
1. **Create a Firebase project** and upgrade it to the **Blaze** plan (Cloud
|
||||
Functions require it). Set a **budget + billing cap** as the hard ceiling
|
||||
against cost runaway.
|
||||
2. **Register the apps**: add the Android app and the iOS app to the Firebase
|
||||
project (their bundle/package ids must match the shipped OOTT app).
|
||||
3. **APNs key**: in the Apple Developer portal create an APNs Auth Key (`.p8`)
|
||||
and upload it under *Project settings → Cloud Messaging → Apple app
|
||||
configuration*. This is what lets Firebase bridge to APNs for iOS.
|
||||
4. **Enable Firestore** (Native mode) — used only for the rate-limit counter.
|
||||
5. **Install the Firebase CLI** (`npm i -g firebase-tools`) and `firebase login`.
|
||||
6. Select the project: `firebase use --add` (creates `.firebaserc`, which holds
|
||||
only the project id and is safe to commit, or keep local).
|
||||
|
||||
## Deploy
|
||||
|
||||
```sh
|
||||
npm run deploy # builds, then `firebase deploy --only functions`
|
||||
```
|
||||
|
||||
After the first deploy, note the function's HTTPS URL (shown in the CLI output,
|
||||
e.g. `https://us-central1-<project>.cloudfunctions.net/relay`). The backend's
|
||||
push send endpoint is that URL plus `/v1/push`. Set this as the default
|
||||
`relay_url` in the backend (`backend/src/settings.rs`, `default_relay_url`); a
|
||||
self-hoster can always override it via `[notifications.push] relay_url`.
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"functions": {
|
||||
"source": ".",
|
||||
"runtime": "nodejs22",
|
||||
"predeploy": ["npm --prefix \"$RESOURCE_DIR\" run build"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
roots: ["<rootDir>/src"],
|
||||
testMatch: ["**/*.test.ts"],
|
||||
};
|
||||
Generated
+6571
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "oott-push-relay",
|
||||
"version": "0.1.0",
|
||||
"description": "Stateless relay that forwards OOTT push notifications to FCM/APNs.",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"main": "lib/index.js",
|
||||
"engines": {
|
||||
"node": "22"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"serve": "npm run build && firebase emulators:start --only functions",
|
||||
"deploy": "npm run build && firebase deploy --only functions"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"firebase-admin": "^13.0.2",
|
||||
"firebase-functions": "^6.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.10.5",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import express, { type Express, type Request, type Response } from "express";
|
||||
|
||||
import { sendPush, validatePushRequest, type MessengerLike } from "./push";
|
||||
import {
|
||||
checkRateLimit,
|
||||
DEFAULT_RATE_LIMIT,
|
||||
type RateLimitOptions,
|
||||
type RateLimitStore,
|
||||
} from "./rateLimit";
|
||||
|
||||
export interface AppDependencies {
|
||||
messenger: MessengerLike;
|
||||
rateLimitStore: RateLimitStore;
|
||||
rateLimit?: RateLimitOptions;
|
||||
}
|
||||
|
||||
// Identify the caller for rate limiting. Behind Cloud Functions the real client IP is the left-most
|
||||
// entry of X-Forwarded-For (Google's proxy appends its own); `trust proxy` makes `req.ip` resolve to
|
||||
// it. One home/deployment is roughly one public IP.
|
||||
function clientKey(req: Request): string {
|
||||
return req.ip ?? "unknown";
|
||||
}
|
||||
|
||||
// Build the relay HTTP app. Dependencies are injected so the routes can be exercised with a fake
|
||||
// messenger and an in-memory rate-limit store in tests, and with the real Firebase ones in
|
||||
// production (see index.ts).
|
||||
export function createApp(deps: AppDependencies): Express {
|
||||
const app = express();
|
||||
// Trust Google's front-end proxy so req.ip is the caller, not the proxy.
|
||||
app.set("trust proxy", true);
|
||||
app.use(express.json({ limit: "256kb" }));
|
||||
|
||||
// Liveness probe — no side effects, never rate limited.
|
||||
app.get("/healthz", (_req: Request, res: Response) => {
|
||||
res.status(200).send("ok");
|
||||
});
|
||||
|
||||
app.post("/v1/push", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const decision = await checkRateLimit(
|
||||
deps.rateLimitStore,
|
||||
clientKey(req),
|
||||
deps.rateLimit ?? DEFAULT_RATE_LIMIT,
|
||||
);
|
||||
if (!decision.allowed) {
|
||||
res.status(429).json({ error: "rate limit exceeded" });
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = validatePushRequest(req.body);
|
||||
if (!validation.ok) {
|
||||
res.status(400).json({ error: validation.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await sendPush(deps.messenger, validation.value);
|
||||
res.status(200).json({ results });
|
||||
} catch (err) {
|
||||
// A messenger/transport failure is upstream's fault, not the caller's.
|
||||
const message = err instanceof Error ? err.message : "unknown error";
|
||||
console.error("Failed to relay push:", message);
|
||||
res.status(502).json({ error: "failed to deliver to FCM" });
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { initializeApp } from "firebase-admin/app";
|
||||
import { getFirestore } from "firebase-admin/firestore";
|
||||
import { getMessaging } from "firebase-admin/messaging";
|
||||
import { onRequest } from "firebase-functions/v2/https";
|
||||
|
||||
import { createApp } from "./app";
|
||||
import { createFirestoreRateLimitStore, type FirestoreLike } from "./rateLimit";
|
||||
import type { MessengerLike, TokenMessage } from "./push";
|
||||
|
||||
// One Admin SDK app per function instance. It authenticates to FCM via the function's runtime
|
||||
// service account — there is no service-account JSON to manage, store, or rotate (Google keeps the
|
||||
// secret).
|
||||
initializeApp();
|
||||
|
||||
// Adapt the Admin Messaging API to the small MessengerLike surface the handler depends on.
|
||||
const messenger: MessengerLike = {
|
||||
sendEach(messages: TokenMessage[]) {
|
||||
return getMessaging().sendEach(messages);
|
||||
},
|
||||
};
|
||||
|
||||
const rateLimitStore = createFirestoreRateLimitStore(
|
||||
getFirestore() as unknown as FirestoreLike,
|
||||
);
|
||||
|
||||
const app = createApp({ messenger, rateLimitStore });
|
||||
|
||||
// Single HTTP function hosting both routes (POST /v1/push, GET /healthz). Scales to zero, so there
|
||||
// is no idle cost and no server/OS to patch; the platform provides TLS and a stable HTTPS URL.
|
||||
export const relay = onRequest({ region: "us-central1", maxInstances: 10 }, app);
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
classifyFailure,
|
||||
MAX_BODY_LENGTH,
|
||||
MAX_TITLE_LENGTH,
|
||||
MAX_TOKENS,
|
||||
sendPush,
|
||||
validatePushRequest,
|
||||
type BatchResponseLike,
|
||||
type MessengerLike,
|
||||
type TokenMessage,
|
||||
} from "./push";
|
||||
|
||||
describe("validatePushRequest", () => {
|
||||
const valid = {
|
||||
tokens: ["token-a", "token-b"],
|
||||
notification: { title: "New device", body: "A new device joined your network" },
|
||||
};
|
||||
|
||||
it("accepts a well-formed request", () => {
|
||||
const result = validatePushRequest(valid);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.tokens).toEqual(["token-a", "token-b"]);
|
||||
expect(result.value.notification.title).toBe("New device");
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a non-object body", 42],
|
||||
["a null body", null],
|
||||
])("rejects %s", (_label, body) => {
|
||||
expect(validatePushRequest(body).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a missing or empty token list", () => {
|
||||
expect(validatePushRequest({ ...valid, tokens: undefined }).ok).toBe(false);
|
||||
expect(validatePushRequest({ ...valid, tokens: [] }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects more than the maximum number of tokens", () => {
|
||||
const tokens = new Array(MAX_TOKENS + 1).fill("t");
|
||||
expect(validatePushRequest({ ...valid, tokens }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-string or empty tokens", () => {
|
||||
expect(validatePushRequest({ ...valid, tokens: ["ok", 1] }).ok).toBe(false);
|
||||
expect(validatePushRequest({ ...valid, tokens: ["ok", ""] }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a missing notification or empty fields", () => {
|
||||
expect(validatePushRequest({ tokens: valid.tokens }).ok).toBe(false);
|
||||
expect(
|
||||
validatePushRequest({ ...valid, notification: { title: "", body: "b" } }).ok,
|
||||
).toBe(false);
|
||||
expect(
|
||||
validatePushRequest({ ...valid, notification: { title: "t", body: "" } }).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects oversized title or body", () => {
|
||||
expect(
|
||||
validatePushRequest({
|
||||
...valid,
|
||||
notification: { title: "t".repeat(MAX_TITLE_LENGTH + 1), body: "b" },
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
expect(
|
||||
validatePushRequest({
|
||||
...valid,
|
||||
notification: { title: "t", body: "b".repeat(MAX_BODY_LENGTH + 1) },
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyFailure", () => {
|
||||
it("maps not-registered to unregistered", () => {
|
||||
expect(classifyFailure("messaging/registration-token-not-registered")).toBe("unregistered");
|
||||
});
|
||||
|
||||
it("maps malformed-token codes to invalid", () => {
|
||||
expect(classifyFailure("messaging/invalid-registration-token")).toBe("invalid");
|
||||
expect(classifyFailure("messaging/invalid-argument")).toBe("invalid");
|
||||
expect(classifyFailure("messaging/mismatched-credential")).toBe("invalid");
|
||||
});
|
||||
|
||||
it("treats unknown or missing codes as transient errors", () => {
|
||||
expect(classifyFailure("messaging/internal-error")).toBe("error");
|
||||
expect(classifyFailure(undefined)).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendPush", () => {
|
||||
function messengerReturning(batch: BatchResponseLike): {
|
||||
messenger: MessengerLike;
|
||||
sent: TokenMessage[][];
|
||||
} {
|
||||
const sent: TokenMessage[][] = [];
|
||||
return {
|
||||
sent,
|
||||
messenger: {
|
||||
async sendEach(messages: TokenMessage[]) {
|
||||
sent.push(messages);
|
||||
return batch;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("maps each response to its token in order", async () => {
|
||||
const { messenger, sent } = messengerReturning({
|
||||
responses: [
|
||||
{ success: true },
|
||||
{ success: false, error: { code: "messaging/registration-token-not-registered" } },
|
||||
{ success: false, error: { code: "messaging/invalid-argument" } },
|
||||
{ success: false, error: { code: "messaging/internal-error" } },
|
||||
],
|
||||
});
|
||||
|
||||
const results = await sendPush(messenger, {
|
||||
tokens: ["ok", "gone", "bad", "flaky"],
|
||||
notification: { title: "t", body: "b" },
|
||||
});
|
||||
|
||||
expect(results).toEqual([
|
||||
{ token: "ok", status: "ok" },
|
||||
{ token: "gone", status: "unregistered" },
|
||||
{ token: "bad", status: "invalid" },
|
||||
{ token: "flaky", status: "error" },
|
||||
]);
|
||||
// Only the sanitized title/body is forwarded — no data payload.
|
||||
expect(sent[0][0]).toEqual({ token: "ok", notification: { title: "t", body: "b" } });
|
||||
});
|
||||
|
||||
it("defaults to a transient error when a response entry is missing", async () => {
|
||||
const { messenger } = messengerReturning({ responses: [] });
|
||||
const results = await sendPush(messenger, {
|
||||
tokens: ["lonely"],
|
||||
notification: { title: "t", body: "b" },
|
||||
});
|
||||
expect(results).toEqual([{ token: "lonely", status: "error" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
// Core push logic, kept free of Firebase wiring so it can be unit-tested with a mock messenger.
|
||||
// The relay forwards only an already-sanitized title/body — never a `data` payload, MAC or IP
|
||||
// (see ../push_notifications.md, "No private data in payloads").
|
||||
|
||||
// Status reported back to the caller for each token so it can prune dead ones.
|
||||
// `ok` – delivered (or accepted by FCM).
|
||||
// `unregistered` – the app was uninstalled / the token expired; prune it.
|
||||
// `invalid` – the token is malformed or for another project; prune it.
|
||||
// `error` – a transient failure; keep the token and retry on the next event.
|
||||
export type TokenStatus = "ok" | "unregistered" | "invalid" | "error";
|
||||
|
||||
export interface PushNotification {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface PushRequest {
|
||||
tokens: string[];
|
||||
notification: PushNotification;
|
||||
}
|
||||
|
||||
export interface TokenResult {
|
||||
token: string;
|
||||
status: TokenStatus;
|
||||
}
|
||||
|
||||
// A single message handed to the messenger — mirrors the shape of `admin.messaging.TokenMessage`.
|
||||
export interface TokenMessage {
|
||||
token: string;
|
||||
notification: PushNotification;
|
||||
}
|
||||
|
||||
// Minimal slice of `admin.messaging.Messaging` we depend on, so tests can supply a fake.
|
||||
export interface BatchResponseLike {
|
||||
responses: Array<{ success: boolean; error?: { code?: string } }>;
|
||||
}
|
||||
|
||||
export interface MessengerLike {
|
||||
sendEach(messages: TokenMessage[]): Promise<BatchResponseLike>;
|
||||
}
|
||||
|
||||
// FCM caps a single `sendEach` multicast at 500 messages; a single OOTT deployment has far fewer
|
||||
// devices, so this doubles as request-size protection.
|
||||
export const MAX_TOKENS = 500;
|
||||
export const MAX_TITLE_LENGTH = 256;
|
||||
export const MAX_BODY_LENGTH = 2048;
|
||||
|
||||
export type ValidationResult =
|
||||
| { ok: true; value: PushRequest }
|
||||
| { ok: false; error: string };
|
||||
|
||||
// Validate and narrow an untrusted request body. Rejects anything malformed or oversized so the
|
||||
// relay never forwards junk to FCM (and a bad caller cannot run up cost with huge payloads).
|
||||
export function validatePushRequest(body: unknown): ValidationResult {
|
||||
if (typeof body !== "object" || body === null) {
|
||||
return { ok: false, error: "body must be a JSON object" };
|
||||
}
|
||||
const candidate = body as Record<string, unknown>;
|
||||
|
||||
const tokens = candidate.tokens;
|
||||
if (!Array.isArray(tokens) || tokens.length === 0) {
|
||||
return { ok: false, error: "tokens must be a non-empty array" };
|
||||
}
|
||||
if (tokens.length > MAX_TOKENS) {
|
||||
return { ok: false, error: `tokens must contain at most ${MAX_TOKENS} entries` };
|
||||
}
|
||||
if (!tokens.every((token) => typeof token === "string" && token.length > 0)) {
|
||||
return { ok: false, error: "every token must be a non-empty string" };
|
||||
}
|
||||
|
||||
const notification = candidate.notification;
|
||||
if (typeof notification !== "object" || notification === null) {
|
||||
return { ok: false, error: "notification must be an object" };
|
||||
}
|
||||
const { title, body: messageBody } = notification as Record<string, unknown>;
|
||||
if (typeof title !== "string" || title.length === 0) {
|
||||
return { ok: false, error: "notification.title must be a non-empty string" };
|
||||
}
|
||||
if (typeof messageBody !== "string" || messageBody.length === 0) {
|
||||
return { ok: false, error: "notification.body must be a non-empty string" };
|
||||
}
|
||||
if (title.length > MAX_TITLE_LENGTH) {
|
||||
return { ok: false, error: `notification.title must be at most ${MAX_TITLE_LENGTH} characters` };
|
||||
}
|
||||
if (messageBody.length > MAX_BODY_LENGTH) {
|
||||
return { ok: false, error: `notification.body must be at most ${MAX_BODY_LENGTH} characters` };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: { tokens: tokens as string[], notification: { title, body: messageBody } },
|
||||
};
|
||||
}
|
||||
|
||||
// Map an FCM error code to the status the backend uses to decide whether to prune the token.
|
||||
export function classifyFailure(code: string | undefined): TokenStatus {
|
||||
switch (code) {
|
||||
case "messaging/registration-token-not-registered":
|
||||
return "unregistered";
|
||||
case "messaging/invalid-registration-token":
|
||||
case "messaging/invalid-argument":
|
||||
case "messaging/mismatched-credential":
|
||||
return "invalid";
|
||||
default:
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
// Send one notification to every token and return a per-token result. Order is preserved so each
|
||||
// response lines up with its token. `sendEach` never throws for per-token failures — they surface as
|
||||
// unsuccessful entries — so only an outright messenger error propagates to the caller.
|
||||
export async function sendPush(
|
||||
messenger: MessengerLike,
|
||||
request: PushRequest,
|
||||
): Promise<TokenResult[]> {
|
||||
const messages: TokenMessage[] = request.tokens.map((token) => ({
|
||||
token,
|
||||
notification: request.notification,
|
||||
}));
|
||||
|
||||
const batch = await messenger.sendEach(messages);
|
||||
|
||||
return request.tokens.map((token, index) => {
|
||||
const response = batch.responses[index];
|
||||
if (response && response.success) {
|
||||
return { token, status: "ok" };
|
||||
}
|
||||
return { token, status: classifyFailure(response?.error?.code) };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
checkRateLimit,
|
||||
createFirestoreRateLimitStore,
|
||||
InMemoryRateLimitStore,
|
||||
type FirestoreLike,
|
||||
} from "./rateLimit";
|
||||
|
||||
describe("checkRateLimit", () => {
|
||||
const options = { limit: 3, windowMs: 1000 };
|
||||
|
||||
it("allows calls up to the limit then blocks within the window", async () => {
|
||||
const store = new InMemoryRateLimitStore();
|
||||
const now = 10_000;
|
||||
|
||||
for (let i = 1; i <= options.limit; i++) {
|
||||
const decision = await checkRateLimit(store, "1.2.3.4", options, now);
|
||||
expect(decision.allowed).toBe(true);
|
||||
expect(decision.count).toBe(i);
|
||||
}
|
||||
|
||||
const blocked = await checkRateLimit(store, "1.2.3.4", options, now);
|
||||
expect(blocked.allowed).toBe(false);
|
||||
expect(blocked.count).toBe(options.limit + 1);
|
||||
});
|
||||
|
||||
it("starts a fresh window once the window has elapsed", async () => {
|
||||
const store = new InMemoryRateLimitStore();
|
||||
|
||||
await checkRateLimit(store, "1.2.3.4", options, 0);
|
||||
await checkRateLimit(store, "1.2.3.4", options, 500);
|
||||
const blocked = await checkRateLimit(store, "1.2.3.4", options, 900);
|
||||
expect(blocked.count).toBe(3);
|
||||
|
||||
// Past the window: the counter resets.
|
||||
const fresh = await checkRateLimit(store, "1.2.3.4", options, 1100);
|
||||
expect(fresh.allowed).toBe(true);
|
||||
expect(fresh.count).toBe(1);
|
||||
});
|
||||
|
||||
it("tracks each source key independently", async () => {
|
||||
const store = new InMemoryRateLimitStore();
|
||||
const a = await checkRateLimit(store, "1.1.1.1", options, 0);
|
||||
const b = await checkRateLimit(store, "2.2.2.2", options, 0);
|
||||
expect(a.count).toBe(1);
|
||||
expect(b.count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFirestoreRateLimitStore", () => {
|
||||
// A tiny in-memory stand-in for the Firestore transaction surface the store uses.
|
||||
function fakeFirestore(): FirestoreLike {
|
||||
const docs = new Map<string, { count: number; windowStart: number }>();
|
||||
return {
|
||||
collection() {
|
||||
return {
|
||||
doc(id: string) {
|
||||
return { path: id };
|
||||
},
|
||||
};
|
||||
},
|
||||
async runTransaction(fn) {
|
||||
const tx = {
|
||||
async get(ref: { path: string }) {
|
||||
const data = docs.get(ref.path);
|
||||
return { exists: data !== undefined, data: () => data };
|
||||
},
|
||||
set(ref: { path: string }, data: { count: number; windowStart: number }) {
|
||||
docs.set(ref.path, data);
|
||||
},
|
||||
};
|
||||
return fn(tx);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("persists and increments the per-key counter transactionally", async () => {
|
||||
const store = createFirestoreRateLimitStore(fakeFirestore());
|
||||
expect(await store.hit("9.9.9.9", 1000, 0)).toBe(1);
|
||||
expect(await store.hit("9.9.9.9", 1000, 100)).toBe(2);
|
||||
// New window resets the count.
|
||||
expect(await store.hit("9.9.9.9", 1000, 2000)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
// Per-source-IP rate limiting so one abuser cannot starve other deployments. A fixed-window counter
|
||||
// is enough here: one home/deployment is roughly one public IP, and limits are generous to tolerate
|
||||
// the occasional CGNAT-shared IP. The counter is the only state the relay keeps and it lives in
|
||||
// Firestore (within the free tier at this volume); the windowing logic itself is pure and tested.
|
||||
|
||||
export interface RateLimitOptions {
|
||||
// Maximum number of allowed calls within a window.
|
||||
limit: number;
|
||||
// Window length in milliseconds.
|
||||
windowMs: number;
|
||||
}
|
||||
|
||||
// Generous defaults: an OOTT deployment only pushes on network-membership changes, so a few hundred
|
||||
// per hour is far above normal while still capping a runaway/abusive source.
|
||||
export const DEFAULT_RATE_LIMIT: RateLimitOptions = {
|
||||
limit: 300,
|
||||
windowMs: 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
// Atomic per-key counter. `hit` records one call against `key` and returns the running count within
|
||||
// the current window, starting a fresh window when the previous one has elapsed.
|
||||
export interface RateLimitStore {
|
||||
hit(key: string, windowMs: number, now: number): Promise<number>;
|
||||
}
|
||||
|
||||
export interface RateLimitDecision {
|
||||
allowed: boolean;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export async function checkRateLimit(
|
||||
store: RateLimitStore,
|
||||
key: string,
|
||||
options: RateLimitOptions = DEFAULT_RATE_LIMIT,
|
||||
now: number = Date.now(),
|
||||
): Promise<RateLimitDecision> {
|
||||
const count = await store.hit(key, options.windowMs, now);
|
||||
return { allowed: count <= options.limit, count };
|
||||
}
|
||||
|
||||
interface WindowState {
|
||||
count: number;
|
||||
windowStart: number;
|
||||
}
|
||||
|
||||
// Compute the next window state from the previous one. A call in a new window resets the counter.
|
||||
function nextWindow(previous: WindowState | null, windowMs: number, now: number): WindowState {
|
||||
if (previous && now - previous.windowStart < windowMs) {
|
||||
return { count: previous.count + 1, windowStart: previous.windowStart };
|
||||
}
|
||||
return { count: 1, windowStart: now };
|
||||
}
|
||||
|
||||
// In-memory store for unit tests and the local emulator. Not for production (a Cloud Function scales
|
||||
// to many instances, so the counter must be shared — see the Firestore store below).
|
||||
export class InMemoryRateLimitStore implements RateLimitStore {
|
||||
private readonly windows = new Map<string, WindowState>();
|
||||
|
||||
async hit(key: string, windowMs: number, now: number): Promise<number> {
|
||||
const state = nextWindow(this.windows.get(key) ?? null, windowMs, now);
|
||||
this.windows.set(key, state);
|
||||
return state.count;
|
||||
}
|
||||
}
|
||||
|
||||
// Structural slice of the Firestore API the store needs, so this module does not depend on
|
||||
// firebase-admin and stays unit-testable.
|
||||
interface DocSnapshotLike {
|
||||
exists: boolean;
|
||||
data(): WindowState | undefined;
|
||||
}
|
||||
interface DocRefLike {
|
||||
readonly path: string;
|
||||
}
|
||||
interface TransactionLike {
|
||||
get(ref: DocRefLike): Promise<DocSnapshotLike>;
|
||||
set(ref: DocRefLike, data: WindowState): void;
|
||||
}
|
||||
interface CollectionLike {
|
||||
doc(id: string): DocRefLike;
|
||||
}
|
||||
export interface FirestoreLike {
|
||||
collection(name: string): CollectionLike;
|
||||
runTransaction<T>(updateFunction: (transaction: TransactionLike) => Promise<T>): Promise<T>;
|
||||
}
|
||||
|
||||
// Firestore-backed store. The read-modify-write runs in a transaction so concurrent function
|
||||
// instances increment the same counter atomically.
|
||||
export function createFirestoreRateLimitStore(
|
||||
db: FirestoreLike,
|
||||
collection = "rate_limits",
|
||||
): RateLimitStore {
|
||||
return {
|
||||
hit(key: string, windowMs: number, now: number): Promise<number> {
|
||||
// Document ids cannot contain "/"; IPv6 addresses are clean but encode defensively anyway.
|
||||
const ref = db.collection(collection).doc(encodeURIComponent(key));
|
||||
return db.runTransaction(async (tx) => {
|
||||
const snapshot = await tx.get(ref);
|
||||
const previous = snapshot.exists ? (snapshot.data() ?? null) : null;
|
||||
const state = nextWindow(previous, windowMs, now);
|
||||
tx.set(ref, state);
|
||||
return state.count;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "es2022",
|
||||
"lib": ["es2022"],
|
||||
"outDir": "lib",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts", "lib", "node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user