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:
rzuasti
2026-06-08 12:28:03 -04:00
co-authored by Claude Opus 4.8
parent fa54361198
commit 4f3fe10332
40 changed files with 8794 additions and 14 deletions
+9
View File
@@ -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):
+321 -13
View File
@@ -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"
+1
View File
@@ -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
);
+1
View File
@@ -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;
+169
View File
@@ -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
View File
@@ -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;
+119
View File
@@ -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
View File
@@ -1,5 +1,6 @@
mod delivery; mod delivery;
mod error; mod error;
mod push;
mod pushover; mod pushover;
mod render; mod render;
+10
View File
@@ -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);
+24
View File
@@ -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)
}
}
+158
View File
@@ -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"
);
}
}
+77
View File
@@ -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"
+14
View File
@@ -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.
+118
View File
@@ -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);
}
}
+1
View File
@@ -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
+67 -1
View File
@@ -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,
+24
View File
@@ -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)}');
}
}
+1
View File
@@ -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();
+140
View File
@@ -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"))
+88
View File
@@ -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:
+3
View File
@@ -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:
+33
View File
@@ -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
) )
+7
View File
@@ -0,0 +1,7 @@
node_modules/
lib/
*.log
.firebase/
# Never commit Firebase project credentials or service-account keys (project rule).
*serviceAccount*.json
.runtimeconfig.json
+77
View File
@@ -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`.
+7
View File
@@ -0,0 +1,7 @@
{
"functions": {
"source": ".",
"runtime": "nodejs22",
"predeploy": ["npm --prefix \"$RESOURCE_DIR\" run build"]
}
}
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
roots: ["<rootDir>/src"],
testMatch: ["**/*.test.ts"],
};
+6571
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -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"
}
}
+67
View File
@@ -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;
}
+30
View File
@@ -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);
+143
View File
@@ -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" }]);
});
});
+130
View File
@@ -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) };
});
}
+83
View File
@@ -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);
});
});
+106
View File
@@ -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;
});
},
};
}
+19
View File
@@ -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"]
}