diff --git a/api/Cargo.lock b/api/Cargo.lock index 5a21ad8..a61926e 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -257,11 +257,16 @@ version = "0.1.0" dependencies = [ "argon2", "axum", + "base64 0.22.1", "chrono", "dotenvy", + "ece", "hex", "jsonwebtoken", "msedge-tts", + "p256", + "rand 0.8.5", + "reqwest", "serde", "serde_json", "sha2", @@ -271,6 +276,18 @@ dependencies = [ "uuid", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -369,6 +386,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.10.0" @@ -473,6 +496,18 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -569,6 +604,38 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ece" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2ea1d2f2cc974957a4e2575d8e5bb494549bab66338d6320c2789abcfff5746" +dependencies = [ + "base64 0.21.7", + "byteorder", + "hex", + "hkdf", + "lazy_static", + "once_cell", + "openssl", + "serde", + "sha2", + "thiserror 1.0.69", +] + [[package]] name = "either" version = "1.15.0" @@ -578,6 +645,26 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -656,6 +743,16 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -828,6 +925,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -843,6 +941,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -851,7 +963,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core 0.10.0", "wasip2", "wasip3", @@ -869,6 +981,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1021,6 +1144,24 @@ dependencies = [ "pin-utils", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.4.0", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", ] [[package]] @@ -1029,13 +1170,21 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64 0.22.1", "bytes", + "futures-channel", + "futures-util", "http 1.4.0", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -1191,6 +1340,22 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "isahc" version = "1.7.2" @@ -1242,7 +1407,7 @@ version = "9.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" dependencies = [ - "base64", + "base64 0.22.1", "js-sys", "pem", "ring", @@ -1362,6 +1527,12 @@ dependencies = [ "value-bag", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchit" version = "0.8.4" @@ -1422,7 +1593,7 @@ dependencies = [ "async-native-tls", "async-std", "async-tungstenite", - "base64", + "base64 0.22.1", "chrono", "futures-util", "http 1.4.0", @@ -1572,6 +1743,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -1618,7 +1801,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -1777,6 +1960,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1786,6 +1978,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -1795,6 +2042,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1808,10 +2061,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.0" @@ -1833,6 +2096,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -1842,6 +2115,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.0" @@ -1866,6 +2148,54 @@ dependencies = [ "bitflags 2.11.0", ] +[[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", + "futures-core", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1900,6 +2230,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustix" version = "1.1.4" @@ -1913,6 +2249,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1940,6 +2311,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -2169,7 +2554,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "crc", "crossbeam-queue", @@ -2242,7 +2627,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags 2.11.0", "byteorder", "bytes", @@ -2284,7 +2669,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags 2.11.0", "byteorder", "crc", @@ -2377,6 +2762,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -2526,6 +2914,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -2581,12 +2979,14 @@ dependencies = [ "http-body-util", "http-range-header", "httpdate", + "iri-string", "mime", "mime_guess", "percent-encoding", "pin-project-lite", "tokio", "tokio-util", + "tower", "tower-layer", "tower-service", "tracing", @@ -2646,6 +3046,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "tungstenite" version = "0.24.0" @@ -2776,6 +3182,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2909,6 +3324,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "whoami" version = "1.6.1" diff --git a/api/Cargo.toml b/api/Cargo.toml index b0d8a3a..c45178e 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -18,3 +18,8 @@ dotenvy = "0.15" msedge-tts = "0.2" sha2 = "0.10" hex = "0.4" +ece = "2" +base64 = "0.22" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +rand = "0.8" +p256 = { version = "0.13", features = ["pem"] } diff --git a/api/migrations/005_push_notifications.sql b/api/migrations/005_push_notifications.sql new file mode 100644 index 0000000..88f91b4 --- /dev/null +++ b/api/migrations/005_push_notifications.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS push_subscriptions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS vapid_keys ( + id INTEGER PRIMARY KEY CHECK (id = 1), + private_key_pem TEXT NOT NULL, + public_key_base64url TEXT NOT NULL +); + +ALTER TABLE user_stats ADD COLUMN reminder_enabled INTEGER DEFAULT 0; +ALTER TABLE user_stats ADD COLUMN reminder_time TEXT DEFAULT '20:00'; +ALTER TABLE user_stats ADD COLUMN last_notified_date TEXT; diff --git a/api/src/config.rs b/api/src/config.rs index 6e94f86..f41704d 100644 --- a/api/src/config.rs +++ b/api/src/config.rs @@ -4,16 +4,25 @@ use sqlx::SqlitePool; pub struct AppState { pub db: SqlitePool, pub jwt_secret: String, + pub vapid_private_key_pem: String, + pub vapid_public_key: String, } impl AppState { - pub fn from_env(db: SqlitePool) -> Self { - let jwt_secret = - std::env::var("JWT_SECRET").unwrap_or_else(|_| "dev-secret-change-me".to_string()); - Self { db, jwt_secret } + pub fn new(db: SqlitePool, jwt_secret: String, vapid_pem: String, vapid_pub: String) -> Self { + Self { + db, + jwt_secret, + vapid_private_key_pem: vapid_pem, + vapid_public_key: vapid_pub, + } } } +pub fn jwt_secret() -> String { + std::env::var("JWT_SECRET").unwrap_or_else(|_| "dev-secret-change-me".to_string()) +} + pub fn database_url() -> String { std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:ayos.db".to_string()) } diff --git a/api/src/db.rs b/api/src/db.rs index cd953c7..cc5fa67 100644 --- a/api/src/db.rs +++ b/api/src/db.rs @@ -20,6 +20,7 @@ pub async fn run_migrations(pool: &SqlitePool) { include_str!("../migrations/002_create_progress.sql"), include_str!("../migrations/003_create_lesson_state.sql"), include_str!("../migrations/004_add_streak_freezes.sql"), + include_str!("../migrations/005_push_notifications.sql"), ]; for sql in &migrations { diff --git a/api/src/main.rs b/api/src/main.rs index 45f49e8..62301f2 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -4,6 +4,7 @@ mod db; mod errors; mod models; mod routes; +mod webpush; use axum::routing::{get, post, put}; use axum::Router; @@ -19,9 +20,13 @@ async fn main() { let pool = db::init_pool(&database_url).await; db::run_migrations(&pool).await; - let state = config::AppState::from_env(pool); + let (vapid_pem, vapid_pub) = webpush::init_vapid_keys(&pool).await; + let state = config::AppState::new(pool, config::jwt_secret(), vapid_pem, vapid_pub); let port = config::port(); + // Spawn background notification scheduler + tokio::spawn(notification_scheduler(state.clone())); + let cors = CorsLayer::new() .allow_origin(Any) .allow_methods([ @@ -53,6 +58,15 @@ async fn main() { .delete(routes::lesson_state::delete_lesson_state), ) .route("/api/tts", get(routes::tts::synthesize)) + .route("/api/push/vapid-key", get(routes::push::vapid_key)) + .route( + "/api/push/subscribe", + post(routes::push::subscribe).delete(routes::push::unsubscribe), + ) + .route( + "/api/push/preferences", + get(routes::push::get_preferences).put(routes::push::update_preferences), + ) .layer(cors) .with_state(state); @@ -72,3 +86,84 @@ async fn main() { axum::serve(listener, app).await.expect("Server error"); } + +async fn notification_scheduler(state: config::AppState) { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + loop { + interval.tick().await; + + let now = chrono::Utc::now(); + let current_time = now.format("%H:%M").to_string(); + let today = now.format("%Y-%m-%d").to_string(); + + // Find users who should receive a reminder now (haven't been notified today) + let users: Vec<(String,)> = match sqlx::query_as( + "SELECT user_id FROM user_stats + WHERE reminder_enabled = 1 AND reminder_time = ? + AND (last_notified_date IS NULL OR last_notified_date != ?)", + ) + .bind(¤t_time) + .bind(&today) + .fetch_all(&state.db) + .await + { + Ok(users) => users, + Err(e) => { + eprintln!("Notification scheduler error: {e}"); + continue; + } + }; + + for (user_id,) in &users { + let subs: Vec<(String, String, String)> = match sqlx::query_as( + "SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = ?", + ) + .bind(user_id) + .fetch_all(&state.db) + .await + { + Ok(s) => s, + Err(_) => continue, + }; + + let payload = serde_json::json!({ + "title": "Time to practice Tagalog!", + "body": "Keep your streak going!", + "data": { "url": "/home" } + }); + let payload_bytes = payload.to_string().into_bytes(); + + for (endpoint, p256dh, auth) in &subs { + match webpush::send_push( + &state.vapid_private_key_pem, + &state.vapid_public_key, + endpoint, + p256dh, + auth, + &payload_bytes, + ) + .await + { + Ok(()) => {} + Err(errors::AppError::NotFound(_)) => { + // Subscription expired, clean up + let _ = sqlx::query("DELETE FROM push_subscriptions WHERE endpoint = ?") + .bind(endpoint) + .execute(&state.db) + .await; + } + Err(e) => { + eprintln!("Failed to send push to {user_id}: {e}"); + } + } + } + + // Mark as notified today + let _ = sqlx::query("UPDATE user_stats SET last_notified_date = ? WHERE user_id = ?") + .bind(&today) + .bind(user_id) + .execute(&state.db) + .await; + } + } +} diff --git a/api/src/models.rs b/api/src/models.rs index 4b53657..15d6245 100644 --- a/api/src/models.rs +++ b/api/src/models.rs @@ -137,3 +137,26 @@ pub struct LessonStateResponse { pub hearts: i32, pub correct_count: i32, } + +// --------------------------------------------------------------------------- +// Push notification DTOs +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub struct PushSubscribeRequest { + pub endpoint: String, + pub p256dh: String, + pub auth: String, +} + +#[derive(Debug, Deserialize)] +pub struct PushPreferencesRequest { + pub reminder_enabled: bool, + pub reminder_time: String, +} + +#[derive(Debug, Serialize)] +pub struct PushPreferencesResponse { + pub reminder_enabled: bool, + pub reminder_time: String, +} diff --git a/api/src/routes/mod.rs b/api/src/routes/mod.rs index 3392339..6a982d5 100644 --- a/api/src/routes/mod.rs +++ b/api/src/routes/mod.rs @@ -1,4 +1,5 @@ pub mod auth; pub mod lesson_state; pub mod progress; +pub mod push; pub mod tts; diff --git a/api/src/routes/push.rs b/api/src/routes/push.rs new file mode 100644 index 0000000..badef9b --- /dev/null +++ b/api/src/routes/push.rs @@ -0,0 +1,92 @@ +use axum::extract::State; +use axum::Json; +use serde_json::json; + +use crate::auth::AuthUser; +use crate::config::AppState; +use crate::errors::AppError; +use crate::models::{PushPreferencesRequest, PushPreferencesResponse, PushSubscribeRequest}; + +/// GET /api/push/vapid-key +pub async fn vapid_key(State(state): State) -> Json { + Json(json!({ "vapid_public_key": state.vapid_public_key })) +} + +/// POST /api/push/subscribe +pub async fn subscribe( + AuthUser(user_id): AuthUser, + State(state): State, + Json(req): Json, +) -> Result, AppError> { + let id = uuid::Uuid::new_v4().to_string(); + + sqlx::query( + "INSERT INTO push_subscriptions (id, user_id, endpoint, p256dh, auth) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(endpoint) DO UPDATE SET p256dh = excluded.p256dh, auth = excluded.auth, user_id = excluded.user_id", + ) + .bind(&id) + .bind(&user_id) + .bind(&req.endpoint) + .bind(&req.p256dh) + .bind(&req.auth) + .execute(&state.db) + .await?; + + Ok(Json(json!({ "ok": true }))) +} + +/// DELETE /api/push/subscribe +pub async fn unsubscribe( + AuthUser(user_id): AuthUser, + State(state): State, +) -> Result, AppError> { + sqlx::query("DELETE FROM push_subscriptions WHERE user_id = ?") + .bind(&user_id) + .execute(&state.db) + .await?; + + Ok(Json(json!({ "ok": true }))) +} + +/// GET /api/push/preferences +pub async fn get_preferences( + AuthUser(user_id): AuthUser, + State(state): State, +) -> Result, AppError> { + let row = sqlx::query_as::<_, (i32, String)>( + "SELECT COALESCE(reminder_enabled, 0), COALESCE(reminder_time, '20:00') FROM user_stats WHERE user_id = ?", + ) + .bind(&user_id) + .fetch_optional(&state.db) + .await?; + + match row { + Some((enabled, time)) => Ok(Json(PushPreferencesResponse { + reminder_enabled: enabled != 0, + reminder_time: time, + })), + None => Ok(Json(PushPreferencesResponse { + reminder_enabled: false, + reminder_time: "20:00".to_string(), + })), + } +} + +/// PUT /api/push/preferences +pub async fn update_preferences( + AuthUser(user_id): AuthUser, + State(state): State, + Json(req): Json, +) -> Result, AppError> { + let enabled = i32::from(req.reminder_enabled); + + sqlx::query("UPDATE user_stats SET reminder_enabled = ?, reminder_time = ? WHERE user_id = ?") + .bind(enabled) + .bind(&req.reminder_time) + .bind(&user_id) + .execute(&state.db) + .await?; + + Ok(Json(json!({ "ok": true }))) +} diff --git a/api/src/webpush.rs b/api/src/webpush.rs new file mode 100644 index 0000000..f05976f --- /dev/null +++ b/api/src/webpush.rs @@ -0,0 +1,139 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; +use serde::Serialize; + +use crate::errors::AppError; + +#[derive(Debug, Serialize)] +struct VapidClaims { + aud: String, + exp: usize, + sub: String, +} + +/// Send a push notification to a subscriber. +pub async fn send_push( + private_key_pem: &str, + public_key_b64url: &str, + endpoint: &str, + p256dh_b64url: &str, + auth_b64url: &str, + payload: &[u8], +) -> Result<(), AppError> { + // Decode subscriber keys + let p256dh = URL_SAFE_NO_PAD + .decode(p256dh_b64url) + .map_err(|e| AppError::Internal(format!("Invalid p256dh: {e}")))?; + let auth = URL_SAFE_NO_PAD + .decode(auth_b64url) + .map_err(|e| AppError::Internal(format!("Invalid auth: {e}")))?; + + // Encrypt payload using RFC 8291 (aes128gcm) + let encrypted = ece::encrypt(&p256dh, &auth, payload) + .map_err(|e| AppError::Internal(format!("Encryption failed: {e}")))?; + + // Build VAPID JWT + let origin = get_origin(endpoint); + let exp = (chrono::Utc::now().timestamp() + 86400) as usize; + let claims = VapidClaims { + aud: origin, + exp, + sub: "mailto:admin@ayos.app".to_string(), + }; + let header = Header::new(Algorithm::ES256); + let key = EncodingKey::from_ec_pem(private_key_pem.as_bytes()) + .map_err(|e| AppError::Internal(format!("Invalid VAPID key: {e}")))?; + let jwt = encode(&header, &claims, &key) + .map_err(|e| AppError::Internal(format!("JWT signing failed: {e}")))?; + + // Send HTTP request to push service + let client = reqwest::Client::new(); + let response = client + .post(endpoint) + .header("TTL", "86400") + .header("Content-Encoding", "aes128gcm") + .header( + "Authorization", + format!("vapid t={jwt}, k={public_key_b64url}"), + ) + .header("Content-Type", "application/octet-stream") + .body(encrypted) + .send() + .await + .map_err(|e| AppError::Internal(format!("Push send failed: {e}")))?; + + if response.status().as_u16() == 410 || response.status().as_u16() == 404 { + return Err(AppError::NotFound("Subscription expired".to_string())); + } + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(AppError::Internal(format!( + "Push service returned {status}: {body}" + ))); + } + + Ok(()) +} + +fn get_origin(url: &str) -> String { + if let Some(idx) = url.find("://") { + let rest = &url[idx + 3..]; + if let Some(path_idx) = rest.find('/') { + url[..idx + 3 + path_idx].to_string() + } else { + url.to_string() + } + } else { + url.to_string() + } +} + +/// Generate a new VAPID key pair. +/// Returns (private_key_pem, public_key_base64url). +pub fn generate_vapid_keys() -> Result<(String, String), AppError> { + use p256::elliptic_curve::sec1::ToEncodedPoint; + use p256::pkcs8::EncodePrivateKey; + use p256::SecretKey; + + let secret_key = SecretKey::random(&mut rand::rngs::OsRng); + + let pem = secret_key + .to_pkcs8_pem(p256::pkcs8::LineEnding::LF) + .map_err(|e| AppError::Internal(format!("Key generation failed: {e}")))?; + + let public_key = secret_key.public_key(); + let point = public_key.to_encoded_point(false); + let public_key_b64url = URL_SAFE_NO_PAD.encode(point.as_bytes()); + + Ok((pem.to_string(), public_key_b64url)) +} + +/// Load or generate VAPID keys from the database. +pub async fn init_vapid_keys(pool: &sqlx::SqlitePool) -> (String, String) { + let existing = sqlx::query_as::<_, (String, String)>( + "SELECT private_key_pem, public_key_base64url FROM vapid_keys WHERE id = 1", + ) + .fetch_optional(pool) + .await + .expect("Failed to query vapid_keys"); + + if let Some((pem, pub_key)) = existing { + return (pem, pub_key); + } + + let (pem, pub_key) = generate_vapid_keys().expect("VAPID key generation failed"); + + sqlx::query( + "INSERT INTO vapid_keys (id, private_key_pem, public_key_base64url) VALUES (1, ?, ?)", + ) + .bind(&pem) + .bind(&pub_key) + .execute(pool) + .await + .expect("Failed to store VAPID keys"); + + println!("Generated new VAPID keys"); + (pem, pub_key) +} diff --git a/web/public/push-sw.js b/web/public/push-sw.js new file mode 100644 index 0000000..b0766c8 --- /dev/null +++ b/web/public/push-sw.js @@ -0,0 +1,41 @@ +// Push notification event handlers for the service worker. +// This file is imported via workbox's importScripts option. + +self.addEventListener('push', function (event) { + var data = {}; + if (event.data) { + try { + data = event.data.json(); + } catch (e) { + data = { body: event.data.text() }; + } + } + + var title = data.title || 'Ayos'; + var options = { + body: data.body || 'Time to practice Tagalog!', + icon: '/pwa-192x192.png', + badge: '/pwa-192x192.png', + data: data.data || { url: '/home' }, + }; + + event.waitUntil(self.registration.showNotification(title, options)); +}); + +self.addEventListener('notificationclick', function (event) { + event.notification.close(); + + var targetUrl = (event.notification.data && event.notification.data.url) || '/home'; + + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (clientList) { + for (var i = 0; i < clientList.length; i++) { + var client = clientList[i]; + if ('focus' in client) { + return client.focus(); + } + } + return self.clients.openWindow(targetUrl); + }) + ); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 85626d3..a99c056 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -11,6 +11,7 @@ import { RegisterPage } from './pages/RegisterPage'; import { HomePage } from './pages/HomePage'; import { LessonPage } from './pages/LessonPage'; import { ReviewPage } from './pages/ReviewPage'; +import { SettingsPage } from './pages/SettingsPage'; import type { ReactNode } from 'react'; function ProtectedRoute({ children }: { children: ReactNode }) { @@ -80,6 +81,7 @@ function App() { element={} /> } /> + } /> diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx index 6e977fc..d0b5b3e 100644 --- a/web/src/components/layout/AppShell.tsx +++ b/web/src/components/layout/AppShell.tsx @@ -4,7 +4,8 @@ import Typography from '@mui/material/Typography'; import Box from '@mui/material/Box'; import IconButton from '@mui/material/IconButton'; import LogoutIcon from '@mui/icons-material/Logout'; -import { Outlet } from 'react-router-dom'; +import SettingsIcon from '@mui/icons-material/Settings'; +import { Outlet, useNavigate } from 'react-router-dom'; import { useAuth } from '../../hooks/useAuth'; import { useProgress } from '../../hooks/useProgress'; import { XPDisplay } from '../common/XPDisplay'; @@ -13,6 +14,7 @@ import LocalFireDepartmentIcon from '@mui/icons-material/LocalFireDepartment'; export function AppShell() { const { logout } = useAuth(); + const navigate = useNavigate(); const { progress } = useProgress(); const stats = progress?.stats; @@ -58,6 +60,13 @@ export function AppShell() { )} + navigate('/settings')} + size="small" + sx={{ color: 'text.secondary' }} + > + + ( + typeof window !== 'undefined' && 'Notification' in window + ? Notification.permission + : 'denied', + ); + const [preferences, setPreferences] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + apiClient + .get('/api/push/preferences') + .then(setPreferences) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const subscribe = useCallback(async () => { + if (!('Notification' in window) || !('serviceWorker' in navigator)) return; + + const perm = await Notification.requestPermission(); + setPermission(perm); + if (perm !== 'granted') return; + + const { vapid_public_key } = await apiClient.get<{ vapid_public_key: string }>( + '/api/push/vapid-key', + ); + + const applicationServerKey = urlBase64ToUint8Array(vapid_public_key); + + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey, + }); + + const json = subscription.toJSON(); + await apiClient.post('/api/push/subscribe', { + endpoint: json.endpoint, + p256dh: json.keys?.p256dh, + auth: json.keys?.auth, + }); + }, []); + + const unsubscribe = useCallback(async () => { + if (!('serviceWorker' in navigator)) return; + + try { + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + if (subscription) { + await subscription.unsubscribe(); + } + } catch { + // ignore errors + } + await apiClient.delete('/api/push/subscribe'); + }, []); + + const updatePreferences = useCallback(async (prefs: PushPreferences) => { + await apiClient.put('/api/push/preferences', prefs); + setPreferences(prefs); + }, []); + + return { permission, preferences, loading, subscribe, unsubscribe, updatePreferences }; +} diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..a2ea995 --- /dev/null +++ b/web/src/pages/SettingsPage.tsx @@ -0,0 +1,147 @@ +import { useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Switch from '@mui/material/Switch'; +import TextField from '@mui/material/TextField'; +import CircularProgress from '@mui/material/CircularProgress'; +import Alert from '@mui/material/Alert'; +import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive'; +import { + usePushNotifications, + localTimeToUtc, + utcTimeToLocal, +} from '../hooks/usePushNotifications'; + +export function SettingsPage() { + const { + permission, + preferences, + loading, + subscribe, + unsubscribe, + updatePreferences, + } = usePushNotifications(); + const [saving, setSaving] = useState(false); + + if (loading) { + return ( + + + + ); + } + + const notificationsSupported = + typeof window !== 'undefined' && + 'Notification' in window && + 'serviceWorker' in navigator; + + const reminderEnabled = preferences?.reminder_enabled ?? false; + const reminderTimeLocal = preferences?.reminder_time + ? utcTimeToLocal(preferences.reminder_time) + : '20:00'; + + const handleToggle = async () => { + setSaving(true); + try { + if (!reminderEnabled) { + await subscribe(); + await updatePreferences({ + reminder_enabled: true, + reminder_time: preferences?.reminder_time || localTimeToUtc('20:00'), + }); + } else { + await unsubscribe(); + await updatePreferences({ + reminder_enabled: false, + reminder_time: preferences?.reminder_time || localTimeToUtc('20:00'), + }); + } + } catch { + // ignore + } + setSaving(false); + }; + + const handleTimeChange = async (newLocalTime: string) => { + setSaving(true); + try { + await updatePreferences({ + reminder_enabled: true, + reminder_time: localTimeToUtc(newLocalTime), + }); + } catch { + // ignore + } + setSaving(false); + }; + + return ( + + + Settings + + + + + + + Daily Reminder + + + + {!notificationsSupported && ( + + Push notifications are not supported in this browser. + + )} + + {notificationsSupported && permission === 'denied' && ( + + Notifications are blocked. Please enable them in your browser settings. + + )} + + + + Enable daily reminder + + + + + {reminderEnabled && ( + + handleTimeChange(e.target.value)} + disabled={saving} + fullWidth + slotProps={{ + inputLabel: { shrink: true }, + }} + /> + + )} + + + ); +} diff --git a/web/vite.config.ts b/web/vite.config.ts index 923feec..956edea 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -38,6 +38,7 @@ export default defineConfig({ }, workbox: { globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], + importScripts: ['/push-sw.js'], }, }), ],