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
|
## Backend
|
||||||
|
|
||||||
- [ ] Implement the pushover API call directly to support HTML content and review notification text to use it
|
- [ ] Implement the pushover API call directly to support HTML content and review notification text to use it
|
||||||
|
- [ ] Check for potential dependency upgrades
|
||||||
|
|
||||||
## Frontend
|
## 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
|
## Improve engine
|
||||||
|
|
||||||
Passive (low noise, no probing):
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -82,7 +82,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"once_cell_polyfill",
|
"once_cell_polyfill",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -642,7 +642,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -890,6 +890,25 @@ dependencies = [
|
|||||||
"tracing-futures",
|
"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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -1038,7 +1057,7 @@ dependencies = [
|
|||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2",
|
"h2 0.2.7",
|
||||||
"http 0.2.12",
|
"http 0.2.12",
|
||||||
"http-body 0.3.1",
|
"http-body 0.3.1",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -1062,6 +1081,7 @@ dependencies = [
|
|||||||
"bytes 1.11.0",
|
"bytes 1.11.0",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2 0.4.14",
|
||||||
"http 1.4.0",
|
"http 1.4.0",
|
||||||
"http-body 1.0.1",
|
"http-body 1.0.1",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -1071,6 +1091,22 @@ dependencies = [
|
|||||||
"pin-utils",
|
"pin-utils",
|
||||||
"smallvec 1.15.1",
|
"smallvec 1.15.1",
|
||||||
"tokio 1.49.0",
|
"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]]
|
[[package]]
|
||||||
@@ -1086,19 +1122,45 @@ dependencies = [
|
|||||||
"tokio-tls",
|
"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]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
"bytes 1.11.0",
|
"bytes 1.11.0",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-util",
|
||||||
"http 1.4.0",
|
"http 1.4.0",
|
||||||
"http-body 1.0.1",
|
"http-body 1.0.1",
|
||||||
"hyper 1.8.1",
|
"hyper 1.8.1",
|
||||||
|
"ipnet",
|
||||||
|
"libc",
|
||||||
|
"percent-encoding",
|
||||||
"pin-project-lite 0.2.16",
|
"pin-project-lite 0.2.16",
|
||||||
|
"socket2 0.6.4",
|
||||||
|
"system-configuration",
|
||||||
"tokio 1.49.0",
|
"tokio 1.49.0",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
"windows-registry",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1298,6 +1360,16 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
@@ -1516,7 +1588,7 @@ checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1660,6 +1732,7 @@ dependencies = [
|
|||||||
"pushover",
|
"pushover",
|
||||||
"r2d2",
|
"r2d2",
|
||||||
"r2d2_sqlite",
|
"r2d2_sqlite",
|
||||||
|
"reqwest 0.12.28",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"rusqlite_migration",
|
"rusqlite_migration",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2032,7 +2105,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "fb1e81d1a39ebb153484469f693f9127908565726331a19a24a783a6a31870d4"
|
checksum = "fb1e81d1a39ebb153484469f693f9127908565726331a19a24a783a6a31870d4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"error-chain",
|
"error-chain",
|
||||||
"reqwest",
|
"reqwest 0.10.10",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio-core",
|
"tokio-core",
|
||||||
@@ -2165,7 +2238,7 @@ dependencies = [
|
|||||||
"http 0.2.12",
|
"http 0.2.12",
|
||||||
"http-body 0.3.1",
|
"http-body 0.3.1",
|
||||||
"hyper 0.13.10",
|
"hyper 0.13.10",
|
||||||
"hyper-tls",
|
"hyper-tls 0.4.3",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
@@ -2187,6 +2260,60 @@ dependencies = [
|
|||||||
"winreg",
|
"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]]
|
[[package]]
|
||||||
name = "ron"
|
name = "ron"
|
||||||
version = "0.12.0"
|
version = "0.12.0"
|
||||||
@@ -2307,7 +2434,40 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"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]]
|
[[package]]
|
||||||
@@ -2337,7 +2497,7 @@ version = "0.1.28"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2585,7 +2745,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2612,6 +2772,12 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "1.0.109"
|
version = "1.0.109"
|
||||||
@@ -2639,6 +2805,9 @@ name = "sync_wrapper"
|
|||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "synstructure"
|
name = "synstructure"
|
||||||
@@ -2651,6 +2820,27 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"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]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.24.0"
|
version = "3.24.0"
|
||||||
@@ -2661,7 +2851,7 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2790,7 +2980,7 @@ dependencies = [
|
|||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
"socket2 0.6.4",
|
"socket2 0.6.4",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2876,6 +3066,16 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-reactor"
|
name = "tokio-reactor"
|
||||||
version = "0.1.12"
|
version = "0.1.12"
|
||||||
@@ -2895,6 +3095,16 @@ dependencies = [
|
|||||||
"tokio-sync",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-sync"
|
name = "tokio-sync"
|
||||||
version = "0.1.8"
|
version = "0.1.8"
|
||||||
@@ -3091,12 +3301,14 @@ dependencies = [
|
|||||||
"http-body-util",
|
"http-body-util",
|
||||||
"http-range-header",
|
"http-range-header",
|
||||||
"httpdate 1.0.3",
|
"httpdate 1.0.3",
|
||||||
|
"iri-string",
|
||||||
"mime",
|
"mime",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite 0.2.16",
|
"pin-project-lite 0.2.16",
|
||||||
"tokio 1.49.0",
|
"tokio 1.49.0",
|
||||||
"tokio-util 0.7.18",
|
"tokio-util 0.7.18",
|
||||||
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -3192,6 +3404,12 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -3470,7 +3688,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3520,6 +3738,17 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
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]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -3538,6 +3767,15 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -3547,6 +3785,70 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "0.7.14"
|
version = "0.7.14"
|
||||||
@@ -3744,6 +4046,12 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
|
|||||||
@@ -31,3 +31,4 @@ utoipa-swagger-ui = { version = "9", features = ["axum"] }
|
|||||||
simple-dns = "0.11.3"
|
simple-dns = "0.11.3"
|
||||||
socket2 = "0.6.4"
|
socket2 = "0.6.4"
|
||||||
csnmp = "0.6.0"
|
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 devices;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
|
pub mod push_tokens;
|
||||||
|
|
||||||
use include_dir::{Dir, include_dir};
|
use include_dir::{Dir, include_dir};
|
||||||
use lazy_static::lazy_static;
|
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 device_events;
|
||||||
pub mod devices;
|
pub mod devices;
|
||||||
pub mod notifications;
|
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 delivery;
|
||||||
mod error;
|
mod error;
|
||||||
|
mod push;
|
||||||
mod pushover;
|
mod pushover;
|
||||||
mod render;
|
mod render;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use tokio::sync::mpsc;
|
|||||||
|
|
||||||
use crate::settings::get_settings;
|
use crate::settings::get_settings;
|
||||||
|
|
||||||
|
use super::push;
|
||||||
use super::pushover;
|
use super::pushover;
|
||||||
|
|
||||||
// A notification handed to the delivery loop. Delivery (a blocking Pushover HTTP call) runs on a
|
// 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 => {
|
other => {
|
||||||
warn!("Notification method set to '{other}'. Set logs to 'info' to see notifications.");
|
warn!("Notification method set to '{other}'. Set logs to 'info' to see notifications.");
|
||||||
info!("Notification: {}", request.body);
|
info!("Notification: {}", request.body);
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
use std::{error, fmt};
|
use std::{error, fmt};
|
||||||
|
|
||||||
|
use crate::db::error::DbError;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum DeliveryError {
|
pub enum DeliveryError {
|
||||||
ParsePushover(pushover::Error),
|
ParsePushover(pushover::Error),
|
||||||
|
Http(reqwest::Error),
|
||||||
|
Db(DbError),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for DeliveryError {
|
impl fmt::Display for DeliveryError {
|
||||||
@@ -11,6 +15,12 @@ impl fmt::Display for DeliveryError {
|
|||||||
DeliveryError::ParsePushover(..) => {
|
DeliveryError::ParsePushover(..) => {
|
||||||
write!(f, "Error delivering notification via Pushover")
|
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)> {
|
fn source(&self) -> Option<&(dyn error::Error + 'static)> {
|
||||||
match *self {
|
match *self {
|
||||||
DeliveryError::ParsePushover(ref e) => Some(e),
|
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)
|
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,
|
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)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct Notifications {
|
pub struct Notifications {
|
||||||
pub method: String,
|
pub method: String,
|
||||||
// Only required when `method` is "pushover"; other methods leave this section out.
|
// Only required when `method` is "pushover"; other methods leave this section out.
|
||||||
pub pushover: Option<Pushover>,
|
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")]
|
#[serde(default = "default_notify_when_not_seen_for")]
|
||||||
pub notify_when_not_seen_for: DurationString,
|
pub notify_when_not_seen_for: DurationString,
|
||||||
}
|
}
|
||||||
@@ -579,6 +603,59 @@ mod tests {
|
|||||||
assert!(settings.validate().is_ok());
|
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#"
|
const NO_PUSHOVER_CONFIG: &str = r#"
|
||||||
[database]
|
[database]
|
||||||
path = "./oott.db"
|
path = "./oott.db"
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ use std::path::{Path, PathBuf};
|
|||||||
use crate::model::device_events::{DeviceEvent, DeviceEventScanner, DeviceEventType};
|
use crate::model::device_events::{DeviceEvent, DeviceEventScanner, DeviceEventType};
|
||||||
use crate::model::devices::{Device, DeviceListResponse, DeviceSummary};
|
use crate::model::devices::{Device, DeviceListResponse, DeviceSummary};
|
||||||
use crate::model::notifications::{Notification, NotificationListResponse, NotificationType};
|
use crate::model::notifications::{Notification, NotificationListResponse, NotificationType};
|
||||||
|
use crate::model::push_tokens::{PushPlatform, PushToken};
|
||||||
use crate::settings::get_settings;
|
use crate::settings::get_settings;
|
||||||
use crate::web_server::devices::{RegisterDevicePayload, UpdateDevicePayload};
|
use crate::web_server::devices::{RegisterDevicePayload, UpdateDevicePayload};
|
||||||
|
use crate::web_server::push_tokens::RegisterPushTokenPayload;
|
||||||
use crate::web_server::scanner_status::{
|
use crate::web_server::scanner_status::{
|
||||||
ActiveScannerStatusResponse, PassiveScannerStatusResponse,
|
ActiveScannerStatusResponse, PassiveScannerStatusResponse,
|
||||||
};
|
};
|
||||||
@@ -32,6 +34,7 @@ pub mod devices;
|
|||||||
pub mod dhcp_scanner;
|
pub mod dhcp_scanner;
|
||||||
pub mod mdns_scanner;
|
pub mod mdns_scanner;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
|
pub mod push_tokens;
|
||||||
pub mod scanner_status;
|
pub mod scanner_status;
|
||||||
pub mod snmp_scanner;
|
pub mod snmp_scanner;
|
||||||
pub mod ssdp_scanner;
|
pub mod ssdp_scanner;
|
||||||
@@ -58,6 +61,8 @@ pub mod utils;
|
|||||||
notifications::read_without_flagging,
|
notifications::read_without_flagging,
|
||||||
notifications::mark_as_new,
|
notifications::mark_as_new,
|
||||||
notifications::mark_all_as_old,
|
notifications::mark_all_as_old,
|
||||||
|
push_tokens::register,
|
||||||
|
push_tokens::unregister,
|
||||||
device_events::list,
|
device_events::list,
|
||||||
arp_scanner::status,
|
arp_scanner::status,
|
||||||
mdns_scanner::status,
|
mdns_scanner::status,
|
||||||
@@ -72,6 +77,9 @@ pub mod utils;
|
|||||||
Notification,
|
Notification,
|
||||||
NotificationListResponse,
|
NotificationListResponse,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
|
PushToken,
|
||||||
|
PushPlatform,
|
||||||
|
RegisterPushTokenPayload,
|
||||||
RegisterDevicePayload,
|
RegisterDevicePayload,
|
||||||
UpdateDevicePayload,
|
UpdateDevicePayload,
|
||||||
DeviceEvent,
|
DeviceEvent,
|
||||||
@@ -84,6 +92,7 @@ pub mod utils;
|
|||||||
tags(
|
tags(
|
||||||
(name = "devices", description = "Device management"),
|
(name = "devices", description = "Device management"),
|
||||||
(name = "notifications", description = "Notification management"),
|
(name = "notifications", description = "Notification management"),
|
||||||
|
(name = "push_tokens", description = "Push notification token registration"),
|
||||||
(name = "device_events", description = "Device event history"),
|
(name = "device_events", description = "Device event history"),
|
||||||
(name = "arp_scanner", description = "ARP scanner process status"),
|
(name = "arp_scanner", description = "ARP scanner process status"),
|
||||||
(name = "mdns_scanner", description = "mDNS/Bonjour 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",
|
"/api/notifications/{id}/mark_as_new",
|
||||||
post(notifications::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))
|
.route_layer(axum::middleware::from_fn(auth))
|
||||||
.layer(ServiceBuilder::new().layer(cors_layer))
|
.layer(ServiceBuilder::new().layer(cors_layer))
|
||||||
// Send visitors straight to the UI; the bare "/" has no content of its own.
|
// 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
|
android-tools
|
||||||
androidSdk
|
androidSdk
|
||||||
jdk17
|
jdk17
|
||||||
|
nodejs_22 # push relay (push_relay/): runs npm install/test/build; bundles npm
|
||||||
claude-code
|
claude-code
|
||||||
clippy # Rust linter
|
clippy # Rust linter
|
||||||
pythonEnv
|
pythonEnv
|
||||||
|
|||||||
@@ -7,10 +7,15 @@ import '../theme/app_colors.dart';
|
|||||||
import '../theme/dimens.dart';
|
import '../theme/dimens.dart';
|
||||||
import '../utils/oott_api.dart';
|
import '../utils/oott_api.dart';
|
||||||
import '../utils/pref_utils.dart';
|
import '../utils/pref_utils.dart';
|
||||||
|
import '../utils/push_service.dart';
|
||||||
import '../utils/ui_snackbars.dart';
|
import '../utils/ui_snackbars.dart';
|
||||||
|
|
||||||
class Settings extends StatefulWidget {
|
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
|
@override
|
||||||
State<Settings> createState() => _SettingsState();
|
State<Settings> createState() => _SettingsState();
|
||||||
@@ -24,6 +29,9 @@ class _SettingsState extends State<Settings> {
|
|||||||
bool _connectionModified = false;
|
bool _connectionModified = false;
|
||||||
bool _isFirstRun = false;
|
bool _isFirstRun = false;
|
||||||
late String _selectedTheme;
|
late String _selectedTheme;
|
||||||
|
late final PushService _pushService;
|
||||||
|
bool _pushEnabled = false;
|
||||||
|
bool _pushBusy = false;
|
||||||
|
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
@@ -36,6 +44,8 @@ class _SettingsState extends State<Settings> {
|
|||||||
PrefUtil.getValue('api_key', '') as String,
|
PrefUtil.getValue('api_key', '') as String,
|
||||||
);
|
);
|
||||||
_selectedTheme = context.read<AppState>().themeKey;
|
_selectedTheme = context.read<AppState>().themeKey;
|
||||||
|
_pushService = widget.pushService ?? FirebasePushService();
|
||||||
|
_pushEnabled = PrefUtil.getValue('push_enabled', false) as bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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 {
|
Future<void> _save() async {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
try {
|
try {
|
||||||
@@ -191,6 +245,18 @@ class _SettingsState extends State<Settings> {
|
|||||||
if (value != null) setState(() => _selectedTheme = value);
|
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),
|
const SizedBox(height: Insets.lg),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
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_devices.dart';
|
||||||
part 'api/oott_api_scanners.dart';
|
part 'api/oott_api_scanners.dart';
|
||||||
part 'api/oott_api_notifications.dart';
|
part 'api/oott_api_notifications.dart';
|
||||||
|
part 'api/oott_api_push.dart';
|
||||||
|
|
||||||
class BackendAPI {
|
class BackendAPI {
|
||||||
static final BackendAPI _instance = BackendAPI._internal();
|
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 connectivity_plus
|
||||||
import encrypter
|
import encrypter
|
||||||
|
import firebase_core
|
||||||
|
import firebase_messaging
|
||||||
|
import flutter_local_notifications
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
@@ -14,6 +17,9 @@ import url_launcher_macos
|
|||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||||
EncrypterPlugin.register(with: registry.registrar(forPlugin: "EncrypterPlugin"))
|
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"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
_flutterfire_internals:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: _flutterfire_internals
|
||||||
|
sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.59"
|
||||||
ansicolor:
|
ansicolor:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -217,6 +225,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
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:
|
fl_chart:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -246,6 +302,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
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:
|
flutter_native_splash:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -725,6 +805,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.10"
|
version: "0.7.10"
|
||||||
|
timezone:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: timezone
|
||||||
|
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.10.1"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ dependencies:
|
|||||||
google_fonts: ^6.2.1
|
google_fonts: ^6.2.1
|
||||||
url_launcher: ^6.3.0
|
url_launcher: ^6.3.0
|
||||||
package_info_plus: ^8.0.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:
|
dev_dependencies:
|
||||||
flutter_test:
|
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 <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||||
#include <encrypter/encrypter_plugin_c_api.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>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
@@ -15,6 +16,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||||
EncrypterPluginCApiRegisterWithRegistrar(
|
EncrypterPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("EncrypterPluginCApi"));
|
registry->GetRegistrarForPlugin("EncrypterPluginCApi"));
|
||||||
|
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
connectivity_plus
|
connectivity_plus
|
||||||
encrypter
|
encrypter
|
||||||
|
firebase_core
|
||||||
url_launcher_windows
|
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