From 4bba182ea3538ce069036f384284066c82aaa62e Mon Sep 17 00:00:00 2001 From: maulanasdqn Date: Thu, 2 Apr 2026 16:03:39 +0700 Subject: [PATCH] feat: migrate imphnen-backend-qr into workspace as imphnen-qr crate Ports the Go QR campaign overlay service to a self-contained Rust crate nested at /v1/qr/... in the gateway. Features: - Auth: register, login, Google OAuth, JWT refresh (bcrypt compat with Go DB) - Users: profile management + admin CRUD (list/role/delete) - Campaigns: create (auto-generates QR PNG via qrcode crate), list, activate, delete; process-image endpoint overlays active campaign QR onto uploaded images (bottom-right corner, image crate) - QR pool connects to imphnen_qr database via QR_DATABASE_URL Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 9 + Cargo.lock | 678 ++++++++++++++++++ Cargo.toml | 5 + imphnen-gateway/Cargo.toml | 2 + imphnen-gateway/src/lib.rs | 10 + imphnen-qr/Cargo.toml | 24 + .../src/auth/application/auth_service.rs | 154 ++++ imphnen-qr/src/auth/application/mod.rs | 1 + imphnen-qr/src/auth/domain/mod.rs | 1 + imphnen-qr/src/auth/domain/service.rs | 30 + .../src/auth/infrastructure/http/dto.rs | 34 + .../src/auth/infrastructure/http/handlers.rs | 72 ++ .../src/auth/infrastructure/http/mod.rs | 3 + .../src/auth/infrastructure/http/routes.rs | 29 + imphnen-qr/src/auth/infrastructure/mod.rs | 2 + .../auth/infrastructure/persistence/mod.rs | 1 + imphnen-qr/src/auth/mod.rs | 3 + .../campaigns/application/campaign_service.rs | 88 +++ imphnen-qr/src/campaigns/application/mod.rs | 1 + imphnen-qr/src/campaigns/domain/entity.rs | 24 + imphnen-qr/src/campaigns/domain/mod.rs | 3 + imphnen-qr/src/campaigns/domain/repository.rs | 14 + imphnen-qr/src/campaigns/domain/service.rs | 15 + .../src/campaigns/infrastructure/http/dto.rs | 22 + .../campaigns/infrastructure/http/handlers.rs | 85 +++ .../src/campaigns/infrastructure/http/mod.rs | 3 + .../campaigns/infrastructure/http/routes.rs | 38 + .../src/campaigns/infrastructure/mod.rs | 2 + .../infrastructure/persistence/mod.rs | 1 + .../postgres_campaign_repository.rs | 107 +++ imphnen-qr/src/campaigns/mod.rs | 4 + imphnen-qr/src/common/mod.rs | 1 + imphnen-qr/src/common/qr_jwt.rs | 59 ++ imphnen-qr/src/config.rs | 30 + imphnen-qr/src/lib.rs | 26 + imphnen-qr/src/middleware/mod.rs | 1 + imphnen-qr/src/middleware/qr_auth.rs | 39 + imphnen-qr/src/users/application/mod.rs | 1 + .../src/users/application/user_service.rs | 51 ++ imphnen-qr/src/users/domain/entity.rs | 21 + imphnen-qr/src/users/domain/mod.rs | 3 + imphnen-qr/src/users/domain/repository.rs | 14 + imphnen-qr/src/users/domain/service.rs | 14 + .../src/users/infrastructure/http/dto.rs | 22 + .../src/users/infrastructure/http/handlers.rs | 73 ++ .../src/users/infrastructure/http/mod.rs | 3 + .../src/users/infrastructure/http/routes.rs | 38 + imphnen-qr/src/users/infrastructure/mod.rs | 2 + .../users/infrastructure/persistence/mod.rs | 1 + .../persistence/postgres_user_repository.rs | 74 ++ imphnen-qr/src/users/mod.rs | 4 + 51 files changed, 1942 insertions(+) create mode 100644 imphnen-qr/Cargo.toml create mode 100644 imphnen-qr/src/auth/application/auth_service.rs create mode 100644 imphnen-qr/src/auth/application/mod.rs create mode 100644 imphnen-qr/src/auth/domain/mod.rs create mode 100644 imphnen-qr/src/auth/domain/service.rs create mode 100644 imphnen-qr/src/auth/infrastructure/http/dto.rs create mode 100644 imphnen-qr/src/auth/infrastructure/http/handlers.rs create mode 100644 imphnen-qr/src/auth/infrastructure/http/mod.rs create mode 100644 imphnen-qr/src/auth/infrastructure/http/routes.rs create mode 100644 imphnen-qr/src/auth/infrastructure/mod.rs create mode 100644 imphnen-qr/src/auth/infrastructure/persistence/mod.rs create mode 100644 imphnen-qr/src/auth/mod.rs create mode 100644 imphnen-qr/src/campaigns/application/campaign_service.rs create mode 100644 imphnen-qr/src/campaigns/application/mod.rs create mode 100644 imphnen-qr/src/campaigns/domain/entity.rs create mode 100644 imphnen-qr/src/campaigns/domain/mod.rs create mode 100644 imphnen-qr/src/campaigns/domain/repository.rs create mode 100644 imphnen-qr/src/campaigns/domain/service.rs create mode 100644 imphnen-qr/src/campaigns/infrastructure/http/dto.rs create mode 100644 imphnen-qr/src/campaigns/infrastructure/http/handlers.rs create mode 100644 imphnen-qr/src/campaigns/infrastructure/http/mod.rs create mode 100644 imphnen-qr/src/campaigns/infrastructure/http/routes.rs create mode 100644 imphnen-qr/src/campaigns/infrastructure/mod.rs create mode 100644 imphnen-qr/src/campaigns/infrastructure/persistence/mod.rs create mode 100644 imphnen-qr/src/campaigns/infrastructure/persistence/postgres_campaign_repository.rs create mode 100644 imphnen-qr/src/campaigns/mod.rs create mode 100644 imphnen-qr/src/common/mod.rs create mode 100644 imphnen-qr/src/common/qr_jwt.rs create mode 100644 imphnen-qr/src/config.rs create mode 100644 imphnen-qr/src/lib.rs create mode 100644 imphnen-qr/src/middleware/mod.rs create mode 100644 imphnen-qr/src/middleware/qr_auth.rs create mode 100644 imphnen-qr/src/users/application/mod.rs create mode 100644 imphnen-qr/src/users/application/user_service.rs create mode 100644 imphnen-qr/src/users/domain/entity.rs create mode 100644 imphnen-qr/src/users/domain/mod.rs create mode 100644 imphnen-qr/src/users/domain/repository.rs create mode 100644 imphnen-qr/src/users/domain/service.rs create mode 100644 imphnen-qr/src/users/infrastructure/http/dto.rs create mode 100644 imphnen-qr/src/users/infrastructure/http/handlers.rs create mode 100644 imphnen-qr/src/users/infrastructure/http/mod.rs create mode 100644 imphnen-qr/src/users/infrastructure/http/routes.rs create mode 100644 imphnen-qr/src/users/infrastructure/mod.rs create mode 100644 imphnen-qr/src/users/infrastructure/persistence/mod.rs create mode 100644 imphnen-qr/src/users/infrastructure/persistence/postgres_user_repository.rs create mode 100644 imphnen-qr/src/users/mod.rs diff --git a/.env.example b/.env.example index 9aecdb3..78ff467 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,15 @@ RETRY_ATTEMPTS=3 RETRY_DELAY=1 GOOGLE_REDIRECT_URL=http://localhost:8000/api/v1/auth/google/callback +# QR campaign service +QR_DATABASE_URL=postgres://imphnen_qr@127.0.0.1:5432/imphnen_qr?sslmode=disable +QR_JWT_SECRET=your-qr-jwt-secret-at-least-32-chars +QR_JWT_EXPIRY_MINUTES=15 +QR_JWT_REFRESH_EXPIRY_DAYS=7 +QR_GOOGLE_CLIENT_ID=your-google-client-id +QR_GOOGLE_CLIENT_SECRET=your-google-client-secret +QR_GOOGLE_REDIRECT_URL=http://localhost:8080/v1/qr/auth/google/callback + # Hackathon feature HACKATHON_JWT_SECRET=your-hackathon-jwt-secret-at-least-32-chars HACKATHON_JWT_EXPIRY_HOURS=168 diff --git a/Cargo.lock b/Cargo.lock index 1a06f41..4dc7a4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,24 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -135,6 +153,17 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "argon2" version = "0.5.3" @@ -153,6 +182,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -223,6 +261,49 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.17", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" +dependencies = [ + "arrayvec", +] + [[package]] name = "axum" version = "0.8.7" @@ -353,6 +434,19 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "bcrypt" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7" +dependencies = [ + "base64", + "blowfish", + "getrandom 0.2.16", + "subtle", + "zeroize", +] + [[package]] name = "bigdecimal" version = "0.4.9" @@ -367,6 +461,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "2.10.0" @@ -376,6 +476,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitstream-io" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +dependencies = [ + "core2", +] + [[package]] name = "bitvec" version = "1.0.1" @@ -406,6 +515,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "borsh" version = "1.5.7" @@ -429,6 +548,12 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" + [[package]] name = "bumpalo" version = "3.19.0" @@ -457,12 +582,24 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.0" @@ -482,6 +619,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -521,6 +660,22 @@ dependencies = [ "stacker", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.4" @@ -568,6 +723,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -601,6 +765,25 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -616,6 +799,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.6" @@ -777,6 +966,26 @@ dependencies = [ "log", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -815,12 +1024,56 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.5" @@ -1034,6 +1287,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "h2" version = "0.4.12" @@ -1053,6 +1316,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1437,6 +1711,46 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + [[package]] name = "imphnen-backend" version = "0.2.0" @@ -1608,12 +1922,14 @@ dependencies = [ "imphnen-iam", "imphnen-libs", "imphnen-middleware", + "imphnen-qr", "imphnen-utils", "lazy_static", "rand 0.9.2", "regex", "serde", "serde_json", + "sqlx", "tokio", "tower-http", "utoipa", @@ -1760,6 +2076,30 @@ dependencies = [ "uuid", ] +[[package]] +name = "imphnen-qr" +version = "0.2.0" +dependencies = [ + "async-trait", + "axum", + "axum-extra", + "bcrypt", + "chrono", + "image", + "imphnen-utils", + "jsonwebtoken", + "oauth2", + "qrcode", + "reqwest", + "serde", + "serde_json", + "sqlx", + "tokio", + "tracing", + "utoipa", + "uuid", +] + [[package]] name = "imphnen-utils" version = "0.2.0" @@ -1814,6 +2154,26 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1836,6 +2196,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -1866,6 +2235,16 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.82" @@ -1900,6 +2279,12 @@ dependencies = [ "spin", ] +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "lettre" version = "0.11.19" @@ -1934,6 +2319,16 @@ version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libm" version = "0.2.15" @@ -1997,6 +2392,15 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2018,6 +2422,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "md-5" version = "0.10.6" @@ -2097,6 +2511,16 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "multer" version = "3.1.0" @@ -2131,6 +2555,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nom" version = "8.0.0" @@ -2140,6 +2570,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2181,6 +2617,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -2201,6 +2648,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2430,6 +2888,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pem" version = "3.0.6" @@ -2503,6 +2973,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "portable-atomic" version = "1.11.1" @@ -2631,6 +3114,25 @@ dependencies = [ "yansi", ] +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn 2.0.111", +] + [[package]] name = "psm" version = "0.1.28" @@ -2661,6 +3163,36 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +dependencies = [ + "image", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quinn" version = "0.11.9" @@ -2812,6 +3344,76 @@ dependencies = [ "rand 0.9.2", ] +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.2", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.17", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2912,6 +3514,12 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "ring" version = "0.17.14" @@ -3376,6 +3984,15 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -3852,6 +4469,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.44" @@ -4275,6 +4906,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -4420,6 +5062,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "whoami" version = "1.6.1" @@ -4776,6 +5424,12 @@ dependencies = [ "tap", ] +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yansi" version = "1.0.1" @@ -4950,3 +5604,27 @@ dependencies = [ "log", "simd-adler32", ] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 3f84827..a28ff7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "imphnen-gacha", # Game mechanics, depends on core services "imphnen-dimentorin",# Learning platform, depends on core services "imphnen-hackathon", # Hackathon feature, standalone with Supabase auth + "imphnen-qr", # QR campaign overlay service "imphnen-gateway", # API gateway, depends on all services "imphnen-backend", # Main application, depends on all services ] @@ -89,6 +90,10 @@ imphnen-dimentorin = { path = "./imphnen-dimentorin" } imphnen-middleware = { path = "./imphnen-middleware" } imphnen-macros = { path = "./imphnen-macros" } imphnen-hackathon = { path = "./imphnen-hackathon" } +imphnen-qr = { path = "./imphnen-qr" } +bcrypt = "0.15" +image = { version = "0.25", features = ["png", "jpeg"] } +qrcode = { version = "0.14", default-features = false, features = ["image"] } [profile.release] lto = "fat" diff --git a/imphnen-gateway/Cargo.toml b/imphnen-gateway/Cargo.toml index a06b663..4d600a7 100644 --- a/imphnen-gateway/Cargo.toml +++ b/imphnen-gateway/Cargo.toml @@ -5,6 +5,8 @@ edition = "2024" [dependencies] imphnen-hackathon.workspace = true +imphnen-qr.workspace = true +sqlx.workspace = true imphnen-iam.workspace = true imphnen-libs.workspace = true imphnen-utils.workspace = true diff --git a/imphnen-gateway/src/lib.rs b/imphnen-gateway/src/lib.rs index 75cf126..0ea742b 100644 --- a/imphnen-gateway/src/lib.rs +++ b/imphnen-gateway/src/lib.rs @@ -17,6 +17,7 @@ use imphnen_dimentorin::{ }; use imphnen_gacha::gacha_router; use imphnen_hackathon::{hackathon_router, HackathonConfig}; +use imphnen_qr::{qr_router, QrConfig}; use imphnen_iam::{ auth_public_routes, permissions_protected_routes, @@ -45,6 +46,14 @@ pub async fn gateway_service( let db = state.postgres_connection.conn.clone(); let state_arc = Arc::new(state.clone()); let hackathon_config = Arc::new(HackathonConfig::from_env()); + let qr_config = Arc::new(QrConfig::from_env()); + let qr_pool = Arc::new( + sqlx::PgPool::connect( + &std::env::var("QR_DATABASE_URL").expect("QR_DATABASE_URL must be set"), + ) + .await + .expect("Failed to connect to QR database"), + ); let public_routes = Router::new() .merge(auth_public_routes(db.clone(), Arc::clone(&state_arc)).layer(from_fn(rate_limiting_middleware))) @@ -68,6 +77,7 @@ pub async fn gateway_service( .route("/", get(Redirect::to("/docs"))) .nest("/v1", public_routes.merge(protected_routes)) .nest("/v1/hackathon", hackathon_router(db.clone(), hackathon_config)) + .nest("/v1/qr", qr_router(qr_pool, qr_config)) .merge(SwaggerUi::new("/docs").url("/openapi.json", docs_router())) .layer(cors_middleware()) .layer(from_fn(security_headers_middleware)) diff --git a/imphnen-qr/Cargo.toml b/imphnen-qr/Cargo.toml new file mode 100644 index 0000000..7cb4b0d --- /dev/null +++ b/imphnen-qr/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "imphnen-qr" +version = "0.2.0" +edition = "2024" + +[dependencies] +imphnen-utils.workspace = true +axum.workspace = true +axum-extra.workspace = true +async-trait.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +jsonwebtoken.workspace = true +bcrypt.workspace = true +chrono.workspace = true +uuid.workspace = true +sqlx.workspace = true +reqwest.workspace = true +oauth2.workspace = true +tracing.workspace = true +utoipa.workspace = true +image.workspace = true +qrcode.workspace = true diff --git a/imphnen-qr/src/auth/application/auth_service.rs b/imphnen-qr/src/auth/application/auth_service.rs new file mode 100644 index 0000000..1284af5 --- /dev/null +++ b/imphnen-qr/src/auth/application/auth_service.rs @@ -0,0 +1,154 @@ +use std::sync::Arc; +use uuid::Uuid; +use sqlx::PgPool; +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use crate::common::qr_jwt::QrJwtService; +use crate::config::QrConfig; +use super::super::domain::service::{QrAuthService, AuthTokens, QrUserData}; + +pub struct QrAuthServiceImpl { + pool: Arc, + jwt: Arc, + config: Arc, +} + +impl QrAuthServiceImpl { + pub fn new(pool: Arc, jwt: Arc, config: Arc) -> Self { + Self { pool, jwt, config } + } + + async fn find_user_by_id(&self, id: Uuid) -> Result { + sqlx::query_as::<_, QrUserData>( + "SELECT id, email, name, role, provider, created_at, updated_at FROM users WHERE id = $1" + ) + .bind(id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("User not found".to_string())) + } + + async fn find_user_by_email(&self, email: &str) -> Result, AppError> { + sqlx::query_scalar::<_, serde_json::Value>( + "SELECT row_to_json(u) FROM (SELECT id, email, name, role, provider, password FROM users WHERE email = $1) u" + ) + .bind(email) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + } + + fn make_tokens(&self, user_id: Uuid, role: &str) -> Result { + Ok(AuthTokens { + access_token: self.jwt.generate_token(user_id, role)?, + refresh_token: self.jwt.generate_refresh_token(user_id, role)?, + }) + } +} + +#[async_trait] +impl QrAuthService for QrAuthServiceImpl { + async fn register(&self, email: String, password: String, name: String) -> Result<(AuthTokens, QrUserData), AppError> { + let existing = self.find_user_by_email(&email).await?; + if existing.is_some() { + return Err(AppError::ConflictError("Email already registered".to_string())); + } + let hashed = bcrypt::hash(&password, 10) + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let user = sqlx::query_as::<_, QrUserData>( + "INSERT INTO users (email, password, name, role, provider) VALUES ($1, $2, $3, 'user', 'local') RETURNING id, email, name, role, provider, created_at, updated_at" + ) + .bind(&email) + .bind(&hashed) + .bind(&name) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let tokens = self.make_tokens(user.id, &user.role)?; + Ok((tokens, user)) + } + + async fn login(&self, email: String, password: String) -> Result<(AuthTokens, QrUserData), AppError> { + let row = self.find_user_by_email(&email).await? + .ok_or_else(|| AppError::AuthenticationError("Invalid credentials".to_string()))?; + let provider = row["provider"].as_str().unwrap_or("local"); + if provider != "local" { + return Err(AppError::AuthenticationError("Account uses social login".to_string())); + } + let stored_hash = row["password"].as_str() + .ok_or_else(|| AppError::AuthenticationError("Invalid credentials".to_string()))?; + let valid = bcrypt::verify(&password, stored_hash) + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + if !valid { + return Err(AppError::AuthenticationError("Invalid credentials".to_string())); + } + let user_id: Uuid = row["id"].as_str() + .and_then(|s| Uuid::parse_str(s).ok()) + .ok_or_else(|| AppError::InternalServerError("Invalid user ID".to_string()))?; + let user = self.find_user_by_id(user_id).await?; + let tokens = self.make_tokens(user.id, &user.role)?; + Ok((tokens, user)) + } + + async fn google_callback(&self, code: String) -> Result<(AuthTokens, QrUserData), AppError> { + let http = reqwest::Client::new(); + let token_res: serde_json::Value = http + .post("https://oauth2.googleapis.com/token") + .form(&[ + ("code", code.as_str()), + ("client_id", self.config.google_client_id.as_str()), + ("client_secret", self.config.google_client_secret.as_str()), + ("redirect_uri", self.config.google_redirect_url.as_str()), + ("grant_type", "authorization_code"), + ]) + .send() + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .json() + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + if token_res.get("error").is_some() { + return Err(AppError::BadRequestError("Google OAuth error".to_string())); + } + let access_token = token_res["access_token"].as_str() + .ok_or_else(|| AppError::InternalServerError("Missing access token from Google".to_string()))?; + let google_user: serde_json::Value = http + .get("https://www.googleapis.com/oauth2/v2/userinfo") + .header("Authorization", format!("Bearer {}", access_token)) + .send() + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .json() + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let email = google_user["email"].as_str() + .ok_or_else(|| AppError::InternalServerError("Missing email from Google".to_string()))?; + let name = google_user["name"].as_str().unwrap_or(email); + let provider_id = google_user["id"].as_str().unwrap_or(""); + let user = sqlx::query_as::<_, QrUserData>( + "INSERT INTO users (email, name, role, provider, provider_id) VALUES ($1, $2, 'user', 'google', $3) + ON CONFLICT (email) DO UPDATE SET provider_id = EXCLUDED.provider_id, updated_at = NOW() + RETURNING id, email, name, role, provider, created_at, updated_at" + ) + .bind(email) + .bind(name) + .bind(provider_id) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let tokens = self.make_tokens(user.id, &user.role)?; + Ok((tokens, user)) + } + + async fn refresh_token(&self, refresh_token: String) -> Result { + let claims = self.jwt.verify_token(&refresh_token)?; + let user_id = Uuid::parse_str(&claims.sub) + .map_err(|_| AppError::AuthenticationError("Invalid token subject".to_string()))?; + let user = self.find_user_by_id(user_id).await?; + Ok(AuthTokens { + access_token: self.jwt.generate_token(user.id, &user.role)?, + refresh_token, + }) + } +} diff --git a/imphnen-qr/src/auth/application/mod.rs b/imphnen-qr/src/auth/application/mod.rs new file mode 100644 index 0000000..3fe88a6 --- /dev/null +++ b/imphnen-qr/src/auth/application/mod.rs @@ -0,0 +1 @@ +pub mod auth_service; diff --git a/imphnen-qr/src/auth/domain/mod.rs b/imphnen-qr/src/auth/domain/mod.rs new file mode 100644 index 0000000..1f278a4 --- /dev/null +++ b/imphnen-qr/src/auth/domain/mod.rs @@ -0,0 +1 @@ +pub mod service; diff --git a/imphnen-qr/src/auth/domain/service.rs b/imphnen-qr/src/auth/domain/service.rs new file mode 100644 index 0000000..4e41233 --- /dev/null +++ b/imphnen-qr/src/auth/domain/service.rs @@ -0,0 +1,30 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; +use imphnen_utils::errors::AppError; + +#[derive(Debug, Serialize, Deserialize)] +pub struct AuthTokens { + pub access_token: String, + pub refresh_token: String, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema, sqlx::FromRow)] +pub struct QrUserData { + pub id: Uuid, + pub email: String, + pub name: String, + pub role: String, + pub provider: String, + pub created_at: Option>, + pub updated_at: Option>, +} + +#[async_trait] +pub trait QrAuthService: Send + Sync { + async fn register(&self, email: String, password: String, name: String) -> Result<(AuthTokens, QrUserData), AppError>; + async fn login(&self, email: String, password: String) -> Result<(AuthTokens, QrUserData), AppError>; + async fn google_callback(&self, code: String) -> Result<(AuthTokens, QrUserData), AppError>; + async fn refresh_token(&self, refresh_token: String) -> Result; +} diff --git a/imphnen-qr/src/auth/infrastructure/http/dto.rs b/imphnen-qr/src/auth/infrastructure/http/dto.rs new file mode 100644 index 0000000..0cc06f7 --- /dev/null +++ b/imphnen-qr/src/auth/infrastructure/http/dto.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use crate::auth::domain::service::QrUserData; + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct RegisterRequest { + pub email: String, + pub password: String, + pub name: String, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct LoginRequest { + pub email: String, + pub password: String, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct RefreshRequest { + pub refresh_token: String, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct AuthResponse { + pub access_token: String, + pub refresh_token: String, + pub user: QrUserData, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct TokensResponse { + pub access_token: String, + pub refresh_token: String, +} diff --git a/imphnen-qr/src/auth/infrastructure/http/handlers.rs b/imphnen-qr/src/auth/infrastructure/http/handlers.rs new file mode 100644 index 0000000..9036686 --- /dev/null +++ b/imphnen-qr/src/auth/infrastructure/http/handlers.rs @@ -0,0 +1,72 @@ +use axum::{Extension, Json, response::IntoResponse}; +use axum::extract::Query; +use std::sync::Arc; +use serde::Deserialize; +use imphnen_utils::response_format::ApiSuccess; +use imphnen_utils::errors::AppError; +use crate::auth::domain::service::QrAuthService; +use crate::config::QrConfig; +use super::dto::{RegisterRequest, LoginRequest, RefreshRequest, AuthResponse, TokensResponse}; + +pub async fn register_handler( + Extension(service): Extension>, + Json(body): Json, +) -> Result { + let (tokens, user) = service.register(body.email, body.password, body.name).await?; + Ok(ApiSuccess(AuthResponse { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + user, + }).into_response()) +} + +pub async fn login_handler( + Extension(service): Extension>, + Json(body): Json, +) -> Result { + let (tokens, user) = service.login(body.email, body.password).await?; + Ok(ApiSuccess(AuthResponse { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + user, + }).into_response()) +} + +pub async fn google_redirect_handler( + Extension(config): Extension>, +) -> Result { + let url = format!( + "https://accounts.google.com/o/oauth2/v2/auth?client_id={}&redirect_uri={}&response_type=code&scope=email+profile", + config.google_client_id, + config.google_redirect_url, + ); + Ok(axum::response::Redirect::temporary(&url).into_response()) +} + +#[derive(Debug, Deserialize)] +pub struct GoogleCallbackQuery { + pub code: String, +} + +pub async fn google_callback_handler( + Extension(service): Extension>, + Query(params): Query, +) -> Result { + let (tokens, user) = service.google_callback(params.code).await?; + Ok(ApiSuccess(AuthResponse { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + user, + }).into_response()) +} + +pub async fn refresh_handler( + Extension(service): Extension>, + Json(body): Json, +) -> Result { + let tokens = service.refresh_token(body.refresh_token).await?; + Ok(ApiSuccess(TokensResponse { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + }).into_response()) +} diff --git a/imphnen-qr/src/auth/infrastructure/http/mod.rs b/imphnen-qr/src/auth/infrastructure/http/mod.rs new file mode 100644 index 0000000..eee210d --- /dev/null +++ b/imphnen-qr/src/auth/infrastructure/http/mod.rs @@ -0,0 +1,3 @@ +pub mod dto; +pub mod handlers; +pub mod routes; diff --git a/imphnen-qr/src/auth/infrastructure/http/routes.rs b/imphnen-qr/src/auth/infrastructure/http/routes.rs new file mode 100644 index 0000000..4cb070b --- /dev/null +++ b/imphnen-qr/src/auth/infrastructure/http/routes.rs @@ -0,0 +1,29 @@ +use axum::{routing::{get, post}, Extension, Router}; +use sqlx::PgPool; +use std::sync::Arc; +use crate::auth::application::auth_service::QrAuthServiceImpl; +use crate::auth::domain::service::QrAuthService; +use crate::common::qr_jwt::QrJwtService; +use crate::config::QrConfig; +use super::handlers::{ + register_handler, + login_handler, + google_redirect_handler, + google_callback_handler, + refresh_handler, +}; + +pub fn qr_auth_routes(pool: Arc, jwt: Arc, config: Arc) -> Router { + let service: Arc = Arc::new( + QrAuthServiceImpl::new(pool, jwt, config.clone()) + ); + + Router::new() + .route("/auth/register", post(register_handler)) + .route("/auth/login", post(login_handler)) + .route("/auth/google", get(google_redirect_handler)) + .route("/auth/google/callback", get(google_callback_handler)) + .route("/auth/refresh", post(refresh_handler)) + .layer(Extension(service)) + .layer(Extension(config)) +} diff --git a/imphnen-qr/src/auth/infrastructure/mod.rs b/imphnen-qr/src/auth/infrastructure/mod.rs new file mode 100644 index 0000000..4c61c09 --- /dev/null +++ b/imphnen-qr/src/auth/infrastructure/mod.rs @@ -0,0 +1,2 @@ +pub mod http; +pub mod persistence; diff --git a/imphnen-qr/src/auth/infrastructure/persistence/mod.rs b/imphnen-qr/src/auth/infrastructure/persistence/mod.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/imphnen-qr/src/auth/infrastructure/persistence/mod.rs @@ -0,0 +1 @@ + diff --git a/imphnen-qr/src/auth/mod.rs b/imphnen-qr/src/auth/mod.rs new file mode 100644 index 0000000..c5ff86f --- /dev/null +++ b/imphnen-qr/src/auth/mod.rs @@ -0,0 +1,3 @@ +pub mod domain; +pub mod application; +pub mod infrastructure; diff --git a/imphnen-qr/src/campaigns/application/campaign_service.rs b/imphnen-qr/src/campaigns/application/campaign_service.rs new file mode 100644 index 0000000..942fd3b --- /dev/null +++ b/imphnen-qr/src/campaigns/application/campaign_service.rs @@ -0,0 +1,88 @@ +use async_trait::async_trait; +use image::{DynamicImage, GenericImageView, ImageFormat, imageops}; +use imphnen_utils::errors::AppError; +use qrcode::QrCode; +use std::io::Cursor; +use std::sync::Arc; +use uuid::Uuid; + +use crate::campaigns::domain::{ + entity::{CampaignEntity, CreateCampaignInput}, + repository::CampaignRepository, + service::QrCampaignService, +}; + +pub struct QrCampaignServiceImpl { + repo: Arc, +} + +impl QrCampaignServiceImpl { + pub fn new(repo: Arc) -> Self { + Self { repo } + } +} + +#[async_trait] +impl QrCampaignService for QrCampaignServiceImpl { + async fn create(&self, name: String, url: String, created_by: Uuid) -> Result { + let qr = QrCode::new(url.as_bytes()) + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + let qr_img = qr.render::>().min_dimensions(256, 256).build(); + let mut qr_bytes = Vec::new(); + DynamicImage::ImageLuma8(qr_img) + .write_to(&mut Cursor::new(&mut qr_bytes), ImageFormat::Png) + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + let input = CreateCampaignInput { + name, + url, + created_by, + qr_code_data: qr_bytes, + }; + self.repo.create(input).await + } + + async fn list_all(&self) -> Result, AppError> { + self.repo.find_all().await + } + + async fn get_active_qr_data(&self) -> Result>, AppError> { + self.repo.find_active_qr_data().await + } + + async fn set_active(&self, id: Uuid) -> Result { + self.repo.set_active(id).await + } + + async fn delete(&self, id: Uuid) -> Result<(), AppError> { + self.repo.delete(id).await + } + + async fn process_image(&self, image_bytes: Vec) -> Result, AppError> { + let qr_data = self.repo.find_active_qr_data().await? + .ok_or_else(|| AppError::NotFoundError("No active campaign".to_string()))?; + + let img = image::load_from_memory(&image_bytes) + .map_err(|_| AppError::BadRequestError("Invalid image format".to_string()))?; + + let qr_img = image::load_from_memory(&qr_data) + .map_err(|_| AppError::InternalServerError("Failed to load QR data".to_string()))?; + + let (w, h) = img.dimensions(); + let qr_size = (std::cmp::min(w, h) / 5).max(100); + + let qr_resized = qr_img.resize_exact(qr_size, qr_size, imageops::FilterType::Nearest); + + let mut output = img.to_rgba8(); + let x = (w - qr_size - 10) as i64; + let y = (h - qr_size - 10) as i64; + imageops::overlay(&mut output, &qr_resized.to_rgba8(), x, y); + + let mut out_bytes = Vec::new(); + DynamicImage::ImageRgba8(output) + .write_to(&mut Cursor::new(&mut out_bytes), ImageFormat::Png) + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + Ok(out_bytes) + } +} diff --git a/imphnen-qr/src/campaigns/application/mod.rs b/imphnen-qr/src/campaigns/application/mod.rs new file mode 100644 index 0000000..753e234 --- /dev/null +++ b/imphnen-qr/src/campaigns/application/mod.rs @@ -0,0 +1 @@ +pub mod campaign_service; diff --git a/imphnen-qr/src/campaigns/domain/entity.rs b/imphnen-qr/src/campaigns/domain/entity.rs new file mode 100644 index 0000000..9e26947 --- /dev/null +++ b/imphnen-qr/src/campaigns/domain/entity.rs @@ -0,0 +1,24 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use utoipa::ToSchema; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, ToSchema, FromRow, Clone)] +pub struct CampaignEntity { + pub id: Uuid, + pub name: String, + pub url: String, + pub is_active: bool, + pub created_by: Uuid, + pub expires_at: DateTime, + pub created_at: Option>, + pub updated_at: Option>, +} + +pub struct CreateCampaignInput { + pub name: String, + pub url: String, + pub created_by: Uuid, + pub qr_code_data: Vec, +} diff --git a/imphnen-qr/src/campaigns/domain/mod.rs b/imphnen-qr/src/campaigns/domain/mod.rs new file mode 100644 index 0000000..228c84e --- /dev/null +++ b/imphnen-qr/src/campaigns/domain/mod.rs @@ -0,0 +1,3 @@ +pub mod entity; +pub mod repository; +pub mod service; diff --git a/imphnen-qr/src/campaigns/domain/repository.rs b/imphnen-qr/src/campaigns/domain/repository.rs new file mode 100644 index 0000000..efde2be --- /dev/null +++ b/imphnen-qr/src/campaigns/domain/repository.rs @@ -0,0 +1,14 @@ +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; + +use super::entity::{CampaignEntity, CreateCampaignInput}; + +#[async_trait] +pub trait CampaignRepository: Send + Sync { + async fn create(&self, input: CreateCampaignInput) -> Result; + async fn find_all(&self) -> Result, AppError>; + async fn find_active_qr_data(&self) -> Result>, AppError>; + async fn set_active(&self, id: Uuid) -> Result; + async fn delete(&self, id: Uuid) -> Result<(), AppError>; +} diff --git a/imphnen-qr/src/campaigns/domain/service.rs b/imphnen-qr/src/campaigns/domain/service.rs new file mode 100644 index 0000000..97c14a0 --- /dev/null +++ b/imphnen-qr/src/campaigns/domain/service.rs @@ -0,0 +1,15 @@ +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; + +use super::entity::CampaignEntity; + +#[async_trait] +pub trait QrCampaignService: Send + Sync { + async fn create(&self, name: String, url: String, created_by: Uuid) -> Result; + async fn list_all(&self) -> Result, AppError>; + async fn get_active_qr_data(&self) -> Result>, AppError>; + async fn set_active(&self, id: Uuid) -> Result; + async fn delete(&self, id: Uuid) -> Result<(), AppError>; + async fn process_image(&self, image_bytes: Vec) -> Result, AppError>; +} diff --git a/imphnen-qr/src/campaigns/infrastructure/http/dto.rs b/imphnen-qr/src/campaigns/infrastructure/http/dto.rs new file mode 100644 index 0000000..f69d9fd --- /dev/null +++ b/imphnen-qr/src/campaigns/infrastructure/http/dto.rs @@ -0,0 +1,22 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateCampaignRequest { + pub name: String, + pub url: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct CampaignResponse { + pub id: Uuid, + pub name: String, + pub url: String, + pub is_active: bool, + pub created_by: Uuid, + pub expires_at: DateTime, + pub created_at: Option>, + pub updated_at: Option>, +} diff --git a/imphnen-qr/src/campaigns/infrastructure/http/handlers.rs b/imphnen-qr/src/campaigns/infrastructure/http/handlers.rs new file mode 100644 index 0000000..d91b4b0 --- /dev/null +++ b/imphnen-qr/src/campaigns/infrastructure/http/handlers.rs @@ -0,0 +1,85 @@ +use axum::{ + extract::{Multipart, Path}, + response::{IntoResponse, Response}, + Extension, Json, +}; +use imphnen_utils::{errors::AppError, response_format::ApiSuccess}; +use std::sync::Arc; +use uuid::Uuid; + +use crate::{ + campaigns::{ + domain::service::QrCampaignService, + infrastructure::http::dto::CreateCampaignRequest, + }, + middleware::qr_auth::QrAuthUser, +}; + +pub async fn create_campaign_handler( + Extension(service): Extension>, + Extension(auth_user): Extension, + Json(body): Json, +) -> Result { + if auth_user.role != "admin" { + return Err(AppError::ForbiddenError("Admin access required".to_string())); + } + let campaign = service.create(body.name, body.url, auth_user.user_id).await?; + Ok(imphnen_utils::response_format::ApiCreated(campaign).into_response()) +} + +pub async fn list_campaigns_handler( + Extension(service): Extension>, + Extension(auth_user): Extension, +) -> Result { + if auth_user.role != "admin" { + return Err(AppError::ForbiddenError("Admin access required".to_string())); + } + let campaigns = service.list_all().await?; + Ok(ApiSuccess(campaigns).into_response()) +} + +pub async fn activate_campaign_handler( + Extension(service): Extension>, + Extension(auth_user): Extension, + Path(id): Path, +) -> Result { + if auth_user.role != "admin" { + return Err(AppError::ForbiddenError("Admin access required".to_string())); + } + let campaign = service.set_active(id).await?; + Ok(ApiSuccess(campaign).into_response()) +} + +pub async fn delete_campaign_handler( + Extension(service): Extension>, + Extension(auth_user): Extension, + Path(id): Path, +) -> Result { + if auth_user.role != "admin" { + return Err(AppError::ForbiddenError("Admin access required".to_string())); + } + service.delete(id).await?; + Ok(imphnen_utils::response_format::ApiMessage::ok("Campaign deleted successfully").into_response()) +} + +pub async fn process_image_handler( + Extension(service): Extension>, + Extension(_auth_user): Extension, + mut multipart: Multipart, +) -> Result { + let mut image_bytes = Vec::new(); + while let Some(field) = multipart.next_field().await.map_err(|e| AppError::BadRequestError(e.to_string()))? { + if field.name() == Some("file") { + image_bytes = field.bytes().await.map_err(|e| AppError::BadRequestError(e.to_string()))?.to_vec(); + break; + } + } + if image_bytes.is_empty() { + return Err(AppError::BadRequestError("No file provided".to_string())); + } + let png_bytes = service.process_image(image_bytes).await?; + Ok(( + [(axum::http::header::CONTENT_TYPE, "image/png")], + png_bytes, + ).into_response()) +} diff --git a/imphnen-qr/src/campaigns/infrastructure/http/mod.rs b/imphnen-qr/src/campaigns/infrastructure/http/mod.rs new file mode 100644 index 0000000..eee210d --- /dev/null +++ b/imphnen-qr/src/campaigns/infrastructure/http/mod.rs @@ -0,0 +1,3 @@ +pub mod dto; +pub mod handlers; +pub mod routes; diff --git a/imphnen-qr/src/campaigns/infrastructure/http/routes.rs b/imphnen-qr/src/campaigns/infrastructure/http/routes.rs new file mode 100644 index 0000000..066faf5 --- /dev/null +++ b/imphnen-qr/src/campaigns/infrastructure/http/routes.rs @@ -0,0 +1,38 @@ +use axum::{ + middleware::from_fn, + routing::{delete, post, put}, + Extension, Router, +}; +use sqlx::PgPool; +use std::sync::Arc; + +use crate::{ + campaigns::{ + application::campaign_service::QrCampaignServiceImpl, + domain::{repository::CampaignRepository, service::QrCampaignService}, + infrastructure::{ + http::handlers::{ + activate_campaign_handler, create_campaign_handler, delete_campaign_handler, + list_campaigns_handler, process_image_handler, + }, + persistence::postgres_campaign_repository::PostgresCampaignRepository, + }, + }, + common::qr_jwt::QrJwtService, + middleware::qr_auth::qr_auth_middleware, +}; + +pub fn qr_campaigns_routes(pool: Arc, jwt: Arc) -> Router { + let repo: Arc = Arc::new(PostgresCampaignRepository::new(pool.clone())); + let service: Arc = Arc::new(QrCampaignServiceImpl::new(repo)); + + Router::new() + .route("/campaigns", post(create_campaign_handler).get(list_campaigns_handler)) + .route("/campaigns/:id/activate", put(activate_campaign_handler)) + .route("/campaigns/:id", delete(delete_campaign_handler)) + .route("/campaigns/process-image", post(process_image_handler)) + .layer(Extension(service)) + .layer(Extension(jwt.clone())) + .layer(Extension(pool)) + .layer(from_fn(qr_auth_middleware)) +} diff --git a/imphnen-qr/src/campaigns/infrastructure/mod.rs b/imphnen-qr/src/campaigns/infrastructure/mod.rs new file mode 100644 index 0000000..4c61c09 --- /dev/null +++ b/imphnen-qr/src/campaigns/infrastructure/mod.rs @@ -0,0 +1,2 @@ +pub mod http; +pub mod persistence; diff --git a/imphnen-qr/src/campaigns/infrastructure/persistence/mod.rs b/imphnen-qr/src/campaigns/infrastructure/persistence/mod.rs new file mode 100644 index 0000000..eeaf1d3 --- /dev/null +++ b/imphnen-qr/src/campaigns/infrastructure/persistence/mod.rs @@ -0,0 +1 @@ +pub mod postgres_campaign_repository; diff --git a/imphnen-qr/src/campaigns/infrastructure/persistence/postgres_campaign_repository.rs b/imphnen-qr/src/campaigns/infrastructure/persistence/postgres_campaign_repository.rs new file mode 100644 index 0000000..9f4e71d --- /dev/null +++ b/imphnen-qr/src/campaigns/infrastructure/persistence/postgres_campaign_repository.rs @@ -0,0 +1,107 @@ +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use sqlx::PgPool; +use std::sync::Arc; +use uuid::Uuid; + +use crate::campaigns::domain::{ + entity::{CampaignEntity, CreateCampaignInput}, + repository::CampaignRepository, +}; + +pub struct PostgresCampaignRepository { + pool: Arc, +} + +impl PostgresCampaignRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } +} + +#[async_trait] +impl CampaignRepository for PostgresCampaignRepository { + async fn create(&self, input: CreateCampaignInput) -> Result { + let mut tx = self.pool.begin().await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + sqlx::query("UPDATE qr_campaigns SET is_active = false, updated_at = NOW()") + .execute(&mut *tx) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + let id = Uuid::new_v4(); + let campaign = sqlx::query_as::<_, CampaignEntity>( + "INSERT INTO qr_campaigns (id, name, url, qr_code_data, is_active, created_by, expires_at) \ + VALUES ($1, $2, $3, $4, true, $5, NOW() + INTERVAL '30 days') \ + RETURNING id, name, url, is_active, created_by, expires_at, created_at, updated_at", + ) + .bind(id) + .bind(&input.name) + .bind(&input.url) + .bind(&input.qr_code_data) + .bind(input.created_by) + .fetch_one(&mut *tx) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + tx.commit().await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + Ok(campaign) + } + + async fn find_all(&self) -> Result, AppError> { + sqlx::query_as::<_, CampaignEntity>( + "SELECT id, name, url, is_active, created_by, expires_at, created_at, updated_at \ + FROM qr_campaigns ORDER BY created_at DESC", + ) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + } + + async fn find_active_qr_data(&self) -> Result>, AppError> { + let row = sqlx::query_as::<_, (Vec,)>( + "SELECT qr_code_data FROM qr_campaigns WHERE is_active = true LIMIT 1", + ) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + Ok(row.map(|r| r.0)) + } + + async fn set_active(&self, id: Uuid) -> Result { + let mut tx = self.pool.begin().await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + sqlx::query("UPDATE qr_campaigns SET is_active = false, updated_at = NOW()") + .execute(&mut *tx) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + let campaign = sqlx::query_as::<_, CampaignEntity>( + "UPDATE qr_campaigns SET is_active = true, updated_at = NOW() WHERE id = $1 \ + RETURNING id, name, url, is_active, created_by, expires_at, created_at, updated_at", + ) + .bind(id) + .fetch_one(&mut *tx) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + tx.commit().await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + + Ok(campaign) + } + + async fn delete(&self, id: Uuid) -> Result<(), AppError> { + sqlx::query("DELETE FROM qr_campaigns WHERE id = $1") + .bind(id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(()) + } +} diff --git a/imphnen-qr/src/campaigns/mod.rs b/imphnen-qr/src/campaigns/mod.rs new file mode 100644 index 0000000..3d2b916 --- /dev/null +++ b/imphnen-qr/src/campaigns/mod.rs @@ -0,0 +1,4 @@ +pub mod domain; +pub mod application; +pub mod infrastructure; +pub use infrastructure::http::routes::qr_campaigns_routes; diff --git a/imphnen-qr/src/common/mod.rs b/imphnen-qr/src/common/mod.rs new file mode 100644 index 0000000..b01715b --- /dev/null +++ b/imphnen-qr/src/common/mod.rs @@ -0,0 +1 @@ +pub mod qr_jwt; diff --git a/imphnen-qr/src/common/qr_jwt.rs b/imphnen-qr/src/common/qr_jwt.rs new file mode 100644 index 0000000..701280e --- /dev/null +++ b/imphnen-qr/src/common/qr_jwt.rs @@ -0,0 +1,59 @@ +use chrono::{Duration, Utc}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use imphnen_utils::errors::AppError; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct QrClaims { + pub sub: String, + pub role: String, + pub exp: usize, +} + +#[derive(Clone)] +pub struct QrJwtService { + encoding_key: EncodingKey, + decoding_key: DecodingKey, + expiry_minutes: i64, + refresh_expiry_days: i64, +} + +impl QrJwtService { + pub fn new(secret: &str, expiry_minutes: i64, refresh_expiry_days: i64) -> Self { + Self { + encoding_key: EncodingKey::from_secret(secret.as_bytes()), + decoding_key: DecodingKey::from_secret(secret.as_bytes()), + expiry_minutes, + refresh_expiry_days, + } + } + + pub fn generate_token(&self, user_id: Uuid, role: &str) -> Result { + let exp = (Utc::now() + Duration::minutes(self.expiry_minutes)).timestamp() as usize; + let claims = QrClaims { + sub: user_id.to_string(), + role: role.to_string(), + exp, + }; + encode(&Header::default(), &claims, &self.encoding_key) + .map_err(|e| AppError::InternalServerError(e.to_string())) + } + + pub fn generate_refresh_token(&self, user_id: Uuid, role: &str) -> Result { + let exp = (Utc::now() + Duration::days(self.refresh_expiry_days)).timestamp() as usize; + let claims = QrClaims { + sub: user_id.to_string(), + role: role.to_string(), + exp, + }; + encode(&Header::default(), &claims, &self.encoding_key) + .map_err(|e| AppError::InternalServerError(e.to_string())) + } + + pub fn verify_token(&self, token: &str) -> Result { + decode::(token, &self.decoding_key, &Validation::default()) + .map(|d| d.claims) + .map_err(|_| AppError::AuthenticationError("Invalid or expired token".to_string())) + } +} diff --git a/imphnen-qr/src/config.rs b/imphnen-qr/src/config.rs new file mode 100644 index 0000000..2fd0b5c --- /dev/null +++ b/imphnen-qr/src/config.rs @@ -0,0 +1,30 @@ +use std::env; + +#[derive(Debug, Clone)] +pub struct QrConfig { + pub jwt_secret: String, + pub jwt_expiry_minutes: i64, + pub refresh_expiry_days: i64, + pub google_client_id: String, + pub google_client_secret: String, + pub google_redirect_url: String, +} + +impl QrConfig { + pub fn from_env() -> Self { + Self { + jwt_secret: env::var("QR_JWT_SECRET").expect("QR_JWT_SECRET must be set"), + jwt_expiry_minutes: env::var("QR_JWT_EXPIRY_MINUTES") + .unwrap_or_else(|_| "15".to_string()) + .parse() + .unwrap_or(15), + refresh_expiry_days: env::var("QR_JWT_REFRESH_EXPIRY_DAYS") + .unwrap_or_else(|_| "7".to_string()) + .parse() + .unwrap_or(7), + google_client_id: env::var("QR_GOOGLE_CLIENT_ID").unwrap_or_default(), + google_client_secret: env::var("QR_GOOGLE_CLIENT_SECRET").unwrap_or_default(), + google_redirect_url: env::var("QR_GOOGLE_REDIRECT_URL").unwrap_or_default(), + } + } +} diff --git a/imphnen-qr/src/lib.rs b/imphnen-qr/src/lib.rs new file mode 100644 index 0000000..07b37c1 --- /dev/null +++ b/imphnen-qr/src/lib.rs @@ -0,0 +1,26 @@ +pub mod config; +pub mod common; +pub mod middleware; +pub mod auth; +pub mod users; +pub mod campaigns; + +pub use config::QrConfig; + +use axum::Router; +use sqlx::PgPool; +use std::sync::Arc; +use common::qr_jwt::QrJwtService; + +pub fn qr_router(pool: Arc, config: Arc) -> Router { + let jwt = Arc::new(QrJwtService::new( + &config.jwt_secret, + config.jwt_expiry_minutes, + config.refresh_expiry_days, + )); + + Router::new() + .merge(auth::infrastructure::http::routes::qr_auth_routes(pool.clone(), jwt.clone(), config.clone())) + .merge(users::infrastructure::http::routes::qr_users_routes(pool.clone(), jwt.clone())) + .merge(campaigns::infrastructure::http::routes::qr_campaigns_routes(pool.clone(), jwt.clone())) +} diff --git a/imphnen-qr/src/middleware/mod.rs b/imphnen-qr/src/middleware/mod.rs new file mode 100644 index 0000000..25cc7e1 --- /dev/null +++ b/imphnen-qr/src/middleware/mod.rs @@ -0,0 +1 @@ +pub mod qr_auth; diff --git a/imphnen-qr/src/middleware/qr_auth.rs b/imphnen-qr/src/middleware/qr_auth.rs new file mode 100644 index 0000000..26ae881 --- /dev/null +++ b/imphnen-qr/src/middleware/qr_auth.rs @@ -0,0 +1,39 @@ +use axum::{body::Body, extract::Request, middleware::Next, response::{IntoResponse, Response}}; +use axum::http::StatusCode; +use std::sync::Arc; +use uuid::Uuid; +use serde::{Deserialize, Serialize}; +use crate::common::qr_jwt::QrJwtService; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QrAuthUser { + pub user_id: Uuid, + pub role: String, +} + +pub async fn qr_auth_middleware( + axum::Extension(jwt_service): axum::Extension>, + mut request: Request, + next: Next, +) -> Result { + let auth_header = request + .headers() + .get("Authorization") + .and_then(|h| h.to_str().ok()) + .ok_or_else(|| (StatusCode::UNAUTHORIZED, "Missing Authorization header").into_response())?; + + let token = auth_header.strip_prefix("Bearer ").ok_or_else(|| { + (StatusCode::UNAUTHORIZED, "Invalid Authorization header format").into_response() + })?; + + let claims = jwt_service.verify_token(token).map_err(|_| { + (StatusCode::UNAUTHORIZED, "Invalid or expired token").into_response() + })?; + + let user_id = Uuid::parse_str(&claims.sub).map_err(|_| { + (StatusCode::UNAUTHORIZED, "Invalid user ID in token").into_response() + })?; + + request.extensions_mut().insert(QrAuthUser { user_id, role: claims.role }); + Ok(next.run(request).await) +} diff --git a/imphnen-qr/src/users/application/mod.rs b/imphnen-qr/src/users/application/mod.rs new file mode 100644 index 0000000..4f2070f --- /dev/null +++ b/imphnen-qr/src/users/application/mod.rs @@ -0,0 +1 @@ +pub mod user_service; diff --git a/imphnen-qr/src/users/application/user_service.rs b/imphnen-qr/src/users/application/user_service.rs new file mode 100644 index 0000000..6be98fd --- /dev/null +++ b/imphnen-qr/src/users/application/user_service.rs @@ -0,0 +1,51 @@ +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use std::sync::Arc; +use uuid::Uuid; + +use crate::users::domain::{ + entity::{UpdateUserInput, UserEntity}, + repository::UserRepository, + service::QrUserService, +}; + +pub struct QrUserServiceImpl { + repo: Arc, +} + +impl QrUserServiceImpl { + pub fn new(repo: Arc) -> Self { + Self { repo } + } +} + +#[async_trait] +impl QrUserService for QrUserServiceImpl { + async fn get_profile(&self, user_id: Uuid) -> Result { + self.repo + .find_by_id(user_id) + .await? + .ok_or_else(|| AppError::NotFoundError("User not found".to_string())) + } + + async fn update_profile(&self, user_id: Uuid, input: UpdateUserInput) -> Result { + if let Some(ref email) = input.email { + if email.trim().is_empty() { + return Err(AppError::ValidationError("Email cannot be empty".to_string())); + } + } + self.repo.update(user_id, input).await + } + + async fn list_all(&self) -> Result, AppError> { + self.repo.find_all().await + } + + async fn update_role(&self, id: Uuid, role: String) -> Result { + self.repo.update_role(id, role).await + } + + async fn delete(&self, id: Uuid) -> Result<(), AppError> { + self.repo.delete(id).await + } +} diff --git a/imphnen-qr/src/users/domain/entity.rs b/imphnen-qr/src/users/domain/entity.rs new file mode 100644 index 0000000..6e8eb4b --- /dev/null +++ b/imphnen-qr/src/users/domain/entity.rs @@ -0,0 +1,21 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use utoipa::ToSchema; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, ToSchema, FromRow, Clone)] +pub struct UserEntity { + pub id: Uuid, + pub email: String, + pub name: String, + pub role: String, + pub provider: String, + pub created_at: Option>, + pub updated_at: Option>, +} + +pub struct UpdateUserInput { + pub name: Option, + pub email: Option, +} diff --git a/imphnen-qr/src/users/domain/mod.rs b/imphnen-qr/src/users/domain/mod.rs new file mode 100644 index 0000000..228c84e --- /dev/null +++ b/imphnen-qr/src/users/domain/mod.rs @@ -0,0 +1,3 @@ +pub mod entity; +pub mod repository; +pub mod service; diff --git a/imphnen-qr/src/users/domain/repository.rs b/imphnen-qr/src/users/domain/repository.rs new file mode 100644 index 0000000..9b4eda2 --- /dev/null +++ b/imphnen-qr/src/users/domain/repository.rs @@ -0,0 +1,14 @@ +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; + +use super::entity::{UpdateUserInput, UserEntity}; + +#[async_trait] +pub trait UserRepository: Send + Sync { + async fn find_by_id(&self, id: Uuid) -> Result, AppError>; + async fn find_all(&self) -> Result, AppError>; + async fn update(&self, id: Uuid, input: UpdateUserInput) -> Result; + async fn update_role(&self, id: Uuid, role: String) -> Result; + async fn delete(&self, id: Uuid) -> Result<(), AppError>; +} diff --git a/imphnen-qr/src/users/domain/service.rs b/imphnen-qr/src/users/domain/service.rs new file mode 100644 index 0000000..46a867b --- /dev/null +++ b/imphnen-qr/src/users/domain/service.rs @@ -0,0 +1,14 @@ +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use uuid::Uuid; + +use super::entity::{UpdateUserInput, UserEntity}; + +#[async_trait] +pub trait QrUserService: Send + Sync { + async fn get_profile(&self, user_id: Uuid) -> Result; + async fn update_profile(&self, user_id: Uuid, input: UpdateUserInput) -> Result; + async fn list_all(&self) -> Result, AppError>; + async fn update_role(&self, id: Uuid, role: String) -> Result; + async fn delete(&self, id: Uuid) -> Result<(), AppError>; +} diff --git a/imphnen-qr/src/users/infrastructure/http/dto.rs b/imphnen-qr/src/users/infrastructure/http/dto.rs new file mode 100644 index 0000000..04b2fe1 --- /dev/null +++ b/imphnen-qr/src/users/infrastructure/http/dto.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateProfileRequest { + pub name: Option, + pub email: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateRoleRequest { + pub role: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct UserResponse { + pub id: String, + pub email: String, + pub name: String, + pub role: String, + pub provider: String, +} diff --git a/imphnen-qr/src/users/infrastructure/http/handlers.rs b/imphnen-qr/src/users/infrastructure/http/handlers.rs new file mode 100644 index 0000000..6ed17ff --- /dev/null +++ b/imphnen-qr/src/users/infrastructure/http/handlers.rs @@ -0,0 +1,73 @@ +use axum::{ + extract::Path, + response::{IntoResponse, Response}, + Extension, Json, +}; +use imphnen_utils::{errors::AppError, response_format::ApiSuccess}; +use std::sync::Arc; +use uuid::Uuid; + +use crate::{ + middleware::qr_auth::QrAuthUser, + users::{ + domain::{entity::UpdateUserInput, service::QrUserService}, + infrastructure::http::dto::{UpdateProfileRequest, UpdateRoleRequest}, + }, +}; + +pub async fn get_me_handler( + Extension(service): Extension>, + Extension(auth_user): Extension, +) -> Result { + let user = service.get_profile(auth_user.user_id).await?; + Ok(ApiSuccess(user).into_response()) +} + +pub async fn update_me_handler( + Extension(service): Extension>, + Extension(auth_user): Extension, + Json(body): Json, +) -> Result { + let input = UpdateUserInput { + name: body.name, + email: body.email, + }; + let user = service.update_profile(auth_user.user_id, input).await?; + Ok(ApiSuccess(user).into_response()) +} + +pub async fn list_users_handler( + Extension(service): Extension>, + Extension(auth_user): Extension, +) -> Result { + if auth_user.role != "admin" { + return Err(AppError::ForbiddenError("Admin access required".to_string())); + } + let users = service.list_all().await?; + Ok(ApiSuccess(users).into_response()) +} + +pub async fn update_role_handler( + Extension(service): Extension>, + Extension(auth_user): Extension, + Path(id): Path, + Json(body): Json, +) -> Result { + if auth_user.role != "admin" { + return Err(AppError::ForbiddenError("Admin access required".to_string())); + } + let user = service.update_role(id, body.role).await?; + Ok(ApiSuccess(user).into_response()) +} + +pub async fn delete_user_handler( + Extension(service): Extension>, + Extension(auth_user): Extension, + Path(id): Path, +) -> Result { + if auth_user.role != "admin" { + return Err(AppError::ForbiddenError("Admin access required".to_string())); + } + service.delete(id).await?; + Ok(imphnen_utils::response_format::ApiMessage::ok("User deleted successfully").into_response()) +} diff --git a/imphnen-qr/src/users/infrastructure/http/mod.rs b/imphnen-qr/src/users/infrastructure/http/mod.rs new file mode 100644 index 0000000..eee210d --- /dev/null +++ b/imphnen-qr/src/users/infrastructure/http/mod.rs @@ -0,0 +1,3 @@ +pub mod dto; +pub mod handlers; +pub mod routes; diff --git a/imphnen-qr/src/users/infrastructure/http/routes.rs b/imphnen-qr/src/users/infrastructure/http/routes.rs new file mode 100644 index 0000000..64b35e0 --- /dev/null +++ b/imphnen-qr/src/users/infrastructure/http/routes.rs @@ -0,0 +1,38 @@ +use axum::{ + middleware::from_fn, + routing::{delete, get, put}, + Extension, Router, +}; +use sqlx::PgPool; +use std::sync::Arc; + +use crate::{ + common::qr_jwt::QrJwtService, + middleware::qr_auth::qr_auth_middleware, + users::{ + application::user_service::QrUserServiceImpl, + domain::{repository::UserRepository, service::QrUserService}, + infrastructure::{ + http::handlers::{ + delete_user_handler, get_me_handler, list_users_handler, update_me_handler, + update_role_handler, + }, + persistence::postgres_user_repository::PostgresUserRepository, + }, + }, +}; + +pub fn qr_users_routes(pool: Arc, jwt: Arc) -> Router { + let repo: Arc = Arc::new(PostgresUserRepository::new(pool.clone())); + let service: Arc = Arc::new(QrUserServiceImpl::new(repo)); + + Router::new() + .route("/users/me", get(get_me_handler).put(update_me_handler)) + .route("/users", get(list_users_handler)) + .route("/users/:id/role", put(update_role_handler)) + .route("/users/:id", delete(delete_user_handler)) + .layer(Extension(service)) + .layer(Extension(jwt.clone())) + .layer(Extension(pool)) + .layer(from_fn(qr_auth_middleware)) +} diff --git a/imphnen-qr/src/users/infrastructure/mod.rs b/imphnen-qr/src/users/infrastructure/mod.rs new file mode 100644 index 0000000..4c61c09 --- /dev/null +++ b/imphnen-qr/src/users/infrastructure/mod.rs @@ -0,0 +1,2 @@ +pub mod http; +pub mod persistence; diff --git a/imphnen-qr/src/users/infrastructure/persistence/mod.rs b/imphnen-qr/src/users/infrastructure/persistence/mod.rs new file mode 100644 index 0000000..9a07f21 --- /dev/null +++ b/imphnen-qr/src/users/infrastructure/persistence/mod.rs @@ -0,0 +1 @@ +pub mod postgres_user_repository; diff --git a/imphnen-qr/src/users/infrastructure/persistence/postgres_user_repository.rs b/imphnen-qr/src/users/infrastructure/persistence/postgres_user_repository.rs new file mode 100644 index 0000000..45bb5e2 --- /dev/null +++ b/imphnen-qr/src/users/infrastructure/persistence/postgres_user_repository.rs @@ -0,0 +1,74 @@ +use async_trait::async_trait; +use imphnen_utils::errors::AppError; +use sqlx::PgPool; +use std::sync::Arc; +use uuid::Uuid; + +use crate::users::domain::{ + entity::{UpdateUserInput, UserEntity}, + repository::UserRepository, +}; + +pub struct PostgresUserRepository { + pool: Arc, +} + +impl PostgresUserRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } +} + +#[async_trait] +impl UserRepository for PostgresUserRepository { + async fn find_by_id(&self, id: Uuid) -> Result, AppError> { + sqlx::query_as::<_, UserEntity>( + "SELECT id, email, name, role, provider, created_at, updated_at FROM users WHERE id = $1", + ) + .bind(id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + } + + async fn find_all(&self) -> Result, AppError> { + sqlx::query_as::<_, UserEntity>( + "SELECT id, email, name, role, provider, created_at, updated_at FROM users ORDER BY created_at DESC", + ) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + } + + async fn update(&self, id: Uuid, input: UpdateUserInput) -> Result { + sqlx::query_as::<_, UserEntity>( + "UPDATE users SET name = COALESCE($1, name), email = COALESCE($2, email), updated_at = NOW() WHERE id = $3 RETURNING id, email, name, role, provider, created_at, updated_at", + ) + .bind(input.name) + .bind(input.email) + .bind(id) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + } + + async fn update_role(&self, id: Uuid, role: String) -> Result { + sqlx::query_as::<_, UserEntity>( + "UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2 RETURNING id, email, name, role, provider, created_at, updated_at", + ) + .bind(role) + .bind(id) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string())) + } + + async fn delete(&self, id: Uuid) -> Result<(), AppError> { + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(()) + } +} diff --git a/imphnen-qr/src/users/mod.rs b/imphnen-qr/src/users/mod.rs new file mode 100644 index 0000000..40ac3c7 --- /dev/null +++ b/imphnen-qr/src/users/mod.rs @@ -0,0 +1,4 @@ +pub mod domain; +pub mod application; +pub mod infrastructure; +pub use infrastructure::http::routes::qr_users_routes;