diff --git a/.e2e-build.lock b/.e2e-build.lock new file mode 100644 index 0000000..e69de29 diff --git a/.env.example b/.env.example index 16d14fa..7519f4e 100644 --- a/.env.example +++ b/.env.example @@ -62,15 +62,12 @@ GITHUB_REDIRECT_URI= # Cloudflare R2 R2_ENDPOINT= R2_REGION=auto -R2_PUBLIC_DOMAIN= -R2_BUCKET_NAME= +R2_ASSETS_PUBLIC_DOMAIN= +R2_ASSETS_BUCKET_NAME= R2_ACCESS_KEY_ID= R2_SECRET_ACCESS_KEY= TURNSTILE_SECRET_KEY= -# SeaweedFS -SEAWEEDFS_ENDPOINT=http://localhost:8888 - # NATS NATS_URL=nats://localhost:4222 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7b43260..77db0b3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,9 +2,7 @@ name: axumkit-server on: push: - branches: [ "main" ] pull_request: - branches: [ "main" ] jobs: build: diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 3b2124c..f036671 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -2,9 +2,7 @@ name: check on: push: - branches: [main] pull_request: - branches: [main] jobs: check: diff --git a/CHANGELOG.md b/CHANGELOG.md index 375f488..9ba53e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,74 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.0] - 2026-04-05 + +### Added + +- **Two-stage email signup flow** (ported from V7) + - `POST /v0/auth/signup` stores pending signup in Redis and queues a verification email (returns 202 Accepted) + - `POST /v0/auth/verify-email` now creates the user account after token verification, not before + - Atomic Redis reservation via Lua script (`reserve_pending_signup.lua`) — prevents concurrent email/handle collision + - `PendingEmailSignupData` stored with email, handle, display name, and pre-hashed password + - `POST /v0/auth/resend-verification-email` is now a **public** endpoint (email-based, no session required) with email enumeration prevention + - `ResendVerificationEmailRequest` DTO with email validation + - `repository_create_user_with_password_hash` for deferred user creation + - `get_ttl_seconds` Redis utility for remaining token TTL lookup + +- **Google One Tap OAuth sign-in** + - `POST /v0/auth/oauth/google/one-tap/login` — validates Google ID token server-side via JWKS + - JWKS caching with `Cache-Control` max-age parsing and forced refresh on key rotation + - `GoogleOneTapLoginRequest` DTO + - `GoogleInvalidIdToken`, `GoogleJwksFetchFailed`, `GoogleJwksParseFailed` error variants with protocol codes + - `jsonwebtoken` v9 workspace dependency + +- **User management system** (ported from V7) + - `POST /v0/users/ban` — ban a user with optional expiration + - `POST /v0/users/unban` — remove an active ban + - `POST /v0/users/roles/grant` — grant Mod/Admin role with optional expiration + - `POST /v0/users/roles/revoke` — revoke a granted role + - All management actions create moderation logs and require admin permission + - `CannotManageSelf` error variant prevents self-management + - 9 permission unit tests + +- **Worker supervisor pattern** + - JoinSet-based supervisor loop with `catch_unwind` auto-restart for consumer panics + - `ConsumerKind` enum with `ConsumerExitOutcome` for structured exit tracking + +- **Handle and display name validation** (V7 exact match) + - `validate_handle`: ASCII alphanumeric + underscore, 4–15 chars, no leading/trailing `_`, no `__` + - `validate_display_name`: blocks control characters, emoji, and Zalgo text (consecutive `NonspacingMark` limit) + - `RESERVED_HANDLES`: 26 reserved words (e.g. `admin`, `support`, `system`) + - `unicode-general-category` crate dependency + +- **Pending email signup constants** + - `EMAIL_SIGNUP_EMAIL_PREFIX`, `EMAIL_SIGNUP_HANDLE_PREFIX` with key generators in `axumkit-constants` + +### Changed + +- **Database connection simplified** (matches V7) + - Merged separate `write_db` + `read_db` into single `db` field in `AppState` + - Environment variables changed from `POSTGRES_WRITE_*` / `POSTGRES_READ_*` to `POSTGRES_*` + - Single `establish_connection()` replaces `establish_write_connection()` + `establish_read_connection()` + +- **Signup endpoint moved from User to Auth module** + - Removed `POST /v0/users` (immediate user creation) + - Replaced with `POST /v0/auth/signup` (deferred creation after email verification) + - `CreateUserResponse` now returns 202 Accepted instead of 201 Created + +- **OAuth pending signup collision checks** + - `find_or_create_oauth_user` now checks pending email/password signups before creating OAuth users + - `complete_signup` pre-checks pending signups for email and handle collisions + - Google, GitHub, and Google One Tap sign-in flows reject emails held by pending signups + +- **All Korean comments translated to English** across 85+ source files + +### Fixed + +- **`CannotManageSelf` permission bug** — was incorrectly returning `CannotManageHigherOrEqualRole` + +--- + ## [0.5.0] - 2026-03-02 ### Added diff --git a/Cargo.lock b/Cargo.lock index ce70719..f46e3d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "const-random", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -131,18 +133,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" - -[[package]] -name = "ar_archive_writer" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" -dependencies = [ - "object", -] +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arbitrary" @@ -155,9 +148,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.8.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" dependencies = [ "rustversion", ] @@ -187,7 +180,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] @@ -203,6 +196,165 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "arrow" +version = "57.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4754a624e5ae42081f464514be454b39711daae0458906dacde5f4c632f33a8" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "57.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7b3141e0ec5145a22d8694ea8b6d6f69305971c4fa1c1a13ef0195aef2d678b" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num-traits", +] + +[[package]] +name = "arrow-array" +version = "57.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c8955af33b25f3b175ee10af580577280b4bd01f7e823d94c7cdef7cf8c9aef" +dependencies = [ + "ahash 0.8.12", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.16.1", + "num-complex", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-buffer" +version = "57.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c697ddca96183182f35b3a18e50b9110b11e916d7b7799cbfd4d34662f2c56c2" +dependencies = [ + "bytes", + "half", + "num-bigint", + "num-traits", +] + +[[package]] +name = "arrow-cast" +version = "57.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "646bbb821e86fd57189c10b4fcdaa941deaf4181924917b0daa92735baa6ada5" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ord", + "arrow-schema", + "arrow-select", + "atoi", + "base64", + "chrono", + "half", + "lexical-core", + "num-traits", + "ryu", +] + +[[package]] +name = "arrow-data" +version = "57.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fdd994a9d28e6365aa78e15da3f3950c0fdcea6b963a12fa1c391afb637b304" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-ord" +version = "57.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d8f1870e03d4cbed632959498bcc84083b5a24bded52905ae1695bd29da45b" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "57.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18228633bad92bff92a95746bbeb16e5fc318e8382b75619dec26db79e4de4c0" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "57.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c872d36b7bf2a6a6a2b40de9156265f0242910791db366a2c17476ba8330d68" + +[[package]] +name = "arrow-select" +version = "57.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68bf3e3efbd1278f770d67e5dc410257300b161b93baedb3aae836144edcaf4b" +dependencies = [ + "ahash 0.8.12", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num-traits", +] + +[[package]] +name = "arrow-string" +version = "57.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85e968097061b3c0e9fe3079cf2e703e487890700546b5b0647f60fca1b5a8d8" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num-traits", + "regex", + "regex-syntax", +] + [[package]] name = "as-slice" version = "0.2.1" @@ -306,9 +458,9 @@ dependencies = [ [[package]] name = "async-nats" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df5af9ebfb0a14481d3eaf6101e6391261e4f30d25b26a7635ade8a39482ded0" +checksum = "07d6f157065c3461096d51aacde0c326fa49f3f6e0199e204c566842cdaa5299" dependencies = [ "base64", "bytes", @@ -316,15 +468,14 @@ dependencies = [ "memchr", "nkeys", "nuid", - "once_cell", "pin-project", "portable-atomic", "rand 0.8.5", "regex", "ring", - "rustls-native-certs 0.7.3", + "rustls-native-certs", "rustls-pki-types", - "rustls-webpki 0.102.8", + "rustls-webpki 0.103.10", "serde", "serde_json", "serde_nanos", @@ -473,9 +624,9 @@ dependencies = [ [[package]] name = "aws-config" -version = "1.8.12" +version = "1.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96571e6996817bf3d58f6b569e4b9fd2e9d2fcf9f7424eed07b2ce9bb87535e5" +checksum = "11493b0bad143270fb8ad284a096dd529ba91924c5409adeac856cc1bf047dbc" dependencies = [ "aws-credential-types", "aws-runtime", @@ -493,7 +644,7 @@ dependencies = [ "fastrand", "hex", "http 1.4.0", - "ring", + "sha1", "time", "tokio", "tracing", @@ -503,9 +654,9 @@ dependencies = [ [[package]] name = "aws-credential-types" -version = "1.2.11" +version = "1.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cd362783681b15d136480ad555a099e82ecd8e2d10a841e14dfd0078d67fee3" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -538,9 +689,9 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.5.18" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "959dab27ce613e6c9658eb3621064d0e2027e5f2acb65bc526a43577facea557" +checksum = "5fc0651c57e384202e47153c1260b84a9936e19803d747615edf199dc3b98d17" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -552,9 +703,12 @@ dependencies = [ "aws-smithy-types", "aws-types", "bytes", + "bytes-utils", "fastrand", "http 0.2.12", + "http 1.4.0", "http-body 0.4.6", + "http-body 1.0.1", "percent-encoding", "pin-project-lite", "tracing", @@ -563,9 +717,9 @@ dependencies = [ [[package]] name = "aws-sdk-s3" -version = "1.121.0" +version = "1.128.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61948728b681f88a1e49b9500469cf9e36575a424e745e2c5a651a42386e7d9c" +checksum = "99304b64672e0d81a3c100a589b93d9ef5e9c0ce12e21c848fd39e50f493c2a1" dependencies = [ "aws-credential-types", "aws-runtime", @@ -587,7 +741,7 @@ dependencies = [ "hmac", "http 0.2.12", "http 1.4.0", - "http-body 0.4.6", + "http-body 1.0.1", "lru", "percent-encoding", "regex-lite", @@ -598,9 +752,9 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.92.0" +version = "1.97.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7d63bd2bdeeb49aa3f9b00c15e18583503b778b2e792fc06284d54e7d5b6566" +checksum = "9aadc669e184501caaa6beafb28c6267fc1baef0810fb58f9b205485ca3f2567" dependencies = [ "aws-credential-types", "aws-runtime", @@ -615,15 +769,16 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", + "http 1.4.0", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-ssooidc" -version = "1.94.0" +version = "1.99.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "532d93574bf731f311bafb761366f9ece345a0416dbcc273d81d6d1a1205239b" +checksum = "1342a7db8f358d3de0aed2007a0b54e875458e39848d54cc1d46700b2bfcb0a8" dependencies = [ "aws-credential-types", "aws-runtime", @@ -638,15 +793,16 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", + "http 1.4.0", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-sts" -version = "1.96.0" +version = "1.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357e9a029c7524db6a0099cd77fbd5da165540339e7296cca603531bc783b56c" +checksum = "ab41ad64e4051ecabeea802d6a17845a91e83287e1dd249e6963ea1ba78c428a" dependencies = [ "aws-credential-types", "aws-runtime", @@ -662,15 +818,16 @@ dependencies = [ "aws-types", "fastrand", "http 0.2.12", + "http 1.4.0", "regex-lite", "tracing", ] [[package]] name = "aws-sigv4" -version = "1.3.7" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69e523e1c4e8e7e8ff219d732988e22bfeae8a1cafdbe6d9eca1546fa080be7c" +checksum = "b0b660013a6683ab23797778e21f1f854744fdf05f68204b4cca4c8c04b5d1f4" dependencies = [ "aws-credential-types", "aws-smithy-eventstream", @@ -696,9 +853,9 @@ dependencies = [ [[package]] name = "aws-smithy-async" -version = "1.2.10" +version = "1.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e39f47abe8641e434de98e047e85ced629862e7ab719b6914a846796ceb289e2" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" dependencies = [ "futures-util", "pin-project-lite", @@ -707,17 +864,18 @@ dependencies = [ [[package]] name = "aws-smithy-checksums" -version = "0.63.13" +version = "0.64.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23374b9170cbbcc6f5df8dc5ebb9b6c5c28a3c8f599f0e8b8b10eb6f4a5c6e74" +checksum = "6750f3dd509b0694a4377f0293ed2f9630d710b1cebe281fa8bac8f099f88bc6" dependencies = [ "aws-smithy-http", "aws-smithy-types", "bytes", "crc-fast", "hex", - "http 0.2.12", - "http-body 0.4.6", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", "md-5", "pin-project-lite", "sha1", @@ -727,9 +885,9 @@ dependencies = [ [[package]] name = "aws-smithy-eventstream" -version = "0.60.17" +version = "0.60.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff6a1db93c9aaf8c8e5d97f055e5fa01a782bd5bdc9c4042b7e18090503b12a7" +checksum = "faf09d74e5e32f76b8762da505a3cd59303e367a664ca67295387baa8c1d7548" dependencies = [ "aws-smithy-types", "bytes", @@ -738,9 +896,9 @@ dependencies = [ [[package]] name = "aws-smithy-http" -version = "0.62.6" +version = "0.63.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "826141069295752372f8203c17f28e30c464d22899a43a0c9fd9c458d469c88b" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" dependencies = [ "aws-smithy-eventstream", "aws-smithy-runtime-api", @@ -749,9 +907,9 @@ dependencies = [ "bytes-utils", "futures-core", "futures-util", - "http 0.2.12", "http 1.4.0", - "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", "percent-encoding", "pin-project-lite", "pin-utils", @@ -760,9 +918,9 @@ dependencies = [ [[package]] name = "aws-smithy-http-client" -version = "1.1.8" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a395c914b1ff95db3cb7003ea4fd19432343af698a9b5028a7d35f8e712240a1" +checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -780,7 +938,7 @@ dependencies = [ "pin-project-lite", "rustls 0.21.12", "rustls 0.23.36", - "rustls-native-certs 0.8.3", + "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", @@ -790,27 +948,27 @@ dependencies = [ [[package]] name = "aws-smithy-json" -version = "0.61.9" +version = "0.62.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49fa1213db31ac95288d981476f78d05d9cbb0353d22cdf3472cc05bb02f6551" +checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a" dependencies = [ "aws-smithy-types", ] [[package]] name = "aws-smithy-observability" -version = "0.2.3" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7764bc1dfdb71157bc481528a649e617ed8c9c8aa93c0e8b01087133677cfc8e" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" dependencies = [ "aws-smithy-runtime-api", ] [[package]] name = "aws-smithy-query" -version = "0.60.12" +version = "0.60.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "786bbf4434ed3e3413c5a1741abf53a0372dd48124ddb2091e6bff1b1b2582b5" +checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" dependencies = [ "aws-smithy-types", "urlencoding", @@ -818,9 +976,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.9.8" +version = "1.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb5b6167fcdf47399024e81ac08e795180c576a20e4d4ce67949f9a88ae37dc1" +checksum = "028999056d2d2fd58a697232f9eec4a643cf73a71cf327690a7edad1d2af2110" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -834,6 +992,7 @@ dependencies = [ "http 1.4.0", "http-body 0.4.6", "http-body 1.0.1", + "http-body-util", "pin-project-lite", "pin-utils", "tokio", @@ -842,9 +1001,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.11.2" +version = "1.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b743e9aab0b8d50a9a40eebedf974fcfe3621032e07c6388d1c7821b155b7b0" +checksum = "876ab3c9c29791ba4ba02b780a3049e21ec63dabda09268b175272c3733a79e6" dependencies = [ "aws-smithy-async", "aws-smithy-types", @@ -859,9 +1018,9 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.4.2" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0828575b70da70406b4cdb5d4afe0afe725f72245f04d34f02e0fb5ebd6fc872" +checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c" dependencies = [ "base64-simd", "bytes", @@ -885,18 +1044,18 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.60.13" +version = "0.60.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11b2f670422ff42bf7065031e72b45bc52a3508bd089f743ea90731ca2b6ea57" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" dependencies = [ "xmlparser", ] [[package]] name = "aws-types" -version = "1.3.11" +version = "1.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d980627d2dd7bfc32a3c025685a033eeab8d365cc840c631ef59d1b8f428164" +checksum = "47c8323699dd9b3c8d5b3c13051ae9cdef58fd179957c882f8374dd8725962d9" dependencies = [ "aws-credential-types", "aws-smithy-async", @@ -999,7 +1158,7 @@ dependencies = [ [[package]] name = "axumkit_config" -version = "0.5.0" +version = "0.7.0" dependencies = [ "axum", "dotenvy", @@ -1016,7 +1175,7 @@ dependencies = [ [[package]] name = "axumkit_dto" -version = "0.5.0" +version = "0.7.0" dependencies = [ "axum", "axum-extra", @@ -1030,6 +1189,7 @@ dependencies = [ "serde", "serde_json", "tracing", + "unicode-general-category", "utoipa", "uuid", "validator", @@ -1037,7 +1197,7 @@ dependencies = [ [[package]] name = "axumkit_entity" -version = "0.5.0" +version = "0.7.0" dependencies = [ "sea-orm", "serde", @@ -1047,7 +1207,7 @@ dependencies = [ [[package]] name = "axumkit_errors" -version = "0.5.0" +version = "0.7.0" dependencies = [ "axum", "axumkit_config", @@ -1059,7 +1219,7 @@ dependencies = [ [[package]] name = "axumkit_server" -version = "0.5.0" +version = "0.7.0" dependencies = [ "anyhow", "argon2", @@ -1083,9 +1243,10 @@ dependencies = [ "hex", "image", "infer", + "jsonwebtoken", "meilisearch-sdk", "oauth2", - "rand 0.9.2", + "rand 0.10.0", "redis", "reqwest", "sea-orm", @@ -1109,7 +1270,7 @@ dependencies = [ [[package]] name = "axumkit_worker" -version = "0.5.0" +version = "0.7.0" dependencies = [ "anyhow", "async-nats", @@ -1244,16 +1405,16 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.3" +version = "1.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" dependencies = [ "arrayref", "arrayvec", "cc", "cfg-if", "constant_time_eq 0.4.2", - "cpufeatures", + "cpufeatures 0.3.0", ] [[package]] @@ -1301,6 +1462,16 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "built" version = "0.8.0" @@ -1355,9 +1526,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" dependencies = [ "serde", ] @@ -1407,11 +1578,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -1431,16 +1613,6 @@ dependencies = [ "phf", ] -[[package]] -name = "chumsky" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" -dependencies = [ - "hashbrown 0.14.5", - "stacker", -] - [[package]] name = "clap" version = "4.5.56" @@ -1531,6 +1703,26 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -1625,6 +1817,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.3.0" @@ -1669,7 +1870,7 @@ checksum = "4aa42bcd3d846ebf66e15bd528d1087f75d1c6c1c66ebff626178a106353c576" dependencies = [ "chrono", "derive_builder", - "strum", + "strum 0.27.2", ] [[package]] @@ -1760,7 +1961,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -1961,7 +2162,7 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "e2e" -version = "0.5.0" +version = "0.7.0" dependencies = [ "anyhow", "axumkit_dto", @@ -2316,9 +2517,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -2331,9 +2532,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -2341,15 +2542,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -2369,9 +2570,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -2388,9 +2589,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -2399,21 +2600,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -2423,7 +2624,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -2459,11 +2659,25 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.0", + "wasip2", + "wasip3", +] + [[package]] name = "gif" version = "0.14.1" @@ -2549,6 +2763,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "num-traits", "zerocopy", ] @@ -2561,16 +2776,6 @@ dependencies = [ "ahash 0.7.8", ] -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash 0.8.12", - "allocator-api2", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -2833,7 +3038,7 @@ dependencies = [ "hyper 1.8.1", "hyper-util", "rustls 0.23.36", - "rustls-native-certs 0.8.3", + "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", @@ -2875,7 +3080,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2 0.6.3", "system-configuration", "tokio", "tower-service", @@ -2988,6 +3193,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -3017,9 +3228,9 @@ dependencies = [ [[package]] name = "image" -version = "0.25.9" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", @@ -3035,8 +3246,8 @@ dependencies = [ "rayon", "rgb", "tiff", - "zune-core 0.5.1", - "zune-jpeg 0.5.12", + "zune-core", + "zune-jpeg", ] [[package]] @@ -3192,9 +3403,11 @@ dependencies = [ "base64", "getrandom 0.2.17", "js-sys", + "pem", "serde", "serde_json", "signature 2.2.0", + "simple_asn1", ] [[package]] @@ -3215,6 +3428,12 @@ dependencies = [ "spin 0.9.8", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "lebe" version = "0.5.3" @@ -3223,13 +3442,12 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "lettre" -version = "0.11.19" +version = "0.11.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" +checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7" dependencies = [ "async-trait", "base64", - "chumsky", "email-encoding", "email_address", "fastrand", @@ -3243,17 +3461,74 @@ dependencies = [ "nom", "percent-encoding", "quoted_printable", - "socket2 0.6.2", + "socket2 0.6.3", "tokio", "tokio-native-tls", "url", ] +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + [[package]] name = "libc" -version = "0.2.180" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libfuzzer-sys" @@ -3352,6 +3627,17 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "serde", + "winapi", +] + [[package]] name = "matchers" version = "0.2.0" @@ -3442,6 +3728,15 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "migration" version = "0.1.0" @@ -3449,7 +3744,7 @@ dependencies = [ "async-std", "dotenvy", "sea-orm-migration", - "strum", + "strum 0.28.0", ] [[package]] @@ -3470,12 +3765,11 @@ dependencies = [ [[package]] name = "minijinja" -version = "2.15.1" +version = "2.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b479616bb6f0779fb0f3964246beda02d4b01144e1b0d5519616e012ccc2a245" +checksum = "805bfd7352166bae857ee569628b52bcd85a1cecf7810861ebceb1686b72b75d" dependencies = [ "memo-map", - "self_cell", "serde", ] @@ -3491,9 +3785,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", @@ -3502,9 +3796,9 @@ dependencies = [ [[package]] name = "moxcms" -version = "0.7.11" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ "num-traits", "pxfm", @@ -3567,6 +3861,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nkeys" version = "0.4.5" @@ -3636,9 +3943,18 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", - "smallvec", - "zeroize", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", ] [[package]] @@ -3719,15 +4035,6 @@ dependencies = [ "url", ] -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -3892,6 +4199,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -4081,6 +4398,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -4159,16 +4486,6 @@ version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" -[[package]] -name = "psm" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa96cb91275ed31d6da3e983447320c4eb219ac180fa1679a0889ff32861e2d" -dependencies = [ - "ar_archive_writer", - "cc", -] - [[package]] name = "ptr_meta" version = "0.1.4" @@ -4253,7 +4570,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.36", - "socket2 0.6.2", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -4290,7 +4607,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.2", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -4316,6 +4633,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "0.7.0" @@ -4343,6 +4666,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -4381,6 +4715,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + [[package]] name = "rav1e" version = "0.8.1" @@ -4418,9 +4758,9 @@ dependencies = [ [[package]] name = "ravif" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" dependencies = [ "avif-serialize", "imgref", @@ -4453,12 +4793,13 @@ dependencies = [ [[package]] name = "redis" -version = "1.0.3" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e969d1d702793536d5fda739a82b88ad7cbe7d04f8386ee8cd16ad3eff4854a5" +checksum = "f44e94c96d8870a387d88ce3de3fdd608cbfc0705f03cb343cdde91509d3e49a" dependencies = [ "arc-swap", "arcstr", + "async-lock", "backon", "bytes", "cfg-if", @@ -4472,7 +4813,7 @@ dependencies = [ "pin-project-lite", "ryu", "sha1_smol", - "socket2 0.6.2", + "socket2 0.6.3", "tokio", "tokio-native-tls", "tokio-util", @@ -4772,24 +5113,11 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.9", + "rustls-webpki 0.103.10", "subtle", "zeroize", ] -[[package]] -name = "rustls-native-certs" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" -dependencies = [ - "openssl-probe 0.1.6", - "rustls-pemfile", - "rustls-pki-types", - "schannel", - "security-framework 2.11.1", -] - [[package]] name = "rustls-native-certs" version = "0.8.3" @@ -4802,15 +5130,6 @@ dependencies = [ "security-framework 3.5.1", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -4833,19 +5152,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.102.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" -dependencies = [ - "rustls-pki-types", - "untrusted 0.9.0", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.9" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "aws-lc-rs", "ring", @@ -4914,9 +5223,9 @@ dependencies = [ [[package]] name = "sea-orm" -version = "2.0.0-rc.30" +version = "2.0.0-rc.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4bb965a287ae073c738851c5d38037ac6da66c9841ac1de7c13c8d08862180a" +checksum = "4b846dc1c7fefbea372c03765ff08307d68894bbad8c73b66176dcd53a3ee131" dependencies = [ "async-stream", "async-trait", @@ -4927,9 +5236,11 @@ dependencies = [ "ipnetwork", "itertools", "log", + "mac_address", "ouroboros", "pgvector", "rust_decimal", + "sea-orm-arrow", "sea-orm-macros", "sea-query", "sea-query-sqlx", @@ -4937,7 +5248,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "strum", + "strum 0.27.2", "thiserror 2.0.18", "time", "tracing", @@ -4945,11 +5256,22 @@ dependencies = [ "uuid", ] +[[package]] +name = "sea-orm-arrow" +version = "2.0.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2eee8405f16c1f337fe3a83389361caea83c928d14dbd666a480407072c365" +dependencies = [ + "arrow", + "sea-query", + "thiserror 2.0.18", +] + [[package]] name = "sea-orm-cli" -version = "2.0.0-rc.30" +version = "2.0.0-rc.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78e98371444bce42bd3f61b46a14de270e9e923a3f40889edba52f1623b4cf75" +checksum = "cd9b34d4c8e615079c04eb7863a429c2d2a8bf9c934eb9eeb580f51f36367124" dependencies = [ "chrono", "clap", @@ -4967,9 +5289,9 @@ dependencies = [ [[package]] name = "sea-orm-macros" -version = "2.0.0-rc.30" +version = "2.0.0-rc.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3e208f041129ad7962b6951f0b392e9ff97a8337bd8c7022c61e7b02ab29fe0" +checksum = "b449fe660e4d365f335222025df97ae01e670ef7ad788b3c67db9183b6cb0474" dependencies = [ "heck 0.5.0", "itertools", @@ -4983,9 +5305,9 @@ dependencies = [ [[package]] name = "sea-orm-migration" -version = "2.0.0-rc.30" +version = "2.0.0-rc.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45f6d4fda5c02f2cdee10062b260dc808691ceb122e1d5f7dd463695173b6f03" +checksum = "b3ceb928aac8be83332d34d1fdbc827d43696135a800723ffeb2e0b33b7b495e" dependencies = [ "async-trait", "clap", @@ -4999,9 +5321,9 @@ dependencies = [ [[package]] name = "sea-query" -version = "1.0.0-rc.30" +version = "1.0.0-rc.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6a067a2f6f13250f615f0bedb5bc3a6c872fec70776d0b43b43caeaa699e232" +checksum = "58decdaaaf2a698170af2fa1b2e8f7b43a970e7768bf18aebaab113bada46354" dependencies = [ "chrono", "inherent", @@ -5119,12 +5441,6 @@ dependencies = [ "libc", ] -[[package]] -name = "self_cell" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" - [[package]] name = "semver" version = "1.0.27" @@ -5237,7 +5553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -5254,7 +5570,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -5338,9 +5654,24 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "similar" -version = "2.7.0" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +checksum = "26d0b06eba54f0ca0770f970a3e89823e766ca638dd940f8469fa0fa50c75396" +dependencies = [ + "bstr", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] [[package]] name = "siphasher" @@ -5385,12 +5716,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5642,19 +5973,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "stacker" -version = "0.1.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59" -dependencies = [ - "cc", - "cfg-if", - "libc", - "psm", - "windows-sys 0.59.0", -] - [[package]] name = "static_assertions" version = "1.1.0" @@ -5707,7 +6025,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -5722,6 +6049,18 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "subtle" version = "2.6.1" @@ -5861,23 +6200,23 @@ dependencies = [ [[package]] name = "tiff" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" dependencies = [ "fax", "flate2", "half", "quick-error", "weezl", - "zune-jpeg 0.4.21", + "zune-jpeg", ] [[package]] name = "time" -version = "0.3.46" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", @@ -5896,14 +6235,23 @@ checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.26" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -5931,9 +6279,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" dependencies = [ "bytes", "libc", @@ -5941,7 +6289,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -5964,9 +6312,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -6081,9 +6429,9 @@ dependencies = [ [[package]] name = "totp-rs" -version = "5.7.0" +version = "5.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f124352108f58ef88299e909f6e9470f1cdc8d2a1397963901b4a6366206bf72" +checksum = "a2b36a9dd327e9f401320a2cb4572cc76ff43742bcfc3291f871691050f140ba" dependencies = [ "base32", "constant_time_eq 0.3.1", @@ -6230,9 +6578,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -6283,6 +6631,12 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -6404,11 +6758,11 @@ dependencies = [ [[package]] name = "uuid" -version = "1.20.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", @@ -6519,6 +6873,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasite" version = "0.1.0" @@ -6584,6 +6947,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -6597,6 +6982,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.85" @@ -6651,6 +7048,22 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -6660,6 +7073,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -6748,15 +7167,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" @@ -6975,6 +7385,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" @@ -7209,12 +7701,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "zune-core" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" - [[package]] name = "zune-core" version = "0.5.1" @@ -7230,20 +7716,11 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "zune-jpeg" -version = "0.4.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" -dependencies = [ - "zune-core 0.4.12", -] - [[package]] name = "zune-jpeg" version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" dependencies = [ - "zune-core 0.5.1", + "zune-core", ] diff --git a/Cargo.toml b/Cargo.toml index a86d658..d5fe5ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package] -version = "0.5.0" +version = "0.7.0" authors = ["Levi Laine "] repository = "https://github.com/levish0/AxumKit" edition = "2024" @@ -21,7 +21,7 @@ axumkit_worker = { path = "crates/axumkit-worker" } axumkit_server = { path = "crates/axumkit-server" } # External dependencies -tokio = { version = "1.49.0", features = ["full"] } +tokio = { version = "1.51.0", features = ["full"] } axum = { version = "0.8.8", features = ["multipart", "macros"] } axum-extra = { version = "0.12.5", features = ["typed-header", "query"] } tower = { version = "0.5.3", features = ["timeout", "limit", "buffer"] } @@ -29,44 +29,46 @@ tower-http = { version = "0.6.8", features = ["cors", "auth", "trace", "request- tower-cookies = "0.11.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" -sea-orm = { version = "2.0.0-rc.30", features = ["postgres-array", "sqlx-postgres", "runtime-tokio-native-tls", "with-ipnetwork"] } -redis = { version = "1.0.3", features = ["tokio-comp", "tokio-native-tls-comp", "connection-manager"] } +sea-orm = { version = "2.0.0-rc.37", features = ["postgres-array", "sqlx-postgres", "runtime-tokio-native-tls", "with-ipnetwork"] } +redis = { version = "1.2.0", features = ["tokio-comp", "tokio-native-tls-comp", "connection-manager"] } tokio-cron-scheduler = "0.15.1" argon2 = "0.5.3" oauth2 = { version = "5.0.0", features = ["reqwest"] } -totp-rs = { version = "5.7.0", features = ["qr"] } +totp-rs = { version = "5.7.1", features = ["qr"] } cookie = "0.18.1" -aws-sdk-s3 = "1.121.0" -aws-config = { version = "1.8.12", features = ["behavior-version-latest"] } +aws-sdk-s3 = "1.128.0" +aws-config = { version = "1.8.15", features = ["behavior-version-latest"] } meilisearch-sdk = { version = "0.32.0" } utoipa = { version = "5.4.0", features = ["axum_extras", "openapi_extensions", "time", "uuid", "chrono"] } utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "debug-embed"] } validator = { version = "0.20.0", features = ["derive"] } tracing = "0.1.44" -tracing-subscriber = { version = "0.3.22", features = ["json", "env-filter"] } +tracing-subscriber = { version = "0.3.23", features = ["json", "env-filter"] } tracing-appender = "0.2.4" reqwest = { version = "0.12.28", features = ["json", "multipart"] } -anyhow = "1.0.100" -chrono = { version = "0.4.43", features = ["serde"] } +anyhow = "1.0.102" +chrono = { version = "0.4.44", features = ["serde"] } chrono-tz = "0.10.4" -uuid = { version = "1.20.0", features = ["v7", "v4"] } +uuid = { version = "1.23.0", features = ["v7", "v4"] } dotenvy = "0.15.7" -rand = "0.9.2" +rand = "0.10.0" hex = "0.4.3" base64 = "0.22.1" -image = { version = "0.25.9", features = ["webp"] } +image = { version = "0.25.10", features = ["webp"] } infer = "0.19.0" -blake3 = "1.8.3" +blake3 = "1.8.4" zstd = "0.13.3" -lettre = { version = "0.11.19", features = ["tokio1-native-tls"] } +lettre = { version = "0.11.21", features = ["tokio1-native-tls"] } mrml = "5.1.0" -minijinja = { version = "2.15.1", features = ["loader"] } -similar = "2.7.0" -futures = "0.3.31" -async-nats = "0.46.0" +minijinja = { version = "2.19.0", features = ["loader"] } +similar = "3.0.0" +futures = "0.3.32" +async-nats = "0.47.0" tokio-stream = { version = "0.1.18", features = ["sync"] } sitemap-rs = "0.4.0" urlencoding = "2.1.3" +unicode-general-category = "1.1.0" +jsonwebtoken = "10.3.0" [profile.dev] opt-level = 1 diff --git a/charts/README.md b/charts/README.md index fd8599e..7b8845e 100644 --- a/charts/README.md +++ b/charts/README.md @@ -129,7 +129,6 @@ appVersion: "0.4.0" | redis | 24.1.2 | bitnami | | nats | 2.12.3 | nats-io | | meilisearch | 0.21.0 | meilisearch | -| seaweedfs | 4.0.407 | seaweedfs | ## Troubleshooting diff --git a/charts/axumkit-server/values.yaml b/charts/axumkit-server/values.yaml index e1ec2fd..dc16c0f 100644 --- a/charts/axumkit-server/values.yaml +++ b/charts/axumkit-server/values.yaml @@ -145,9 +145,6 @@ secret: # NATS (optional, default: nats://localhost:4222) NATS_URL: "" - # SeaweedFS (required) - SEAWEEDFS_ENDPOINT: "" - # Google OAuth (required) GOOGLE_CLIENT_ID: "" GOOGLE_CLIENT_SECRET: "" @@ -161,8 +158,8 @@ secret: # Cloudflare R2 (required) R2_ENDPOINT: "" R2_REGION: "auto" - R2_PUBLIC_DOMAIN: "" - R2_BUCKET_NAME: "" + R2_ASSETS_PUBLIC_DOMAIN: "" + R2_ASSETS_BUCKET_NAME: "" R2_ACCESS_KEY_ID: "" R2_SECRET_ACCESS_KEY: "" diff --git a/charts/axumkit-worker/values.yaml b/charts/axumkit-worker/values.yaml index 8f3f714..8b7f5c3 100644 --- a/charts/axumkit-worker/values.yaml +++ b/charts/axumkit-worker/values.yaml @@ -104,14 +104,11 @@ secret: # NATS (optional, default: nats://localhost:4222) NATS_URL: "" - # SeaweedFS (required) - SEAWEEDFS_ENDPOINT: "" - # Cloudflare R2 (required for sitemap) R2_ENDPOINT: "" R2_REGION: "auto" - R2_PUBLIC_DOMAIN: "" - R2_BUCKET_NAME: "" + R2_ASSETS_PUBLIC_DOMAIN: "" + R2_ASSETS_BUCKET_NAME: "" R2_ACCESS_KEY_ID: "" R2_SECRET_ACCESS_KEY: "" diff --git a/charts/axumkit/Chart.yaml b/charts/axumkit/Chart.yaml index 2013fe1..9849980 100644 --- a/charts/axumkit/Chart.yaml +++ b/charts/axumkit/Chart.yaml @@ -62,8 +62,3 @@ dependencies: version: "0.21.0" repository: "https://meilisearch.github.io/meilisearch-kubernetes" condition: meilisearch.enabled - - - name: seaweedfs - version: "4.0.407" - repository: "https://seaweedfs.github.io/seaweedfs/helm" - condition: seaweedfs.enabled diff --git a/charts/axumkit/values-local.yaml.example b/charts/axumkit/values-local.yaml.example index 819f61e..581ca96 100644 --- a/charts/axumkit/values-local.yaml.example +++ b/charts/axumkit/values-local.yaml.example @@ -29,8 +29,8 @@ axumkit-server: GITHUB_REDIRECT_URI: "http://localhost:8000/v0/auth/github/callback" R2_ENDPOINT: "" - R2_PUBLIC_DOMAIN: "" - R2_BUCKET_NAME: "" + R2_ASSETS_PUBLIC_DOMAIN: "" + R2_ASSETS_BUCKET_NAME: "" R2_ACCESS_KEY_ID: "" R2_SECRET_ACCESS_KEY: "" @@ -55,8 +55,8 @@ axumkit-worker: POSTGRES_WRITE_PASSWORD: "axumkit_secret" R2_ENDPOINT: "" - R2_PUBLIC_DOMAIN: "" - R2_BUCKET_NAME: "" + R2_ASSETS_PUBLIC_DOMAIN: "" + R2_ASSETS_BUCKET_NAME: "" R2_ACCESS_KEY_ID: "" R2_SECRET_ACCESS_KEY: "" diff --git a/charts/axumkit/values.yaml b/charts/axumkit/values.yaml index 1160396..b2d32d9 100644 --- a/charts/axumkit/values.yaml +++ b/charts/axumkit/values.yaml @@ -38,7 +38,6 @@ axumkit-server: POSTGRES_READ_PASSWORD: "" NATS_URL: "nats://axumkit-nats:4222" - SEAWEEDFS_ENDPOINT: "http://axumkit-seaweedfs-filer:8888" GOOGLE_CLIENT_ID: "" GOOGLE_CLIENT_SECRET: "" @@ -50,8 +49,8 @@ axumkit-server: R2_ENDPOINT: "" R2_REGION: "auto" - R2_PUBLIC_DOMAIN: "" - R2_BUCKET_NAME: "" + R2_ASSETS_PUBLIC_DOMAIN: "" + R2_ASSETS_BUCKET_NAME: "" R2_ACCESS_KEY_ID: "" R2_SECRET_ACCESS_KEY: "" @@ -92,12 +91,11 @@ axumkit-worker: POSTGRES_WRITE_PASSWORD: "" NATS_URL: "nats://axumkit-nats:4222" - SEAWEEDFS_ENDPOINT: "http://axumkit-seaweedfs-filer:8888" R2_ENDPOINT: "" R2_REGION: "auto" - R2_PUBLIC_DOMAIN: "" - R2_BUCKET_NAME: "" + R2_ASSETS_PUBLIC_DOMAIN: "" + R2_ASSETS_BUCKET_NAME: "" R2_ACCESS_KEY_ID: "" R2_SECRET_ACCESS_KEY: "" @@ -183,22 +181,3 @@ meilisearch: enabled: true size: 10Gi -# ============================================================================= -# SeaweedFS -# ============================================================================= -seaweedfs: - enabled: true - - master: - replicas: 1 - storage: 1Gi - - volume: - replicas: 1 - storage: 50Gi - - filer: - replicas: 1 - storage: 1Gi - s3: - enabled: true diff --git a/crates/axumkit-config/src/server_config.rs b/crates/axumkit-config/src/server_config.rs index 44cba34..58da535 100644 --- a/crates/axumkit-config/src/server_config.rs +++ b/crates/axumkit-config/src/server_config.rs @@ -8,10 +8,10 @@ use tracing::warn; pub struct ServerConfig { pub is_dev: bool, - pub totp_secret: String, // TOTP 백업 코드 해시용 시크릿 - pub auth_session_max_lifetime_hours: i64, // 세션 최대 수명 (시간) - pub auth_session_sliding_ttl_hours: i64, // 활동 시 연장 TTL (시간) - pub auth_session_refresh_threshold: u8, // TTL 갱신 임계값 (%) + pub totp_secret: String, // Secret for hashing TOTP backup codes + pub auth_session_max_lifetime_hours: i64, // Maximum session lifetime (hours) + pub auth_session_sliding_ttl_hours: i64, // Sliding TTL extended on activity (hours) + pub auth_session_refresh_threshold: u8, // TTL refresh threshold (%) pub auth_email_verification_token_expire_time: i64, // minutes pub auth_password_reset_token_expire_time: i64, // minutes pub auth_email_change_token_expire_time: i64, // minutes @@ -27,32 +27,26 @@ pub struct ServerConfig { pub github_client_secret: String, pub github_redirect_uri: String, - // Cloudflare + // Cloudflare R2 (shared credentials) pub r2_endpoint: String, pub r2_region: String, - pub r2_public_domain: String, - pub r2_bucket_name: String, pub r2_access_key_id: String, pub r2_secret_access_key: String, + // R2 Assets (public bucket - images, sitemap) + pub r2_assets_public_domain: String, + pub r2_assets_bucket_name: String, + + // Cloudflare Turnstile pub turnstile_secret_key: String, - // Write DB (Primary) - pub db_write_host: String, - pub db_write_port: String, - pub db_write_name: String, - pub db_write_user: String, - pub db_write_password: String, - pub db_write_max_connection: u32, - pub db_write_min_connection: u32, - - // Read DB (Replica) - pub db_read_host: String, - pub db_read_port: String, - pub db_read_name: String, - pub db_read_user: String, - pub db_read_password: String, - pub db_read_max_connection: u32, - pub db_read_min_connection: u32, + // Database (via PgDog connection pooler) + pub db_host: String, + pub db_port: String, + pub db_name: String, + pub db_user: String, + pub db_password: String, + pub db_max_connection: u32, + pub db_min_connection: u32, // Redis Session (persistent, for sessions/tokens/rate-limit) pub redis_session_host: String, @@ -73,9 +67,6 @@ pub struct ServerConfig { pub meilisearch_host: String, pub meilisearch_api_key: Option, - // SeaweedFS (revision content storage) - pub seaweedfs_endpoint: String, - pub cors_allowed_origins: Vec, pub cors_allowed_headers: Vec, pub cors_max_age: Option, @@ -89,12 +80,13 @@ pub struct ServerConfig { pub stability_timeout_secs: u64, // Request timeout in seconds (default: 30) } -// LazyLock으로 자동 초기화 +// Auto-initialized via LazyLock static CONFIG: LazyLock = LazyLock::new(|| { dotenv().ok(); let mut errors: Vec = Vec::new(); + // Required env vars (collects errors on missing, does not panic) macro_rules! require { ($name:expr) => { env::var($name).unwrap_or_else(|_| { @@ -104,6 +96,7 @@ static CONFIG: LazyLock = LazyLock::new(|| { }; } + // Required env vars + parsing (collects errors on missing/parse failure) macro_rules! require_parse { ($name:expr, $ty:ty) => {{ match env::var($name) { @@ -187,24 +180,18 @@ static CONFIG: LazyLock = LazyLock::new(|| { let github_redirect_uri = require!("GITHUB_REDIRECT_URI"); let r2_endpoint = require!("R2_ENDPOINT"); let r2_region = require!("R2_REGION"); - let r2_public_domain = require!("R2_PUBLIC_DOMAIN"); - let r2_bucket_name = require!("R2_BUCKET_NAME"); let r2_access_key_id = require!("R2_ACCESS_KEY_ID"); let r2_secret_access_key = require!("R2_SECRET_ACCESS_KEY"); + let r2_assets_public_domain = require!("R2_ASSETS_PUBLIC_DOMAIN"); + let r2_assets_bucket_name = require!("R2_ASSETS_BUCKET_NAME"); let turnstile_secret_key = require!("TURNSTILE_SECRET_KEY"); - let db_write_host = require!("POSTGRES_WRITE_HOST"); - let db_write_port = require!("POSTGRES_WRITE_PORT"); - let db_write_name = require!("POSTGRES_WRITE_NAME"); - let db_write_user = require!("POSTGRES_WRITE_USER"); - let db_write_password = require!("POSTGRES_WRITE_PASSWORD"); - let db_read_host = require!("POSTGRES_READ_HOST"); - let db_read_port = require!("POSTGRES_READ_PORT"); - let db_read_name = require!("POSTGRES_READ_NAME"); - let db_read_user = require!("POSTGRES_READ_USER"); - let db_read_password = require!("POSTGRES_READ_PASSWORD"); + let db_host = require!("POSTGRES_HOST"); + let db_port = require!("POSTGRES_PORT"); + let db_name = require!("POSTGRES_NAME"); + let db_user = require!("POSTGRES_USER"); + let db_password = require!("POSTGRES_PASSWORD"); let server_host = require!("HOST"); let server_port = require!("PORT"); - let seaweedfs_endpoint = require!("SEAWEEDFS_ENDPOINT"); // Required parsed vars let auth_session_max_lifetime_hours = require_parse!("AUTH_SESSION_MAX_LIFETIME_HOURS", i64); @@ -224,28 +211,32 @@ static CONFIG: LazyLock = LazyLock::new(|| { is_dev, totp_secret, - auth_session_max_lifetime_hours, - auth_session_sliding_ttl_hours, + auth_session_max_lifetime_hours: auth_session_max_lifetime_hours.max(0), + auth_session_sliding_ttl_hours: auth_session_sliding_ttl_hours.max(0), auth_session_refresh_threshold, auth_email_verification_token_expire_time: env::var( "AUTH_EMAIL_VERIFICATION_TOKEN_EXPIRE_TIME", ) .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(1), // 기본값 1시간 (minutes) + .and_then(|v| v.parse::().ok()) + .unwrap_or(1) + .max(0), // Default 1 hour (minutes) auth_password_reset_token_expire_time: env::var("AUTH_PASSWORD_RESET_TOKEN_EXPIRE_TIME") .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(15), // 기본값 15분 + .and_then(|v| v.parse::().ok()) + .unwrap_or(15) + .max(0), // Default 15 minutes auth_email_change_token_expire_time: env::var("AUTH_EMAIL_CHANGE_TOKEN_EXPIRE_TIME") .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(15), // 기본값 15분 + .and_then(|v| v.parse::().ok()) + .unwrap_or(15) + .max(0), // Default 15 minutes oauth_pending_signup_ttl_minutes: env::var("OAUTH_PENDING_SIGNUP_TTL_MINUTES") .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(10), // 기본값 10분 + .and_then(|v| v.parse::().ok()) + .unwrap_or(10) + .max(0), // Default 10 minutes // Google google_client_id, @@ -257,44 +248,32 @@ static CONFIG: LazyLock = LazyLock::new(|| { github_client_secret, github_redirect_uri, - // Cloudflare + // Cloudflare R2 (shared credentials) r2_endpoint, r2_region, - r2_public_domain, - r2_bucket_name, r2_access_key_id, r2_secret_access_key, + // R2 Assets (public bucket) + r2_assets_public_domain, + r2_assets_bucket_name, + + // Cloudflare Turnstile turnstile_secret_key, - // Write DB (Primary) - db_write_host, - db_write_port, - db_write_name, - db_write_user, - db_write_password, - db_write_max_connection: env::var("POSTGRES_WRITE_MAX_CONNECTION") + // Database (via PgDog connection pooler) + db_host, + db_port, + db_name, + db_user, + db_password, + db_max_connection: env::var("POSTGRES_MAX_CONNECTION") .ok() .and_then(|v| v.parse().ok()) - .unwrap_or(100), - db_write_min_connection: env::var("POSTGRES_WRITE_MIN_CONNECTION") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(10), - - // Read DB (Replica) - db_read_host, - db_read_port, - db_read_name, - db_read_user, - db_read_password, - db_read_max_connection: env::var("POSTGRES_READ_MAX_CONNECTION") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(100), - db_read_min_connection: env::var("POSTGRES_READ_MIN_CONNECTION") + .unwrap_or(30), + db_min_connection: env::var("POSTGRES_MIN_CONNECTION") .ok() .and_then(|v| v.parse().ok()) - .unwrap_or(10), + .unwrap_or(5), // Redis Session redis_session_host: env::var("REDIS_SESSION_HOST") @@ -329,9 +308,6 @@ static CONFIG: LazyLock = LazyLock::new(|| { cookie_domain: env::var("COOKIE_DOMAIN").ok().filter(|d| !d.is_empty()), - // SeaweedFS - seaweedfs_endpoint, - // Stability Layer stability_concurrency_limit: env::var("STABILITY_CONCURRENCY_LIMIT") .ok() @@ -349,7 +325,7 @@ static CONFIG: LazyLock = LazyLock::new(|| { }); impl ServerConfig { - // 이제 단순히 CONFIG에 접근만 하면 됨 + // Simply access the CONFIG static pub fn get() -> &'static ServerConfig { &CONFIG } diff --git a/crates/axumkit-constants/src/action_log_actions.rs b/crates/axumkit-constants/src/action_log_actions.rs index 0a62e8e..fffbf4a 100644 --- a/crates/axumkit-constants/src/action_log_actions.rs +++ b/crates/axumkit-constants/src/action_log_actions.rs @@ -3,45 +3,45 @@ use std::fmt; use std::str::FromStr; use utoipa::ToSchema; -/// Action Log Action enum (action_logs.action 필드에 저장됨) -/// 포맷: "{resource}:{operation}" +/// Action Log Action enum (stored in action_logs.action field) +/// Format: "{resource}:{operation}" #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] pub enum ActionLogAction { // ==================== Post Actions ==================== - /// 포스트 생성 + /// Post created #[serde(rename = "post:create")] PostCreate, - /// 포스트 편집 + /// Post edited #[serde(rename = "post:edit")] PostEdit, - /// 포스트 삭제 + /// Post deleted #[serde(rename = "post:delete")] PostDelete, // ==================== User Actions ==================== - /// 유저 생성 + /// User created #[serde(rename = "user:create")] UserCreate, - /// 유저 프로필 편집 + /// User profile edited #[serde(rename = "user:edit")] UserEdit, // ==================== Auth Actions ==================== - /// 로그인 + /// Login #[serde(rename = "auth:login")] AuthLogin, - /// 로그아웃 + /// Logout #[serde(rename = "auth:logout")] AuthLogout, - /// OAuth 로그인 + /// OAuth login #[serde(rename = "auth:oauth_login")] AuthOAuthLogin, // ==================== OAuth Actions ==================== - /// OAuth 연결 + /// OAuth connected #[serde(rename = "oauth:link")] OAuthLink, - /// OAuth 연결 해제 + /// OAuth disconnected #[serde(rename = "oauth:unlink")] OAuthUnlink, } diff --git a/crates/axumkit-constants/src/cache_keys.rs b/crates/axumkit-constants/src/cache_keys.rs index 6c8c875..9b44f48 100644 --- a/crates/axumkit-constants/src/cache_keys.rs +++ b/crates/axumkit-constants/src/cache_keys.rs @@ -30,3 +30,48 @@ pub fn oauth_pending_key(token: &str) -> String { pub fn oauth_pending_lock_key(token: &str) -> String { format!("{}{}", OAUTH_PENDING_LOCK_PREFIX, token) } + +/// Email verification token prefix. +/// Format: "email_verification:{token}" +pub const EMAIL_VERIFICATION_PREFIX: &str = "email_verification:"; + +/// Password reset token prefix. +/// Format: "password_reset:{token}" +pub const PASSWORD_RESET_PREFIX: &str = "password_reset:"; + +/// Email change token prefix. +/// Format: "email_change:{token}" +pub const EMAIL_CHANGE_PREFIX: &str = "email_change:"; + +/// Build email verification key. +pub fn email_verification_key(token: &str) -> String { + format!("{}{}", EMAIL_VERIFICATION_PREFIX, token) +} + +/// Build password reset key. +pub fn password_reset_key(token: &str) -> String { + format!("{}{}", PASSWORD_RESET_PREFIX, token) +} + +/// Build email change key. +pub fn email_change_key(token: &str) -> String { + format!("{}{}", EMAIL_CHANGE_PREFIX, token) +} + +/// Pending email signup email index prefix +/// Format: "email_signup:email:{email}" +pub const EMAIL_SIGNUP_EMAIL_PREFIX: &str = "email_signup:email:"; + +/// Pending email signup handle index prefix +/// Format: "email_signup:handle:{handle}" +pub const EMAIL_SIGNUP_HANDLE_PREFIX: &str = "email_signup:handle:"; + +/// Pending email signup email index key generation +pub fn email_signup_email_key(email: &str) -> String { + format!("{}{}", EMAIL_SIGNUP_EMAIL_PREFIX, email) +} + +/// Pending email signup handle index key generation +pub fn email_signup_handle_key(handle: &str) -> String { + format!("{}{}", EMAIL_SIGNUP_HANDLE_PREFIX, handle) +} diff --git a/crates/axumkit-constants/src/lib.rs b/crates/axumkit-constants/src/lib.rs index 979468f..c80c8d6 100644 --- a/crates/axumkit-constants/src/lib.rs +++ b/crates/axumkit-constants/src/lib.rs @@ -1,5 +1,6 @@ pub mod action_log_actions; pub mod cache_keys; +pub mod moderation_actions; pub mod nats_subjects; pub mod storage_keys; @@ -7,11 +8,16 @@ pub use action_log_actions::{ action_log_action_to_string, string_to_action_log_action, ActionLogAction, }; pub use cache_keys::{ - oauth_pending_key, oauth_pending_lock_key, oauth_state_key, OAUTH_PENDING_LOCK_PREFIX, - OAUTH_PENDING_PREFIX, OAUTH_STATE_PREFIX, OAUTH_STATE_TTL_SECONDS, + email_change_key, email_signup_email_key, email_signup_handle_key, email_verification_key, + oauth_pending_key, oauth_pending_lock_key, oauth_state_key, password_reset_key, + EMAIL_CHANGE_PREFIX, EMAIL_SIGNUP_EMAIL_PREFIX, EMAIL_SIGNUP_HANDLE_PREFIX, + EMAIL_VERIFICATION_PREFIX, OAUTH_PENDING_LOCK_PREFIX, OAUTH_PENDING_PREFIX, OAUTH_STATE_PREFIX, + OAUTH_STATE_TTL_SECONDS, PASSWORD_RESET_PREFIX, +}; +pub use moderation_actions::{ + moderation_action_to_string, string_to_moderation_action, ModerationAction, }; pub use nats_subjects::REALTIME_EVENTS_SUBJECT; pub use storage_keys::{ - user_image_key, BANNER_IMAGE_MAX_SIZE, POST_CONTENT_PREFIX, PROFILE_IMAGE_MAX_SIZE, - USER_IMAGES_PREFIX, + user_image_key, BANNER_IMAGE_MAX_SIZE, PROFILE_IMAGE_MAX_SIZE, USER_IMAGES_PREFIX, }; diff --git a/crates/axumkit-constants/src/moderation_actions.rs b/crates/axumkit-constants/src/moderation_actions.rs new file mode 100644 index 0000000..4c0ccb4 --- /dev/null +++ b/crates/axumkit-constants/src/moderation_actions.rs @@ -0,0 +1,59 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::str::FromStr; +use utoipa::ToSchema; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub enum ModerationAction { + #[serde(rename = "user:ban")] + UserBan, + #[serde(rename = "user:unban")] + UserUnban, + #[serde(rename = "user:grant_role")] + UserGrantRole, + #[serde(rename = "user:revoke_role")] + UserRevokeRole, + #[serde(rename = "search:reindex")] + SearchReindex, +} + +impl ModerationAction { + pub fn as_str(&self) -> &'static str { + match self { + ModerationAction::UserBan => "user:ban", + ModerationAction::UserUnban => "user:unban", + ModerationAction::UserGrantRole => "user:grant_role", + ModerationAction::UserRevokeRole => "user:revoke_role", + ModerationAction::SearchReindex => "search:reindex", + } + } +} + +impl fmt::Display for ModerationAction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl FromStr for ModerationAction { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "user:ban" => Ok(ModerationAction::UserBan), + "user:unban" => Ok(ModerationAction::UserUnban), + "user:grant_role" => Ok(ModerationAction::UserGrantRole), + "user:revoke_role" => Ok(ModerationAction::UserRevokeRole), + "search:reindex" => Ok(ModerationAction::SearchReindex), + _ => Err(format!("Unknown moderation action: {}", s)), + } + } +} + +pub fn moderation_action_to_string(action: ModerationAction) -> String { + action.as_str().to_string() +} + +pub fn string_to_moderation_action(s: &str) -> Option { + s.parse().ok() +} diff --git a/crates/axumkit-constants/src/storage_keys.rs b/crates/axumkit-constants/src/storage_keys.rs index 20344c3..d648548 100644 --- a/crates/axumkit-constants/src/storage_keys.rs +++ b/crates/axumkit-constants/src/storage_keys.rs @@ -1,8 +1,5 @@ //! R2 storage key prefixes and image size limits -/// Prefix for post content in SeaweedFS -pub const POST_CONTENT_PREFIX: &str = "posts"; - /// Prefix for user profile/banner images pub const USER_IMAGES_PREFIX: &str = "user-images"; diff --git a/crates/axumkit-dto/Cargo.toml b/crates/axumkit-dto/Cargo.toml index 3b5eddc..60c68c0 100644 --- a/crates/axumkit-dto/Cargo.toml +++ b/crates/axumkit-dto/Cargo.toml @@ -24,4 +24,5 @@ axum.workspace = true axum-extra.workspace = true cookie.workspace = true serde_json.workspace = true -tracing.workspace = true \ No newline at end of file +tracing.workspace = true +unicode-general-category.workspace = true \ No newline at end of file diff --git a/crates/axumkit-dto/src/action_logs/response/action_log.rs b/crates/axumkit-dto/src/action_logs/response/action_log.rs index 1c5591c..78ba12f 100644 --- a/crates/axumkit-dto/src/action_logs/response/action_log.rs +++ b/crates/axumkit-dto/src/action_logs/response/action_log.rs @@ -1,19 +1,17 @@ use axumkit_entity::action_logs::Model as ActionLogModel; use axumkit_entity::common::ActionResourceType; use chrono::{DateTime, Utc}; -use sea_orm::prelude::IpNetwork; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use utoipa::ToSchema; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +/// Response payload for action log response. pub struct ActionLogResponse { pub id: Uuid, pub action: String, pub actor_id: Option, - #[schema(value_type = Option)] - pub actor_ip: Option, pub resource_type: ActionResourceType, pub resource_id: Option, pub summary: String, @@ -28,7 +26,6 @@ impl From for ActionLogResponse { id: model.id, action: model.action, actor_id: model.actor_id, - actor_ip: model.actor_ip, resource_type: model.resource_type, resource_id: model.resource_id, summary: model.summary, diff --git a/crates/axumkit-dto/src/auth/request/change_email.rs b/crates/axumkit-dto/src/auth/request/change_email.rs index 6c797f9..e8c92a7 100644 --- a/crates/axumkit-dto/src/auth/request/change_email.rs +++ b/crates/axumkit-dto/src/auth/request/change_email.rs @@ -4,11 +4,11 @@ use validator::Validate; #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct ChangeEmailRequest { - /// 현재 비밀번호 (본인 확인용) + /// Current password (for identity verification) #[validate(length(min = 1))] pub password: String, - /// 새 이메일 주소 + /// New email address #[validate(email(message = "Invalid email format."))] pub new_email: String, } diff --git a/crates/axumkit-dto/src/auth/request/change_password.rs b/crates/axumkit-dto/src/auth/request/change_password.rs index 372b7d1..79be37c 100644 --- a/crates/axumkit-dto/src/auth/request/change_password.rs +++ b/crates/axumkit-dto/src/auth/request/change_password.rs @@ -4,11 +4,11 @@ use validator::Validate; #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct ChangePasswordRequest { - /// 현재 비밀번호 + /// Current password #[validate(length(min = 1))] pub current_password: String, - /// 새 비밀번호 + /// New password #[validate(length( min = 6, max = 20, diff --git a/crates/axumkit-dto/src/auth/request/complete_signup.rs b/crates/axumkit-dto/src/auth/request/complete_signup.rs index e30159e..1a187dc 100644 --- a/crates/axumkit-dto/src/auth/request/complete_signup.rs +++ b/crates/axumkit-dto/src/auth/request/complete_signup.rs @@ -1,21 +1,54 @@ -use crate::validator::string_validator::validate_not_blank; +use crate::validator::string_validator::{ + validate_display_name, validate_handle, validate_not_blank, +}; use serde::Deserialize; use utoipa::ToSchema; use validator::Validate; -/// OAuth pending signup 완료 요청 +/// OAuth pending signup completion request #[derive(Debug, Clone, Deserialize, Validate, ToSchema)] +#[schema( + description = "Request body for completing an OAuth signup after a provider login for a new user." +)] pub struct CompleteSignupRequest { - /// Pending signup 토큰 (OAuth 로그인 시 반환됨) + /// Pending signup token (returned during OAuth sign-in) #[validate(length(min = 1, message = "Pending token is required"))] pub pending_token: String, - /// 사용자 핸들 (고유 식별자) + /// Unique user handle. + /// + /// - Length: 4–15 characters + /// - Allowed characters: `a-z`, `A-Z`, `0-9`, `_` + /// - Cannot start or end with `_` + /// - No consecutive underscores (`__`) + /// - Reserved words are not allowed (e.g. `admin`, `support`, `help`, `system`) + #[schema( + min_length = 4, + max_length = 15, + pattern = "^[a-zA-Z0-9][a-zA-Z0-9_]*[a-zA-Z0-9]$", + example = "john_doe" + )] #[validate(length( - min = 3, - max = 20, - message = "Handle must be between 3 and 20 characters" + min = 4, + max = 15, + message = "Handle must be between 4 and 15 characters" ))] #[validate(custom(function = "validate_not_blank"))] + #[validate(custom(function = "validate_handle"))] pub handle: String, + + /// Display name shown in the UI. + /// + /// - Length: 1-50 characters + /// - Unicode letters, spaces, and punctuation are permitted + /// - Emoji, control characters, and invisible Unicode are not allowed + #[schema(min_length = 1, max_length = 50, example = "John Doe")] + #[validate(length( + min = 1, + max = 50, + message = "Display name must be between 1 and 50 characters" + ))] + #[validate(custom(function = "validate_not_blank"))] + #[validate(custom(function = "validate_display_name"))] + pub display_name: String, } diff --git a/crates/axumkit-dto/src/auth/request/confirm_email_change.rs b/crates/axumkit-dto/src/auth/request/confirm_email_change.rs index d19e706..9b8c10b 100644 --- a/crates/axumkit-dto/src/auth/request/confirm_email_change.rs +++ b/crates/axumkit-dto/src/auth/request/confirm_email_change.rs @@ -4,7 +4,7 @@ use validator::Validate; #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct ConfirmEmailChangeRequest { - /// 이메일 변경 토큰 (이메일 링크의 ?token= 값) + /// Email change token (?token= value from the email link) #[validate(length(min = 1))] pub token: String, } diff --git a/crates/axumkit-dto/src/auth/request/forgot_password.rs b/crates/axumkit-dto/src/auth/request/forgot_password.rs index f544d58..25c096f 100644 --- a/crates/axumkit-dto/src/auth/request/forgot_password.rs +++ b/crates/axumkit-dto/src/auth/request/forgot_password.rs @@ -4,7 +4,7 @@ use validator::Validate; #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct ForgotPasswordRequest { - /// 비밀번호 재설정을 요청할 이메일 주소 + /// Email address to request password reset for #[validate(email)] pub email: String, } diff --git a/crates/axumkit-dto/src/auth/request/login.rs b/crates/axumkit-dto/src/auth/request/login.rs index 81ce659..182b49d 100644 --- a/crates/axumkit-dto/src/auth/request/login.rs +++ b/crates/axumkit-dto/src/auth/request/login.rs @@ -13,7 +13,7 @@ pub struct LoginRequest { message = "Password must be between 6 and 20 characters." ))] pub password: String, - /// 로그인 유지 여부 (체크 시 30일, 미체크 시 브라우저 닫으면 만료) + /// Remember me (checked: 30 days, unchecked: expires when browser is closed) #[serde(default)] #[schema(example = false)] pub remember_me: bool, diff --git a/crates/axumkit-dto/src/auth/request/mod.rs b/crates/axumkit-dto/src/auth/request/mod.rs index 96da8f2..7ffa7bc 100644 --- a/crates/axumkit-dto/src/auth/request/mod.rs +++ b/crates/axumkit-dto/src/auth/request/mod.rs @@ -4,6 +4,7 @@ pub mod complete_signup; pub mod confirm_email_change; pub mod forgot_password; pub mod login; +pub mod resend_verification_email; pub mod reset_password; pub mod totp_disable; pub mod totp_enable; @@ -17,6 +18,7 @@ pub use complete_signup::CompleteSignupRequest; pub use confirm_email_change::ConfirmEmailChangeRequest; pub use forgot_password::ForgotPasswordRequest; pub use login::LoginRequest; +pub use resend_verification_email::ResendVerificationEmailRequest; pub use reset_password::ResetPasswordRequest; pub use totp_disable::TotpDisableRequest; pub use totp_enable::TotpEnableRequest; diff --git a/crates/axumkit-dto/src/auth/request/resend_verification_email.rs b/crates/axumkit-dto/src/auth/request/resend_verification_email.rs new file mode 100644 index 0000000..d6074a8 --- /dev/null +++ b/crates/axumkit-dto/src/auth/request/resend_verification_email.rs @@ -0,0 +1,12 @@ +use serde::Deserialize; +use utoipa::ToSchema; +use validator::Validate; + +/// Request body for resending a pending signup verification email. +#[derive(Debug, Deserialize, Validate, ToSchema)] +#[schema(description = "Request body for resending a pending signup verification email.")] +pub struct ResendVerificationEmailRequest { + /// Verification email recipient address + #[validate(email)] + pub email: String, +} diff --git a/crates/axumkit-dto/src/auth/request/reset_password.rs b/crates/axumkit-dto/src/auth/request/reset_password.rs index 3172def..ad665c5 100644 --- a/crates/axumkit-dto/src/auth/request/reset_password.rs +++ b/crates/axumkit-dto/src/auth/request/reset_password.rs @@ -4,11 +4,11 @@ use validator::Validate; #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct ResetPasswordRequest { - /// 비밀번호 재설정 토큰 (이메일 링크의 ?token= 값) + /// Password reset token (?token= value from the email link) #[validate(length(min = 1))] pub token: String, - /// 새 비밀번호 + /// New password #[validate(length( min = 6, max = 20, diff --git a/crates/axumkit-dto/src/auth/request/totp_disable.rs b/crates/axumkit-dto/src/auth/request/totp_disable.rs index 7ced00b..0775a7f 100644 --- a/crates/axumkit-dto/src/auth/request/totp_disable.rs +++ b/crates/axumkit-dto/src/auth/request/totp_disable.rs @@ -2,10 +2,10 @@ use serde::Deserialize; use utoipa::ToSchema; use validator::Validate; -/// TOTP 비활성화 요청 +/// TOTP disable request #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct TotpDisableRequest { - /// 현재 TOTP 코드 (6자리) 또는 백업 코드 (8자리) + /// Current TOTP code (6 digits) or backup code (8 digits) #[validate(length(min = 6, max = 8, message = "Code must be 6-8 characters"))] pub code: String, } diff --git a/crates/axumkit-dto/src/auth/request/totp_enable.rs b/crates/axumkit-dto/src/auth/request/totp_enable.rs index 9622c33..99896de 100644 --- a/crates/axumkit-dto/src/auth/request/totp_enable.rs +++ b/crates/axumkit-dto/src/auth/request/totp_enable.rs @@ -2,10 +2,10 @@ use serde::Deserialize; use utoipa::ToSchema; use validator::Validate; -/// TOTP 활성화 요청 (setup 후 첫 코드 검증) +/// TOTP enable request (first code verification after setup) #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct TotpEnableRequest { - /// 인증 앱에서 생성한 6자리 TOTP 코드 + /// 6-digit TOTP code generated by the authenticator app #[validate(length(equal = 6, message = "TOTP code must be 6 digits"))] pub code: String, } diff --git a/crates/axumkit-dto/src/auth/request/totp_regenerate_backup_codes.rs b/crates/axumkit-dto/src/auth/request/totp_regenerate_backup_codes.rs index 241a0c3..01a93be 100644 --- a/crates/axumkit-dto/src/auth/request/totp_regenerate_backup_codes.rs +++ b/crates/axumkit-dto/src/auth/request/totp_regenerate_backup_codes.rs @@ -2,10 +2,10 @@ use serde::Deserialize; use utoipa::ToSchema; use validator::Validate; -/// 백업 코드 재생성 요청 +/// Backup code regeneration request #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct TotpRegenerateBackupCodesRequest { - /// 현재 TOTP 코드 (6자리) + /// Current TOTP code (6 digits) #[validate(length(equal = 6, message = "TOTP code must be 6 digits"))] pub code: String, } diff --git a/crates/axumkit-dto/src/auth/request/totp_verify.rs b/crates/axumkit-dto/src/auth/request/totp_verify.rs index 42ce665..80a3276 100644 --- a/crates/axumkit-dto/src/auth/request/totp_verify.rs +++ b/crates/axumkit-dto/src/auth/request/totp_verify.rs @@ -2,12 +2,12 @@ use serde::Deserialize; use utoipa::ToSchema; use validator::Validate; -/// TOTP 검증 요청 (로그인 시 2단계 인증) +/// TOTP verification request (two-factor authentication during login) #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct TotpVerifyRequest { - /// 로그인 시 받은 임시 토큰 + /// Temporary token received during login pub temp_token: String, - /// TOTP 코드 (6자리) 또는 백업 코드 (8자리) + /// TOTP code (6 digits) or backup code (8 digits) #[validate(length(min = 6, max = 8, message = "Code must be 6-8 characters"))] pub code: String, } diff --git a/crates/axumkit-dto/src/auth/request/verify_email.rs b/crates/axumkit-dto/src/auth/request/verify_email.rs index 5852066..0506ba4 100644 --- a/crates/axumkit-dto/src/auth/request/verify_email.rs +++ b/crates/axumkit-dto/src/auth/request/verify_email.rs @@ -4,7 +4,7 @@ use validator::Validate; #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct VerifyEmailRequest { - /// 이메일 인증 토큰 + /// Email verification token #[validate(length(min = 1))] pub token: String, } diff --git a/crates/axumkit-dto/src/auth/response/login.rs b/crates/axumkit-dto/src/auth/response/login.rs index 96e39f1..8553de5 100644 --- a/crates/axumkit-dto/src/auth/response/login.rs +++ b/crates/axumkit-dto/src/auth/response/login.rs @@ -29,8 +29,8 @@ pub fn create_login_response(session_id: String, remember_me: bool) -> Result Result { SameSite::Lax }; - // 세션 쿠키 삭제 (만료 시간을 과거로 설정) + // Delete session cookie (set expiration time to the past) let mut cookie_builder = Cookie::build(("session_id", "")) .http_only(true) .secure(true) diff --git a/crates/axumkit-dto/src/auth/response/totp_backup_codes.rs b/crates/axumkit-dto/src/auth/response/totp_backup_codes.rs index e03c7bb..d6740cd 100644 --- a/crates/axumkit-dto/src/auth/response/totp_backup_codes.rs +++ b/crates/axumkit-dto/src/auth/response/totp_backup_codes.rs @@ -3,10 +3,10 @@ use axum::response::{IntoResponse, Response}; use serde::Serialize; use utoipa::ToSchema; -/// 백업 코드 재생성 응답 +/// Backup code regeneration response #[derive(Debug, Serialize, ToSchema)] pub struct TotpBackupCodesResponse { - /// 새로 생성된 백업 코드 목록 (10개, 8자리 영숫자) + /// Newly generated list of backup codes (10 codes, 8-character alphanumeric) pub backup_codes: Vec, } diff --git a/crates/axumkit-dto/src/auth/response/totp_enable.rs b/crates/axumkit-dto/src/auth/response/totp_enable.rs index 6db7bcd..c80f16a 100644 --- a/crates/axumkit-dto/src/auth/response/totp_enable.rs +++ b/crates/axumkit-dto/src/auth/response/totp_enable.rs @@ -3,10 +3,10 @@ use axum::response::{IntoResponse, Response}; use serde::Serialize; use utoipa::ToSchema; -/// TOTP 활성화 응답 (백업 코드 반환) +/// TOTP enable response (returns backup codes) #[derive(Debug, Serialize, ToSchema)] pub struct TotpEnableResponse { - /// 백업 코드 목록 (10개, 8자리 영숫자) + /// List of backup codes (10 codes, 8-character alphanumeric) pub backup_codes: Vec, } diff --git a/crates/axumkit-dto/src/auth/response/totp_required.rs b/crates/axumkit-dto/src/auth/response/totp_required.rs index 91d13a9..98693a2 100644 --- a/crates/axumkit-dto/src/auth/response/totp_required.rs +++ b/crates/axumkit-dto/src/auth/response/totp_required.rs @@ -4,10 +4,10 @@ use axum::response::{IntoResponse, Response}; use serde::Serialize; use utoipa::ToSchema; -/// 로그인 시 TOTP 필요 응답 (202 Accepted) +/// TOTP required response during login (202 Accepted) #[derive(Debug, Serialize, ToSchema)] pub struct TotpRequiredResponse { - /// TOTP 검증용 임시 토큰 + /// Temporary token for TOTP verification pub temp_token: String, } diff --git a/crates/axumkit-dto/src/auth/response/totp_setup.rs b/crates/axumkit-dto/src/auth/response/totp_setup.rs index ffa1847..6e4ddfb 100644 --- a/crates/axumkit-dto/src/auth/response/totp_setup.rs +++ b/crates/axumkit-dto/src/auth/response/totp_setup.rs @@ -3,12 +3,12 @@ use axum::response::{IntoResponse, Response}; use serde::Serialize; use utoipa::ToSchema; -/// TOTP Setup 응답 +/// TOTP setup response #[derive(Debug, Serialize, ToSchema)] pub struct TotpSetupResponse { - /// QR 코드 PNG 이미지 (Base64 인코딩) + /// QR code PNG image (Base64 encoded) pub qr_code_base64: String, - /// otpauth:// URI (수동 입력용) + /// otpauth:// URI (for manual entry) pub qr_code_uri: String, } diff --git a/crates/axumkit-dto/src/auth/response/totp_status.rs b/crates/axumkit-dto/src/auth/response/totp_status.rs index 2c8015b..9b834aa 100644 --- a/crates/axumkit-dto/src/auth/response/totp_status.rs +++ b/crates/axumkit-dto/src/auth/response/totp_status.rs @@ -4,15 +4,15 @@ use chrono::{DateTime, Utc}; use serde::Serialize; use utoipa::ToSchema; -/// TOTP 상태 응답 +/// TOTP status response #[derive(Debug, Serialize, ToSchema)] pub struct TotpStatusResponse { - /// TOTP 활성화 여부 + /// Whether TOTP is enabled pub enabled: bool, - /// TOTP 활성화 시각 (활성화된 경우만) + /// TOTP activation time (only when enabled) #[serde(skip_serializing_if = "Option::is_none")] pub enabled_at: Option>, - /// 남은 백업 코드 수 (활성화된 경우만) + /// Remaining backup code count (only when enabled) #[serde(skip_serializing_if = "Option::is_none")] pub backup_codes_remaining: Option, } diff --git a/crates/axumkit-dto/src/lib.rs b/crates/axumkit-dto/src/lib.rs index 8e23892..1624a80 100644 --- a/crates/axumkit-dto/src/lib.rs +++ b/crates/axumkit-dto/src/lib.rs @@ -4,9 +4,9 @@ pub mod action_logs; pub mod auth; +pub mod moderation; pub mod oauth; pub mod pagination; -pub mod posts; pub mod search; pub mod user; pub mod validator; diff --git a/crates/axumkit-dto/src/moderation/mod.rs b/crates/axumkit-dto/src/moderation/mod.rs new file mode 100644 index 0000000..4ead916 --- /dev/null +++ b/crates/axumkit-dto/src/moderation/mod.rs @@ -0,0 +1,5 @@ +pub mod request; +pub mod response; + +pub use request::ListModerationLogsRequest; +pub use response::{ListModerationLogsResponse, ModerationLogListItem}; diff --git a/crates/axumkit-dto/src/moderation/request/list_logs.rs b/crates/axumkit-dto/src/moderation/request/list_logs.rs new file mode 100644 index 0000000..33e499c --- /dev/null +++ b/crates/axumkit-dto/src/moderation/request/list_logs.rs @@ -0,0 +1,20 @@ +use crate::pagination::CursorDirection; +use axumkit_constants::ModerationAction; +use axumkit_entity::common::ModerationResourceType; +use serde::Deserialize; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; +use validator::Validate; + +#[derive(Debug, Deserialize, Validate, ToSchema, IntoParams)] +#[into_params(parameter_in = Query)] +pub struct ListModerationLogsRequest { + pub cursor_id: Option, + pub cursor_direction: Option, + #[validate(range(min = 1, max = 100, message = "Limit must be between 1 and 100."))] + pub limit: u64, + pub actor_id: Option, + pub resource_type: Option, + pub resource_id: Option, + pub actions: Option>, +} diff --git a/crates/axumkit-dto/src/moderation/request/mod.rs b/crates/axumkit-dto/src/moderation/request/mod.rs new file mode 100644 index 0000000..a6c1f30 --- /dev/null +++ b/crates/axumkit-dto/src/moderation/request/mod.rs @@ -0,0 +1,3 @@ +pub mod list_logs; + +pub use list_logs::ListModerationLogsRequest; diff --git a/crates/axumkit-dto/src/moderation/response/list_logs.rs b/crates/axumkit-dto/src/moderation/response/list_logs.rs new file mode 100644 index 0000000..505464b --- /dev/null +++ b/crates/axumkit-dto/src/moderation/response/list_logs.rs @@ -0,0 +1,49 @@ +use axum::{Json, response::IntoResponse}; +use axumkit_entity::common::ModerationResourceType; +use axumkit_entity::moderation_logs::Model as ModerationLogModel; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use serde_json::Value as JsonValue; +use utoipa::ToSchema; +use uuid::Uuid; + +#[derive(Debug, Serialize, ToSchema)] +pub struct ModerationLogListItem { + pub id: Uuid, + pub action: String, + pub actor_id: Option, + pub resource_type: ModerationResourceType, + pub resource_id: Option, + pub reason: String, + #[schema(value_type = Option)] + pub metadata: Option, + pub created_at: DateTime, +} + +impl From for ModerationLogListItem { + fn from(model: ModerationLogModel) -> Self { + Self { + id: model.id, + action: model.action, + actor_id: model.actor_id, + resource_type: model.resource_type, + resource_id: model.resource_id, + reason: model.reason, + metadata: model.metadata, + created_at: model.created_at, + } + } +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ListModerationLogsResponse { + pub data: Vec, + pub has_newer: bool, + pub has_older: bool, +} + +impl IntoResponse for ListModerationLogsResponse { + fn into_response(self) -> axum::response::Response { + Json(self).into_response() + } +} diff --git a/crates/axumkit-dto/src/moderation/response/mod.rs b/crates/axumkit-dto/src/moderation/response/mod.rs new file mode 100644 index 0000000..2106087 --- /dev/null +++ b/crates/axumkit-dto/src/moderation/response/mod.rs @@ -0,0 +1,3 @@ +pub mod list_logs; + +pub use list_logs::{ListModerationLogsResponse, ModerationLogListItem}; diff --git a/crates/axumkit-dto/src/oauth/internal/sign_in_result.rs b/crates/axumkit-dto/src/oauth/internal/sign_in_result.rs index e2cfea6..75aaf38 100644 --- a/crates/axumkit-dto/src/oauth/internal/sign_in_result.rs +++ b/crates/axumkit-dto/src/oauth/internal/sign_in_result.rs @@ -1,12 +1,11 @@ -/// OAuth 로그인 서비스의 결과 +/// OAuth sign-in service result pub enum SignInResult { - /// 로그인 성공 (기존 사용자 또는 handle 제공한 신규 사용자) + /// Sign-in success (existing user or new user who provided a handle) Success(String), // session_id - /// 신규 사용자가 handle 없이 요청 → pending signup 상태 + /// New user requested without a handle → pending signup state PendingSignup { pending_token: String, email: String, - display_name: String, }, } diff --git a/crates/axumkit-dto/src/oauth/request/github.rs b/crates/axumkit-dto/src/oauth/request/github.rs index bc96fed..42185d6 100644 --- a/crates/axumkit-dto/src/oauth/request/github.rs +++ b/crates/axumkit-dto/src/oauth/request/github.rs @@ -2,7 +2,7 @@ use serde::Deserialize; use utoipa::ToSchema; use validator::Validate; -/// GitHub OAuth 로그인 요청 +/// GitHub OAuth sign-in request #[derive(Debug, Clone, Deserialize, Validate, ToSchema)] pub struct GithubLoginRequest { /// Authorization code from GitHub OAuth callback diff --git a/crates/axumkit-dto/src/oauth/request/google.rs b/crates/axumkit-dto/src/oauth/request/google.rs index 198ad08..ee8d7c2 100644 --- a/crates/axumkit-dto/src/oauth/request/google.rs +++ b/crates/axumkit-dto/src/oauth/request/google.rs @@ -2,7 +2,7 @@ use serde::Deserialize; use utoipa::ToSchema; use validator::Validate; -/// Google OAuth 로그인 요청 +/// Google OAuth sign-in request #[derive(Debug, Clone, Deserialize, Validate, ToSchema)] pub struct GoogleLoginRequest { /// Authorization code from Google OAuth callback @@ -13,3 +13,12 @@ pub struct GoogleLoginRequest { #[validate(length(min = 1, message = "State is required"))] pub state: String, } + +/// Google One Tap login request. +#[derive(Debug, Clone, Deserialize, Validate, ToSchema)] +#[schema(description = "Request body for signing in with Google One Tap.")] +pub struct GoogleOneTapLoginRequest { + /// Google One Tap credential JWT + #[validate(length(min = 1, message = "Credential is required"))] + pub credential: String, +} diff --git a/crates/axumkit-dto/src/oauth/request/link.rs b/crates/axumkit-dto/src/oauth/request/link.rs index 48a38f3..e6df156 100644 --- a/crates/axumkit-dto/src/oauth/request/link.rs +++ b/crates/axumkit-dto/src/oauth/request/link.rs @@ -2,7 +2,7 @@ use serde::Deserialize; use utoipa::ToSchema; use validator::Validate; -/// Google OAuth 연결 요청 +/// Google OAuth link request #[derive(Debug, Clone, Deserialize, Validate, ToSchema)] pub struct GoogleLinkRequest { /// Authorization code from Google OAuth callback @@ -14,7 +14,7 @@ pub struct GoogleLinkRequest { pub state: String, } -/// GitHub OAuth 연결 요청 +/// GitHub OAuth link request #[derive(Debug, Clone, Deserialize, Validate, ToSchema)] pub struct GithubLinkRequest { /// Authorization code from GitHub OAuth callback diff --git a/crates/axumkit-dto/src/oauth/request/mod.rs b/crates/axumkit-dto/src/oauth/request/mod.rs index 2c83627..0f08cd1 100644 --- a/crates/axumkit-dto/src/oauth/request/mod.rs +++ b/crates/axumkit-dto/src/oauth/request/mod.rs @@ -6,6 +6,6 @@ pub mod unlink; pub use authorize::{OAuthAuthorizeFlow, OAuthAuthorizeQuery}; pub use github::GithubLoginRequest; -pub use google::GoogleLoginRequest; +pub use google::{GoogleLoginRequest, GoogleOneTapLoginRequest}; pub use link::{GithubLinkRequest, GoogleLinkRequest}; pub use unlink::UnlinkOAuthRequest; diff --git a/crates/axumkit-dto/src/oauth/request/unlink.rs b/crates/axumkit-dto/src/oauth/request/unlink.rs index 0e2f819..df32ff9 100644 --- a/crates/axumkit-dto/src/oauth/request/unlink.rs +++ b/crates/axumkit-dto/src/oauth/request/unlink.rs @@ -3,7 +3,7 @@ use serde::Deserialize; use utoipa::ToSchema; use validator::Validate; -/// OAuth 연결 해제 요청 +/// OAuth unlink request #[derive(Debug, Clone, Deserialize, Validate, ToSchema)] pub struct UnlinkOAuthRequest { /// OAuth provider to unlink (Google or Github) diff --git a/crates/axumkit-dto/src/oauth/response/oauth_connection.rs b/crates/axumkit-dto/src/oauth/response/oauth_connection.rs index d263768..d82532d 100644 --- a/crates/axumkit-dto/src/oauth/response/oauth_connection.rs +++ b/crates/axumkit-dto/src/oauth/response/oauth_connection.rs @@ -7,13 +7,13 @@ use chrono::{DateTime, Utc}; use serde::Serialize; use utoipa::ToSchema; -/// OAuth 연결 정보 응답 +/// OAuth connection info response #[derive(Debug, Clone, Serialize, ToSchema)] pub struct OAuthConnectionResponse { /// OAuth provider (Google, Github) pub provider: OAuthProvider, - /// 연결 생성 시각 + /// Connection creation time pub created_at: DateTime, } @@ -26,7 +26,7 @@ impl From for OAuthConnectionResponse { } } -/// OAuth 연결 목록 응답 +/// OAuth connection list response #[derive(Debug, Serialize, ToSchema)] pub struct OAuthConnectionListResponse { pub connections: Vec, diff --git a/crates/axumkit-dto/src/oauth/response/oauth_url.rs b/crates/axumkit-dto/src/oauth/response/oauth_url.rs index d236f17..0cda05a 100644 --- a/crates/axumkit-dto/src/oauth/response/oauth_url.rs +++ b/crates/axumkit-dto/src/oauth/response/oauth_url.rs @@ -6,10 +6,10 @@ use axum::{ use serde::Serialize; use utoipa::ToSchema; -/// OAuth authorization URL 응답 +/// OAuth authorization URL response #[derive(Debug, Clone, Serialize, ToSchema)] pub struct OAuthUrlResponse { - /// Google/GitHub OAuth authorization URL (state parameter 포함) + /// Google/GitHub OAuth authorization URL (includes state parameter) pub auth_url: String, } diff --git a/crates/axumkit-dto/src/oauth/response/sign_in.rs b/crates/axumkit-dto/src/oauth/response/sign_in.rs index c92865c..2dd043b 100644 --- a/crates/axumkit-dto/src/oauth/response/sign_in.rs +++ b/crates/axumkit-dto/src/oauth/response/sign_in.rs @@ -7,15 +7,14 @@ use crate::auth::response::create_login_response; use crate::oauth::internal::SignInResult; use axumkit_errors::errors::Errors; -/// 신규 사용자가 handle 없이 OAuth 로그인 시 반환되는 pending signup 응답 +/// Pending signup response returned when a new user signs in via OAuth without a handle #[derive(Debug, Serialize, ToSchema)] +#[schema(description = "Response body returned when OAuth sign-in requires profile completion.")] pub struct OAuthPendingSignupResponse { - /// Pending signup 완료를 위한 일회용 토큰 + /// One-time token for completing pending signup pub pending_token: String, - /// OAuth provider로부터 받은 이메일 + /// Email received from the OAuth provider pub email: String, - /// OAuth provider로부터 받은 표시 이름 - pub display_name: String, } impl IntoResponse for OAuthPendingSignupResponse { @@ -24,36 +23,34 @@ impl IntoResponse for OAuthPendingSignupResponse { } } -/// OAuth 로그인 결과를 HTTP 응답으로 변환 +/// Converts OAuth sign-in result to HTTP response pub enum OAuthSignInResponse { - /// 로그인 성공 - 204 No Content + Set-Cookie + /// Sign-in success - 204 No Content + Set-Cookie Success { session_id: String }, /// Pending signup - 200 OK + JSON body PendingSignup(OAuthPendingSignupResponse), } impl OAuthSignInResponse { - /// SignInResult를 OAuthSignInResponse로 변환 + /// Converts SignInResult to OAuthSignInResponse pub fn from_result(result: SignInResult) -> Self { match result { SignInResult::Success(session_id) => OAuthSignInResponse::Success { session_id }, SignInResult::PendingSignup { pending_token, email, - display_name, } => OAuthSignInResponse::PendingSignup(OAuthPendingSignupResponse { pending_token, email, - display_name, }), } } - /// HTTP 응답으로 변환 (remember_me=true for OAuth, 30일 세션) + /// Converts to HTTP response (remember_me=true for OAuth, 30-day session) pub fn into_response_result(self) -> Result { match self { OAuthSignInResponse::Success { session_id } => { - // OAuth 로그인은 항상 30일 유지 (remember_me=true) + // OAuth sign-in always persists for 30 days (remember_me=true) create_login_response(session_id, true) } OAuthSignInResponse::PendingSignup(response) => Ok(response.into_response()), diff --git a/crates/axumkit-dto/src/posts/mod.rs b/crates/axumkit-dto/src/posts/mod.rs deleted file mode 100644 index ab5f0b0..0000000 --- a/crates/axumkit-dto/src/posts/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod request; -pub mod response; - -pub use request::{CreatePostRequest, GetPostPath, ListPostsQuery, UpdatePostRequest}; -pub use response::{ - CreatePostResponse, DeletePostResponse, ListPostsResponse, PostListItem, PostResponse, -}; diff --git a/crates/axumkit-dto/src/posts/request/create_post.rs b/crates/axumkit-dto/src/posts/request/create_post.rs deleted file mode 100644 index 599377c..0000000 --- a/crates/axumkit-dto/src/posts/request/create_post.rs +++ /dev/null @@ -1,22 +0,0 @@ -use crate::validator::string_validator::validate_not_blank; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; -use validator::Validate; - -#[derive(Debug, Deserialize, Serialize, Validate, ToSchema)] -pub struct CreatePostRequest { - #[validate(length( - min = 1, - max = 200, - message = "Title must be between 1 and 200 characters" - ))] - #[validate(custom(function = "validate_not_blank"))] - pub title: String, - #[validate(length( - min = 1, - max = 40000, - message = "Content must be between 1 and 40000 characters" - ))] - #[validate(custom(function = "validate_not_blank"))] - pub content: String, -} diff --git a/crates/axumkit-dto/src/posts/request/get_post.rs b/crates/axumkit-dto/src/posts/request/get_post.rs deleted file mode 100644 index 77a65c3..0000000 --- a/crates/axumkit-dto/src/posts/request/get_post.rs +++ /dev/null @@ -1,10 +0,0 @@ -use serde::Deserialize; -use utoipa::{IntoParams, ToSchema}; -use uuid::Uuid; -use validator::Validate; - -#[derive(Debug, Deserialize, ToSchema, IntoParams, Validate)] -#[into_params(parameter_in = Path)] -pub struct GetPostPath { - pub id: Uuid, -} diff --git a/crates/axumkit-dto/src/posts/request/list_posts.rs b/crates/axumkit-dto/src/posts/request/list_posts.rs deleted file mode 100644 index 435d886..0000000 --- a/crates/axumkit-dto/src/posts/request/list_posts.rs +++ /dev/null @@ -1,8 +0,0 @@ -use serde::Deserialize; -use utoipa::{IntoParams, ToSchema}; - -#[derive(Debug, Deserialize, ToSchema, IntoParams)] -pub struct ListPostsQuery { - pub limit: u64, - pub offset: u64, -} diff --git a/crates/axumkit-dto/src/posts/request/mod.rs b/crates/axumkit-dto/src/posts/request/mod.rs deleted file mode 100644 index 6d7e71a..0000000 --- a/crates/axumkit-dto/src/posts/request/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -mod create_post; -mod get_post; -mod list_posts; -mod update_post; - -pub use create_post::CreatePostRequest; -pub use get_post::GetPostPath; -pub use list_posts::ListPostsQuery; -pub use update_post::UpdatePostRequest; diff --git a/crates/axumkit-dto/src/posts/request/update_post.rs b/crates/axumkit-dto/src/posts/request/update_post.rs deleted file mode 100644 index 4bd008f..0000000 --- a/crates/axumkit-dto/src/posts/request/update_post.rs +++ /dev/null @@ -1,22 +0,0 @@ -use crate::validator::string_validator::validate_not_blank; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; -use validator::Validate; - -#[derive(Debug, Deserialize, Serialize, Validate, ToSchema)] -pub struct UpdatePostRequest { - #[validate(length( - min = 1, - max = 200, - message = "Title must be between 1 and 200 characters" - ))] - #[validate(custom(function = "validate_not_blank"))] - pub title: Option, - #[validate(length( - min = 1, - max = 40000, - message = "Content must be between 1 and 40000 characters" - ))] - #[validate(custom(function = "validate_not_blank"))] - pub content: Option, -} diff --git a/crates/axumkit-dto/src/posts/response/create_post.rs b/crates/axumkit-dto/src/posts/response/create_post.rs deleted file mode 100644 index 4e35064..0000000 --- a/crates/axumkit-dto/src/posts/response/create_post.rs +++ /dev/null @@ -1,18 +0,0 @@ -use axum::{ - Json, - http::StatusCode, - response::{IntoResponse, Response}, -}; -use serde::Serialize; -use utoipa::ToSchema; - -#[derive(Debug, Serialize, ToSchema)] -pub struct CreatePostResponse { - pub id: String, -} - -impl IntoResponse for CreatePostResponse { - fn into_response(self) -> Response { - (StatusCode::CREATED, Json(self)).into_response() - } -} diff --git a/crates/axumkit-dto/src/posts/response/delete_post.rs b/crates/axumkit-dto/src/posts/response/delete_post.rs deleted file mode 100644 index 6c6cda9..0000000 --- a/crates/axumkit-dto/src/posts/response/delete_post.rs +++ /dev/null @@ -1,18 +0,0 @@ -use axum::{ - Json, - http::StatusCode, - response::{IntoResponse, Response}, -}; -use serde::Serialize; -use utoipa::ToSchema; - -#[derive(Debug, Serialize, ToSchema)] -pub struct DeletePostResponse { - pub success: bool, -} - -impl IntoResponse for DeletePostResponse { - fn into_response(self) -> Response { - (StatusCode::OK, Json(self)).into_response() - } -} diff --git a/crates/axumkit-dto/src/posts/response/list_posts.rs b/crates/axumkit-dto/src/posts/response/list_posts.rs deleted file mode 100644 index a7066e4..0000000 --- a/crates/axumkit-dto/src/posts/response/list_posts.rs +++ /dev/null @@ -1,28 +0,0 @@ -use axum::{ - Json, - http::StatusCode, - response::{IntoResponse, Response}, -}; -use chrono::{DateTime, Utc}; -use serde::Serialize; -use utoipa::ToSchema; - -#[derive(Debug, Serialize, ToSchema)] -pub struct PostListItem { - pub id: String, - pub author_id: String, - pub title: String, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct ListPostsResponse { - pub posts: Vec, -} - -impl IntoResponse for ListPostsResponse { - fn into_response(self) -> Response { - (StatusCode::OK, Json(self)).into_response() - } -} diff --git a/crates/axumkit-dto/src/posts/response/mod.rs b/crates/axumkit-dto/src/posts/response/mod.rs deleted file mode 100644 index 0f9340e..0000000 --- a/crates/axumkit-dto/src/posts/response/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -mod create_post; -mod delete_post; -mod list_posts; -mod post; - -pub use create_post::CreatePostResponse; -pub use delete_post::DeletePostResponse; -pub use list_posts::{ListPostsResponse, PostListItem}; -pub use post::PostResponse; diff --git a/crates/axumkit-dto/src/posts/response/post.rs b/crates/axumkit-dto/src/posts/response/post.rs deleted file mode 100644 index e38e312..0000000 --- a/crates/axumkit-dto/src/posts/response/post.rs +++ /dev/null @@ -1,24 +0,0 @@ -use axum::{ - Json, - http::StatusCode, - response::{IntoResponse, Response}, -}; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; - -#[derive(Debug, Deserialize, Serialize, ToSchema)] -pub struct PostResponse { - pub id: String, - pub author_id: String, - pub title: String, - pub content: String, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -impl IntoResponse for PostResponse { - fn into_response(self) -> Response { - (StatusCode::OK, Json(self)).into_response() - } -} diff --git a/crates/axumkit-dto/src/search/mod.rs b/crates/axumkit-dto/src/search/mod.rs index 68a6164..be4bcb9 100644 --- a/crates/axumkit-dto/src/search/mod.rs +++ b/crates/axumkit-dto/src/search/mod.rs @@ -1,5 +1,5 @@ pub mod request; pub mod response; -pub use request::{SearchPostsRequest, SearchUsersRequest, SortOrder}; -pub use response::{PostSearchHit, SearchPostsResponse, SearchUsersResponse, UserSearchItem}; +pub use request::{SearchUsersRequest, SortOrder}; +pub use response::{SearchUsersResponse, UserSearchItem}; diff --git a/crates/axumkit-dto/src/search/request/mod.rs b/crates/axumkit-dto/src/search/request/mod.rs index aedf972..9ddb000 100644 --- a/crates/axumkit-dto/src/search/request/mod.rs +++ b/crates/axumkit-dto/src/search/request/mod.rs @@ -1,7 +1,5 @@ pub mod common; -pub mod posts; pub mod users; pub use common::SortOrder; -pub use posts::SearchPostsRequest; pub use users::SearchUsersRequest; diff --git a/crates/axumkit-dto/src/search/request/posts.rs b/crates/axumkit-dto/src/search/request/posts.rs deleted file mode 100644 index 4a70e16..0000000 --- a/crates/axumkit-dto/src/search/request/posts.rs +++ /dev/null @@ -1,22 +0,0 @@ -use crate::validator::string_validator::validate_not_blank; -use serde::Deserialize; -use utoipa::{IntoParams, ToSchema}; -use validator::Validate; - -#[derive(Debug, Deserialize, ToSchema, Validate, IntoParams)] -#[into_params(parameter_in = Query)] -pub struct SearchPostsRequest { - #[validate(length( - min = 1, - max = 200, - message = "Query must be between 1 and 200 characters" - ))] - #[validate(custom(function = "validate_not_blank"))] - pub query: String, - - #[validate(range(min = 1, message = "Page must be greater than 0"))] - pub page: u32, - - #[validate(range(min = 1, max = 20, message = "Page size must be between 1 and 20"))] - pub page_size: u32, -} diff --git a/crates/axumkit-dto/src/search/response/mod.rs b/crates/axumkit-dto/src/search/response/mod.rs index c7ae9ca..4c17b5e 100644 --- a/crates/axumkit-dto/src/search/response/mod.rs +++ b/crates/axumkit-dto/src/search/response/mod.rs @@ -1,5 +1,3 @@ -pub mod posts; pub mod users; -pub use posts::{PostSearchHit, SearchPostsResponse}; pub use users::{SearchUsersResponse, UserSearchItem}; diff --git a/crates/axumkit-dto/src/search/response/posts.rs b/crates/axumkit-dto/src/search/response/posts.rs deleted file mode 100644 index 345ffa3..0000000 --- a/crates/axumkit-dto/src/search/response/posts.rs +++ /dev/null @@ -1,29 +0,0 @@ -use axum::Json; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use serde::Serialize; -use utoipa::ToSchema; -use uuid::Uuid; - -#[derive(Debug, Serialize, ToSchema)] -pub struct PostSearchHit { - pub id: Uuid, - pub author_id: Uuid, - pub title: String, - pub content_snippet: String, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct SearchPostsResponse { - pub hits: Vec, - pub page: u32, - pub page_size: u32, - pub total_hits: u64, - pub total_pages: u32, -} - -impl IntoResponse for SearchPostsResponse { - fn into_response(self) -> axum::response::Response { - (StatusCode::OK, Json(self)).into_response() - } -} diff --git a/crates/axumkit-dto/src/user/mod.rs b/crates/axumkit-dto/src/user/mod.rs index 9eed526..a53f6e7 100644 --- a/crates/axumkit-dto/src/user/mod.rs +++ b/crates/axumkit-dto/src/user/mod.rs @@ -2,10 +2,12 @@ pub mod request; pub mod response; pub use request::{ - CheckHandleAvailablePath, CreateUserRequest, GetUserProfileByIdRequest, GetUserProfileRequest, + BanUserRequest, CheckHandleAvailablePath, CreateUserRequest, GetUserProfileByIdRequest, + GetUserProfileRequest, GrantRoleRequest, RevokeRoleRequest, UnbanUserRequest, UpdateMyProfileRequest, UploadUserImageRequest, }; pub use response::{ - CheckHandleAvailableResponse, CreateUserResponse, PublicUserProfile, UploadUserImageResponse, + BanUserResponse, CheckHandleAvailableResponse, CreateUserResponse, GrantRoleResponse, + PublicUserProfile, RevokeRoleResponse, UnbanUserResponse, UploadUserImageResponse, UserResponse, }; diff --git a/crates/axumkit-dto/src/user/request/ban_user.rs b/crates/axumkit-dto/src/user/request/ban_user.rs new file mode 100644 index 0000000..e04c4ed --- /dev/null +++ b/crates/axumkit-dto/src/user/request/ban_user.rs @@ -0,0 +1,63 @@ +use crate::validator::datetime_validator::validate_future_datetime; +use crate::validator::string_validator::validate_not_blank; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; +use validator::Validate; + +#[derive(Debug, Serialize, Deserialize, ToSchema, Validate)] +/// Request payload for ban user request. +pub struct BanUserRequest { + pub user_id: Uuid, + /// Ban expiration time (None = permanent ban) + #[validate(custom(function = "validate_future_datetime"))] + pub expires_at: Option>, + #[validate(length( + min = 1, + max = 1000, + message = "Reason must be between 1 and 1000 characters." + ))] + #[validate(custom(function = "validate_not_blank"))] + pub reason: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Duration; + + #[test] + fn test_ban_user_request_rejects_past_expires_at() { + let req = BanUserRequest { + user_id: Uuid::now_v7(), + expires_at: Some(Utc::now() - Duration::minutes(1)), + reason: "test".to_string(), + }; + + let err = req + .validate() + .expect_err("past expires_at must be rejected"); + let field_errors = err.field_errors(); + let field = field_errors + .get("expires_at") + .expect("expires_at field error must exist"); + assert!( + field + .iter() + .any(|e| e.code.as_ref() == "expires_at_must_be_in_future"), + "expected expires_at_must_be_in_future error" + ); + } + + #[test] + fn test_ban_user_request_accepts_future_expires_at() { + let req = BanUserRequest { + user_id: Uuid::now_v7(), + expires_at: Some(Utc::now() + Duration::minutes(10)), + reason: "test".to_string(), + }; + + assert!(req.validate().is_ok()); + } +} diff --git a/crates/axumkit-dto/src/user/request/check_handle_available.rs b/crates/axumkit-dto/src/user/request/check_handle_available.rs index a6992e2..5ec1e06 100644 --- a/crates/axumkit-dto/src/user/request/check_handle_available.rs +++ b/crates/axumkit-dto/src/user/request/check_handle_available.rs @@ -1,16 +1,18 @@ -use crate::validator::string_validator::validate_not_blank; +use crate::validator::string_validator::{validate_handle, validate_not_blank}; use serde::Deserialize; use utoipa::{IntoParams, ToSchema}; use validator::Validate; #[derive(Debug, Deserialize, ToSchema, IntoParams, Validate)] #[into_params(parameter_in = Path)] +/// Request payload for check handle available path. pub struct CheckHandleAvailablePath { #[validate(length( - min = 3, - max = 20, - message = "Handle must be between 3 and 20 characters." + min = 4, + max = 15, + message = "Handle must be between 4 and 15 characters." ))] #[validate(custom(function = "validate_not_blank"))] + #[validate(custom(function = "validate_handle"))] pub handle: String, } diff --git a/crates/axumkit-dto/src/user/request/create_user.rs b/crates/axumkit-dto/src/user/request/create_user.rs index 60e5b79..a212360 100644 --- a/crates/axumkit-dto/src/user/request/create_user.rs +++ b/crates/axumkit-dto/src/user/request/create_user.rs @@ -1,26 +1,51 @@ -use crate::validator::string_validator::validate_not_blank; +use crate::validator::string_validator::{ + validate_display_name, validate_handle, validate_not_blank, +}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use validator::Validate; #[derive(Debug, Serialize, Deserialize, ToSchema, Validate)] +#[schema(description = "Request body for starting an email and password signup.")] +/// Request payload for create user request. pub struct CreateUserRequest { #[schema(example = "user@example.com")] #[validate(email)] pub email: String, + /// Unique user handle. + /// + /// - Length: 4–15 characters + /// - Allowed characters: `a-z`, `A-Z`, `0-9`, `_` + /// - Cannot start or end with `_` + /// - No consecutive underscores (`__`) + /// - Reserved words are not allowed (e.g. `admin`, `support`, `help`, `system`) + #[schema( + min_length = 4, + max_length = 15, + pattern = "^[a-zA-Z0-9][a-zA-Z0-9_]*[a-zA-Z0-9]$", + example = "john_doe" + )] #[validate(length( - min = 3, - max = 20, - message = "Handle must be between 3 and 20 characters." + min = 4, + max = 15, + message = "Handle must be between 4 and 15 characters." ))] #[validate(custom(function = "validate_not_blank"))] + #[validate(custom(function = "validate_handle"))] pub handle: String, + /// Display name shown in the UI. + /// + /// - Length: 1–50 characters + /// - Unicode letters, spaces, and punctuation are permitted + /// - Emoji, control characters, and invisible Unicode are not allowed + #[schema(min_length = 1, max_length = 50, example = "John Doe")] #[validate(length( - min = 3, - max = 20, - message = "Name must be between 3 and 20 characters." + min = 1, + max = 50, + message = "Display name must be between 1 and 50 characters." ))] #[validate(custom(function = "validate_not_blank"))] + #[validate(custom(function = "validate_display_name"))] pub display_name: String, #[validate(length( min = 6, diff --git a/crates/axumkit-dto/src/user/request/grant_role.rs b/crates/axumkit-dto/src/user/request/grant_role.rs new file mode 100644 index 0000000..bdef2f1 --- /dev/null +++ b/crates/axumkit-dto/src/user/request/grant_role.rs @@ -0,0 +1,67 @@ +use crate::validator::datetime_validator::validate_future_datetime; +use crate::validator::string_validator::validate_not_blank; +use axumkit_entity::common::Role; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; +use validator::Validate; + +#[derive(Debug, Serialize, Deserialize, ToSchema, Validate)] +/// Request payload for grant role request. +pub struct GrantRoleRequest { + pub user_id: Uuid, + pub role: Role, + /// Role expiration time (None = permanent) + #[validate(custom(function = "validate_future_datetime"))] + pub expires_at: Option>, + #[validate(length( + min = 1, + max = 1000, + message = "Reason must be between 1 and 1000 characters." + ))] + #[validate(custom(function = "validate_not_blank"))] + pub reason: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Duration; + + #[test] + fn test_grant_role_request_rejects_past_expires_at() { + let req = GrantRoleRequest { + user_id: Uuid::now_v7(), + role: Role::Mod, + expires_at: Some(Utc::now() - Duration::minutes(1)), + reason: "test".to_string(), + }; + + let err = req + .validate() + .expect_err("past expires_at must be rejected"); + let field_errors = err.field_errors(); + let field = field_errors + .get("expires_at") + .expect("expires_at field error must exist"); + assert!( + field + .iter() + .any(|e| e.code.as_ref() == "expires_at_must_be_in_future"), + "expected expires_at_must_be_in_future error" + ); + } + + #[test] + fn test_grant_role_request_accepts_explicit_role() { + let req = GrantRoleRequest { + user_id: Uuid::now_v7(), + role: Role::Mod, + expires_at: Some(Utc::now() + Duration::minutes(10)), + reason: "test".to_string(), + }; + + assert!(req.validate().is_ok()); + } +} diff --git a/crates/axumkit-dto/src/user/request/mod.rs b/crates/axumkit-dto/src/user/request/mod.rs index 96f16f9..810554b 100644 --- a/crates/axumkit-dto/src/user/request/mod.rs +++ b/crates/axumkit-dto/src/user/request/mod.rs @@ -1,13 +1,21 @@ +pub mod ban_user; pub mod check_handle_available; pub mod create_user; pub mod get_user_profile; pub mod get_user_profile_by_id; +pub mod grant_role; +pub mod revoke_role; +pub mod unban_user; pub mod update_my_profile; pub mod upload_user_image; +pub use ban_user::BanUserRequest; pub use check_handle_available::CheckHandleAvailablePath; pub use create_user::CreateUserRequest; pub use get_user_profile::GetUserProfileRequest; pub use get_user_profile_by_id::GetUserProfileByIdRequest; +pub use grant_role::GrantRoleRequest; +pub use revoke_role::RevokeRoleRequest; +pub use unban_user::UnbanUserRequest; pub use update_my_profile::UpdateMyProfileRequest; pub use upload_user_image::UploadUserImageRequest; diff --git a/crates/axumkit-dto/src/user/request/revoke_role.rs b/crates/axumkit-dto/src/user/request/revoke_role.rs new file mode 100644 index 0000000..68855cc --- /dev/null +++ b/crates/axumkit-dto/src/user/request/revoke_role.rs @@ -0,0 +1,20 @@ +use crate::validator::string_validator::validate_not_blank; +use axumkit_entity::common::Role; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; +use validator::Validate; + +#[derive(Debug, Serialize, Deserialize, ToSchema, Validate)] +/// Request payload for revoke role request. +pub struct RevokeRoleRequest { + pub user_id: Uuid, + pub role: Role, + #[validate(length( + min = 1, + max = 1000, + message = "Reason must be between 1 and 1000 characters." + ))] + #[validate(custom(function = "validate_not_blank"))] + pub reason: String, +} diff --git a/crates/axumkit-dto/src/user/request/unban_user.rs b/crates/axumkit-dto/src/user/request/unban_user.rs new file mode 100644 index 0000000..077826b --- /dev/null +++ b/crates/axumkit-dto/src/user/request/unban_user.rs @@ -0,0 +1,18 @@ +use crate::validator::string_validator::validate_not_blank; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; +use validator::Validate; + +#[derive(Debug, Serialize, Deserialize, ToSchema, Validate)] +/// Request payload for unban user request. +pub struct UnbanUserRequest { + pub user_id: Uuid, + #[validate(length( + min = 1, + max = 1000, + message = "Reason must be between 1 and 1000 characters." + ))] + #[validate(custom(function = "validate_not_blank"))] + pub reason: String, +} diff --git a/crates/axumkit-dto/src/user/request/update_my_profile.rs b/crates/axumkit-dto/src/user/request/update_my_profile.rs index 92c92b9..add2df3 100644 --- a/crates/axumkit-dto/src/user/request/update_my_profile.rs +++ b/crates/axumkit-dto/src/user/request/update_my_profile.rs @@ -1,16 +1,24 @@ -use crate::validator::string_validator::validate_not_blank; +use crate::validator::string_validator::{validate_display_name, validate_not_blank}; use serde::Deserialize; use utoipa::ToSchema; use validator::Validate; #[derive(Debug, Deserialize, ToSchema, Validate)] +/// Request payload for update my profile request. pub struct UpdateMyProfileRequest { + /// Display name shown in the UI. + /// + /// - Length: 1–50 characters + /// - Unicode letters, spaces, and punctuation are permitted + /// - Emoji, control characters, and invisible Unicode are not allowed + #[schema(min_length = 1, max_length = 50, example = "John Doe")] #[validate(length( min = 1, max = 50, message = "Display name must be between 1 and 50 characters." ))] #[validate(custom(function = "validate_not_blank"))] + #[validate(custom(function = "validate_display_name"))] pub display_name: Option, #[validate(length(max = 500, message = "Bio cannot exceed 500 characters."))] diff --git a/crates/axumkit-dto/src/user/response/ban_user.rs b/crates/axumkit-dto/src/user/response/ban_user.rs new file mode 100644 index 0000000..63f8494 --- /dev/null +++ b/crates/axumkit-dto/src/user/response/ban_user.rs @@ -0,0 +1,18 @@ +use axum::{Json, http::StatusCode, response::IntoResponse, response::Response}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +/// Response payload for ban user response. +pub struct BanUserResponse { + pub user_id: Uuid, + pub expires_at: Option>, +} + +impl IntoResponse for BanUserResponse { + fn into_response(self) -> Response { + (StatusCode::OK, Json(self)).into_response() + } +} diff --git a/crates/axumkit-dto/src/user/response/create_user.rs b/crates/axumkit-dto/src/user/response/create_user.rs index 578b953..646b7ac 100644 --- a/crates/axumkit-dto/src/user/response/create_user.rs +++ b/crates/axumkit-dto/src/user/response/create_user.rs @@ -13,6 +13,6 @@ pub struct CreateUserResponse { impl IntoResponse for CreateUserResponse { fn into_response(self) -> Response { - (StatusCode::CREATED, Json(self)).into_response() + (StatusCode::ACCEPTED, Json(self)).into_response() } } diff --git a/crates/axumkit-dto/src/user/response/grant_role.rs b/crates/axumkit-dto/src/user/response/grant_role.rs new file mode 100644 index 0000000..181f2a6 --- /dev/null +++ b/crates/axumkit-dto/src/user/response/grant_role.rs @@ -0,0 +1,20 @@ +use axum::{Json, http::StatusCode, response::IntoResponse, response::Response}; +use axumkit_entity::common::Role; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +/// Response payload for grant role response. +pub struct GrantRoleResponse { + pub user_id: Uuid, + pub role: Role, + pub expires_at: Option>, +} + +impl IntoResponse for GrantRoleResponse { + fn into_response(self) -> Response { + (StatusCode::OK, Json(self)).into_response() + } +} diff --git a/crates/axumkit-dto/src/user/response/mod.rs b/crates/axumkit-dto/src/user/response/mod.rs index 00096e1..115c9a7 100644 --- a/crates/axumkit-dto/src/user/response/mod.rs +++ b/crates/axumkit-dto/src/user/response/mod.rs @@ -1,11 +1,19 @@ +pub mod ban_user; pub mod check_handle_available; pub mod create_user; +pub mod grant_role; pub mod public_user_profile; +pub mod revoke_role; +pub mod unban_user; pub mod upload_user_image; pub mod user_profile; +pub use ban_user::BanUserResponse; pub use check_handle_available::CheckHandleAvailableResponse; pub use create_user::CreateUserResponse; +pub use grant_role::GrantRoleResponse; pub use public_user_profile::PublicUserProfile; +pub use revoke_role::RevokeRoleResponse; +pub use unban_user::UnbanUserResponse; pub use upload_user_image::UploadUserImageResponse; pub use user_profile::UserResponse; diff --git a/crates/axumkit-dto/src/user/response/revoke_role.rs b/crates/axumkit-dto/src/user/response/revoke_role.rs new file mode 100644 index 0000000..e78f2ab --- /dev/null +++ b/crates/axumkit-dto/src/user/response/revoke_role.rs @@ -0,0 +1,18 @@ +use axum::{Json, http::StatusCode, response::IntoResponse, response::Response}; +use axumkit_entity::common::Role; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +/// Response payload for revoke role response. +pub struct RevokeRoleResponse { + pub user_id: Uuid, + pub role: Role, +} + +impl IntoResponse for RevokeRoleResponse { + fn into_response(self) -> Response { + (StatusCode::OK, Json(self)).into_response() + } +} diff --git a/crates/axumkit-dto/src/user/response/unban_user.rs b/crates/axumkit-dto/src/user/response/unban_user.rs new file mode 100644 index 0000000..74803c6 --- /dev/null +++ b/crates/axumkit-dto/src/user/response/unban_user.rs @@ -0,0 +1,16 @@ +use axum::{Json, http::StatusCode, response::IntoResponse, response::Response}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +/// Response payload for unban user response. +pub struct UnbanUserResponse { + pub user_id: Uuid, +} + +impl IntoResponse for UnbanUserResponse { + fn into_response(self) -> Response { + (StatusCode::OK, Json(self)).into_response() + } +} diff --git a/crates/axumkit-dto/src/validator/datetime_validator.rs b/crates/axumkit-dto/src/validator/datetime_validator.rs new file mode 100644 index 0000000..f8ad58e --- /dev/null +++ b/crates/axumkit-dto/src/validator/datetime_validator.rs @@ -0,0 +1,11 @@ +use chrono::{DateTime, Utc}; +use validator::ValidationError; + +/// Validates that expires_at is in the future (rejects past datetimes). +/// When used on an Option field, the validator crate only calls this for Some values. +pub fn validate_future_datetime(dt: &DateTime) -> Result<(), ValidationError> { + if *dt <= Utc::now() { + return Err(ValidationError::new("expires_at_must_be_in_future")); + } + Ok(()) +} diff --git a/crates/axumkit-dto/src/validator/mod.rs b/crates/axumkit-dto/src/validator/mod.rs index 66254c8..6e355bf 100644 --- a/crates/axumkit-dto/src/validator/mod.rs +++ b/crates/axumkit-dto/src/validator/mod.rs @@ -1,3 +1,4 @@ +pub mod datetime_validator; pub mod json_validator; pub mod multipart_validator; pub mod path_validator; diff --git a/crates/axumkit-dto/src/validator/string_validator.rs b/crates/axumkit-dto/src/validator/string_validator.rs index 4153b31..09c2b1b 100644 --- a/crates/axumkit-dto/src/validator/string_validator.rs +++ b/crates/axumkit-dto/src/validator/string_validator.rs @@ -1,8 +1,124 @@ +use unicode_general_category::{GeneralCategory, get_general_category}; use validator::ValidationError; +/// Validates that a string is not blank (not empty after trimming whitespace) pub fn validate_not_blank(s: &str) -> Result<(), ValidationError> { if s.trim().is_empty() { return Err(ValidationError::new("blank_string")); } Ok(()) } + +/// Reserved handles that cannot be registered +const RESERVED_HANDLES: &[&str] = &[ + "admin", + "administrator", + "support", + "help", + "system", + "null", + "undefined", + "root", + "moderator", + "mod", + "staff", + "official", + "bot", + "api", + "mail", + "email", + "info", + "contact", + "security", + "abuse", + "noreply", + "no_reply", + "anonymous", + "guest", + "user", + "test", +]; + +/// Validates a user handle. +/// +/// Rules: +/// - Only ASCII alphanumeric characters and underscores (`a-z`, `A-Z`, `0-9`, `_`) +/// - Cannot start or end with an underscore +/// - No consecutive underscores (`__`) +/// - Cannot be a reserved word (case-insensitive) +pub fn validate_handle(handle: &str) -> Result<(), ValidationError> { + // Only allow ASCII alphanumeric + underscore. + // This implicitly rejects: control chars, invisible Unicode, homoglyphs, + // combining chars (Zalgo), RTL/LTR overrides, zero-width chars, etc. + if !handle + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_') + { + return Err(ValidationError::new("handle_invalid_chars")); + } + + if handle.starts_with('_') || handle.ends_with('_') { + return Err(ValidationError::new("handle_leading_trailing_underscore")); + } + + if handle.contains("__") { + return Err(ValidationError::new("handle_consecutive_underscores")); + } + + let lower = handle.to_ascii_lowercase(); + if RESERVED_HANDLES.contains(&lower.as_str()) { + return Err(ValidationError::new("handle_reserved")); + } + + Ok(()) +} + +/// Validates a display name (Twitter-style policy, no emoji). +/// +/// Rules: +/// - No control characters (`Cc`), format/invisible characters (`Cf`), +/// surrogates (`Cs`), private-use characters (`Co`), line separators (`Zl`), +/// or paragraph separators (`Zp`) +/// - No emoji or miscellaneous symbols (`So` — covers 😀, 🎉, ©, ®, ™, etc.) +/// - No more than 2 consecutive non-spacing marks (blocks Zalgo text) +/// +/// Unicode letters, spaces, and punctuation are permitted. +pub fn validate_display_name(name: &str) -> Result<(), ValidationError> { + let mut consecutive_combining: u32 = 0; + + for ch in name.chars() { + let category = get_general_category(ch); + + match category { + // Cc: control chars (U+0000–U+001F, U+007F–U+009F) + // Cf: format chars — covers ALL of: soft hyphen, ZWS, ZWNJ, ZWJ, + // LTR/RTL marks, LTR/RTL overrides, BOM, word joiners, etc. + // Cs: surrogates + // Co: private use + // Zl: line separator (U+2028) + // Zp: paragraph separator (U+2029) + // So: other symbols — covers emoji (😀 🎉 🚀 etc.) and misc symbols (© ® ™ ★ ♠ etc.) + GeneralCategory::Control + | GeneralCategory::Format + | GeneralCategory::Surrogate + | GeneralCategory::PrivateUse + | GeneralCategory::LineSeparator + | GeneralCategory::ParagraphSeparator + | GeneralCategory::OtherSymbol => { + return Err(ValidationError::new("display_name_invalid_chars")); + } + // Mn: non-spacing marks — Zalgo abuses these + GeneralCategory::NonspacingMark => { + consecutive_combining += 1; + if consecutive_combining > 2 { + return Err(ValidationError::new("display_name_combining_chars")); + } + } + _ => { + consecutive_combining = 0; + } + } + } + + Ok(()) +} diff --git a/crates/axumkit-entity/src/action_logs.rs b/crates/axumkit-entity/src/action_logs.rs index 7007d03..ee028b0 100644 --- a/crates/axumkit-entity/src/action_logs.rs +++ b/crates/axumkit-entity/src/action_logs.rs @@ -1,9 +1,8 @@ +use super::users::Entity as UsersEntity; +use crate::common::ActionResourceType; use sea_orm::prelude::*; use uuid::Uuid; -use super::common::ActionResourceType; -use super::users::Entity as UsersEntity; - #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "action_logs")] pub struct Model { @@ -13,8 +12,6 @@ pub struct Model { pub action: String, #[sea_orm(nullable)] pub actor_id: Option, - #[sea_orm(nullable)] - pub actor_ip: Option, #[sea_orm(not_null)] pub resource_type: ActionResourceType, #[sea_orm(nullable)] diff --git a/crates/axumkit-entity/src/common/action/mod.rs b/crates/axumkit-entity/src/common/action/mod.rs new file mode 100644 index 0000000..7ee0858 --- /dev/null +++ b/crates/axumkit-entity/src/common/action/mod.rs @@ -0,0 +1,3 @@ +mod resource_type; + +pub use resource_type::ActionResourceType; diff --git a/crates/axumkit-entity/src/common/action/resource_type.rs b/crates/axumkit-entity/src/common/action/resource_type.rs new file mode 100644 index 0000000..adbe419 --- /dev/null +++ b/crates/axumkit-entity/src/common/action/resource_type.rs @@ -0,0 +1,16 @@ +use sea_orm::{DeriveActiveEnum, EnumIter}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Deserialize, Serialize, ToSchema, +)] +#[sea_orm( + rs_type = "String", + db_type = "Enum", + enum_name = "action_resource_type" +)] +pub enum ActionResourceType { + #[sea_orm(string_value = "user")] + User, +} diff --git a/crates/axumkit-entity/src/common/mod.rs b/crates/axumkit-entity/src/common/mod.rs new file mode 100644 index 0000000..e904ded --- /dev/null +++ b/crates/axumkit-entity/src/common/mod.rs @@ -0,0 +1,9 @@ +pub mod action; +pub mod moderation; +mod oauth_provider; +mod role; + +pub use action::ActionResourceType; +pub use moderation::ModerationResourceType; +pub use oauth_provider::OAuthProvider; +pub use role::Role; diff --git a/crates/axumkit-entity/src/common/moderation/mod.rs b/crates/axumkit-entity/src/common/moderation/mod.rs new file mode 100644 index 0000000..9ddcf7d --- /dev/null +++ b/crates/axumkit-entity/src/common/moderation/mod.rs @@ -0,0 +1,3 @@ +mod resource_type; + +pub use resource_type::ModerationResourceType; diff --git a/crates/axumkit-entity/src/common/moderation/resource_type.rs b/crates/axumkit-entity/src/common/moderation/resource_type.rs new file mode 100644 index 0000000..ffa8f65 --- /dev/null +++ b/crates/axumkit-entity/src/common/moderation/resource_type.rs @@ -0,0 +1,18 @@ +use sea_orm::{DeriveActiveEnum, EnumIter}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive( + Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Deserialize, Serialize, ToSchema, +)] +#[sea_orm( + rs_type = "String", + db_type = "Enum", + enum_name = "moderation_resource_type" +)] +pub enum ModerationResourceType { + #[sea_orm(string_value = "user")] + User, + #[sea_orm(string_value = "system")] + System, +} diff --git a/crates/axumkit-entity/src/common.rs b/crates/axumkit-entity/src/common/oauth_provider.rs similarity index 57% rename from crates/axumkit-entity/src/common.rs rename to crates/axumkit-entity/src/common/oauth_provider.rs index 47f44c3..8cad42a 100644 --- a/crates/axumkit-entity/src/common.rs +++ b/crates/axumkit-entity/src/common/oauth_provider.rs @@ -2,7 +2,7 @@ use sea_orm::{DeriveActiveEnum, EnumIter}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -/// OAuth 제공자: 소셜 로그인 제공 업체 +/// OAuth provider: social login provider #[derive( Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Deserialize, Serialize, ToSchema, )] @@ -24,21 +24,3 @@ pub enum OAuthProvider { #[sea_orm(string_value = "microsoft")] Microsoft, } - -/// Action Log 리소스 타입: 사용자 활동 로그의 대상 리소스 종류 -#[derive( - Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Deserialize, Serialize, ToSchema, -)] -#[sea_orm( - rs_type = "String", - db_type = "Enum", - enum_name = "action_resource_type" -)] -pub enum ActionResourceType { - /// 사용자 - #[sea_orm(string_value = "user")] - User, - /// 포스트 - #[sea_orm(string_value = "post")] - Post, -} diff --git a/crates/axumkit-entity/src/common/role.rs b/crates/axumkit-entity/src/common/role.rs new file mode 100644 index 0000000..3e4bff6 --- /dev/null +++ b/crates/axumkit-entity/src/common/role.rs @@ -0,0 +1,62 @@ +use sea_orm::{DeriveActiveEnum, EnumIter}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + EnumIter, + DeriveActiveEnum, + Deserialize, + Serialize, + ToSchema, + Hash, +)] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "role")] +pub enum Role { + #[sea_orm(string_value = "mod")] + #[serde(rename = "mod")] + Mod, + #[sea_orm(string_value = "admin")] + #[serde(rename = "admin")] + Admin, +} + +impl Role { + pub fn as_str(self) -> &'static str { + match self { + Self::Mod => "mod", + Self::Admin => "admin", + } + } + + pub fn display_priority(self) -> u8 { + match self { + Self::Mod => 1, + Self::Admin => 2, + } + } + + pub const ALL: &'static [Role] = &[Role::Mod, Role::Admin]; +} + +impl std::fmt::Display for Role { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl std::str::FromStr for Role { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "mod" => Ok(Self::Mod), + "admin" => Ok(Self::Admin), + _ => Err(format!("Unknown role: {}", s)), + } + } +} diff --git a/crates/axumkit-entity/src/lib.rs b/crates/axumkit-entity/src/lib.rs index e757fbb..ece2683 100644 --- a/crates/axumkit-entity/src/lib.rs +++ b/crates/axumkit-entity/src/lib.rs @@ -1,5 +1,7 @@ pub mod action_logs; pub mod common; -pub mod posts; +pub mod moderation_logs; +pub mod user_bans; pub mod user_oauth_connections; +pub mod user_roles; pub mod users; diff --git a/crates/axumkit-entity/src/moderation_logs.rs b/crates/axumkit-entity/src/moderation_logs.rs new file mode 100644 index 0000000..19d326b --- /dev/null +++ b/crates/axumkit-entity/src/moderation_logs.rs @@ -0,0 +1,45 @@ +use sea_orm::prelude::*; +use uuid::Uuid; + +use super::common::ModerationResourceType; +use super::users::Entity as UsersEntity; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "moderation_logs")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + #[sea_orm(column_type = "Text", not_null)] + pub action: String, + #[sea_orm(nullable)] + pub actor_id: Option, + #[sea_orm(not_null)] + pub resource_type: ModerationResourceType, + #[sea_orm(nullable)] + pub resource_id: Option, + #[sea_orm(column_type = "Text", not_null)] + pub reason: String, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub metadata: Option, + #[sea_orm(column_type = "TimestampWithTimeZone", not_null)] + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "UsersEntity", + from = "Column::ActorId", + to = "super::users::Column::Id", + on_delete = "SetNull" + )] + Actor, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Actor.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/axumkit-entity/src/posts.rs b/crates/axumkit-entity/src/user_bans.rs similarity index 50% rename from crates/axumkit-entity/src/posts.rs rename to crates/axumkit-entity/src/user_bans.rs index 54dffeb..e666894 100644 --- a/crates/axumkit-entity/src/posts.rs +++ b/crates/axumkit-entity/src/user_bans.rs @@ -1,33 +1,33 @@ -use sea_orm::entity::prelude::*; +use sea_orm::prelude::*; +use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] -#[sea_orm(table_name = "posts")] +#[sea_orm(table_name = "user_bans")] pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] + #[sea_orm(primary_key)] pub id: Uuid, - pub author_id: Uuid, - pub title: String, - #[sea_orm(column_type = "Text", not_null)] - pub storage_key: String, + #[sea_orm(not_null, unique)] + pub user_id: Uuid, + #[sea_orm(column_type = "TimestampWithTimeZone", nullable)] + pub expires_at: Option, #[sea_orm(column_type = "TimestampWithTimeZone", not_null)] pub created_at: DateTimeUtc, - #[sea_orm(column_type = "TimestampWithTimeZone", not_null)] - pub updated_at: DateTimeUtc, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { #[sea_orm( belongs_to = "super::users::Entity", - from = "Column::AuthorId", - to = "super::users::Column::Id" + from = "Column::UserId", + to = "super::users::Column::Id", + on_delete = "Cascade" )] - Author, + User, } impl Related for Entity { fn to() -> RelationDef { - Relation::Author.def() + Relation::User.def() } } diff --git a/crates/axumkit-entity/src/user_oauth_connections.rs b/crates/axumkit-entity/src/user_oauth_connections.rs index cfa7063..fe3bdaa 100644 --- a/crates/axumkit-entity/src/user_oauth_connections.rs +++ b/crates/axumkit-entity/src/user_oauth_connections.rs @@ -1,9 +1,8 @@ +use super::users::Entity as UsersEntity; +use crate::common::OAuthProvider; use sea_orm::prelude::*; use uuid::Uuid; -use super::common::OAuthProvider; -use super::users::Entity as UsersEntity; - #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "user_oauth_connections")] pub struct Model { diff --git a/crates/axumkit-entity/src/user_roles.rs b/crates/axumkit-entity/src/user_roles.rs new file mode 100644 index 0000000..09b277c --- /dev/null +++ b/crates/axumkit-entity/src/user_roles.rs @@ -0,0 +1,39 @@ +use sea_orm::prelude::*; +use uuid::Uuid; + +use super::common::Role; +use super::users::Entity as UsersEntity; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "user_roles")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + #[sea_orm(not_null)] + pub user_id: Uuid, + #[sea_orm(not_null)] + pub role: Role, + #[sea_orm(column_type = "TimestampWithTimeZone", not_null)] + pub granted_at: DateTimeUtc, + #[sea_orm(column_type = "TimestampWithTimeZone", nullable)] + pub expires_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "UsersEntity", + from = "Column::UserId", + to = "super::users::Column::Id", + on_delete = "Cascade" + )] + Users, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Users.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/axumkit-entity/src/users.rs b/crates/axumkit-entity/src/users.rs index 52b6610..f85c329 100644 --- a/crates/axumkit-entity/src/users.rs +++ b/crates/axumkit-entity/src/users.rs @@ -8,11 +8,11 @@ use super::user_oauth_connections::Entity as UserOAuthConnectionsEntity; pub struct Model { #[sea_orm(primary_key)] pub id: Uuid, - #[sea_orm(column_name = "display_name", not_null)] + #[sea_orm(column_type = "Text", not_null)] pub display_name: String, - #[sea_orm(string_len = 20, not_null, unique)] - pub handle: String, - #[sea_orm(string_len = 200, nullable)] + #[sea_orm(column_type = "Text", not_null, unique)] + pub handle: String, // Unique + #[sea_orm(column_type = "Text", nullable)] pub bio: Option, #[sea_orm(string_len = 254, not_null, unique)] pub email: String, @@ -24,6 +24,7 @@ pub struct Model { pub profile_image: Option, #[sea_orm(column_type = "Text", nullable)] pub banner_image: Option, + // TOTP 2FA #[sea_orm(column_type = "Text", nullable)] pub totp_secret: Option, #[sea_orm(column_type = "TimestampWithTimeZone", nullable)] diff --git a/crates/axumkit-errors/src/errors.rs b/crates/axumkit-errors/src/errors.rs index e3dbdc2..ef616d1 100644 --- a/crates/axumkit-errors/src/errors.rs +++ b/crates/axumkit-errors/src/errors.rs @@ -1,7 +1,7 @@ use crate::handlers::{ email_handler, eventstream_handler, file_handler, general_handler, meilisearch_handler, - oauth_handler, password_handler, post_handler, rate_limit_handler, session_handler, - system_handler, token_handler, totp_handler, turnstile_handler, user_handler, worker_handler, + oauth_handler, password_handler, rate_limit_handler, session_handler, system_handler, + token_handler, totp_handler, turnstile_handler, user_handler, worker_handler, }; use axum::Json; use axum::http::StatusCode; @@ -42,6 +42,9 @@ impl From> for Errors { #[derive(Debug)] pub enum Errors { + // Auth errors + InvalidCredentials, + // User errors UserInvalidPassword, UserPasswordNotSet, @@ -58,6 +61,7 @@ pub enum Errors { UserAlreadyBanned, UserDoesNotHaveRole, UserAlreadyHasRole, + CannotManageSelf, CannotManageHigherOrEqualRole, UserTokenExpired, UserNoRefreshToken, @@ -71,9 +75,6 @@ pub enum Errors { // Permission errors ForbiddenError(String), - // Post - PostNotFound, - // Document DocumentNotFound, DocumentAlreadyExists, @@ -107,6 +108,9 @@ pub enum Errors { OauthHandleRequired, OauthEmailAlreadyExists, OauthEmailNotVerified, + GoogleInvalidIdToken, + GoogleJwksFetchFailed, + GoogleJwksParseFailed, // Password errors PasswordRequiredForUpdate, @@ -187,7 +191,6 @@ impl IntoResponse for Errors { totp_handler::log_error(&self); email_handler::log_error(&self); file_handler::log_error(&self); - post_handler::log_error(&self); worker_handler::log_error(&self); eventstream_handler::log_error(&self); rate_limit_handler::log_error(&self); @@ -205,7 +208,6 @@ impl IntoResponse for Errors { .or_else(|| totp_handler::map_response(&self)) .or_else(|| email_handler::map_response(&self)) .or_else(|| file_handler::map_response(&self)) - .or_else(|| post_handler::map_response(&self)) .or_else(|| worker_handler::map_response(&self)) .or_else(|| eventstream_handler::map_response(&self)) .or_else(|| rate_limit_handler::map_response(&self)) @@ -221,11 +223,11 @@ impl IntoResponse for Errors { // Only include details in dev mode let is_dev = ServerConfig::get().is_dev; - // 오류 응답 구성 + // Construct error response let body = ErrorResponse { status: status.as_u16(), code: code.to_string(), - details: if is_dev { details } else { None }, // 개발 환경에서만 상세 정보 표시 + details: if is_dev { details } else { None }, // Show details only in dev environment }; (status, Json(body)).into_response() diff --git a/crates/axumkit-errors/src/handlers/email_handler.rs b/crates/axumkit-errors/src/handlers/email_handler.rs index a715729..218bd71 100644 --- a/crates/axumkit-errors/src/handlers/email_handler.rs +++ b/crates/axumkit-errors/src/handlers/email_handler.rs @@ -3,10 +3,10 @@ use crate::protocol::email::*; use axum::http::StatusCode; use tracing::debug; -/// 이메일 관련 에러 로깅 처리 +/// Email-related error logging handler pub fn log_error(error: &Errors) { match error { - // 비즈니스 로직 에러 - debug! 레벨 (클라이언트 실수) + // Business logic errors - debug! level (client mistakes) Errors::EmailAlreadyVerified => { debug!("Client error: {:?}", error); } @@ -22,6 +22,6 @@ pub fn map_response(error: &Errors) -> Option<(StatusCode, &'static str, Option< Some((StatusCode::BAD_REQUEST, EMAIL_ALREADY_VERIFIED, None)) } - _ => None, // 다른 도메인의 에러는 None 반환 + _ => None, // Return None for errors from other domains } } diff --git a/crates/axumkit-errors/src/handlers/eventstream_handler.rs b/crates/axumkit-errors/src/handlers/eventstream_handler.rs index 510524c..654d0d3 100644 --- a/crates/axumkit-errors/src/handlers/eventstream_handler.rs +++ b/crates/axumkit-errors/src/handlers/eventstream_handler.rs @@ -3,7 +3,7 @@ use crate::protocol::eventstream::*; use axum::http::StatusCode; use tracing::warn; -/// EventStream 관련 에러 로깅 처리 +/// EventStream error logging handler pub fn log_error(error: &Errors) { match error { Errors::EventStreamPublishFailed => { diff --git a/crates/axumkit-errors/src/handlers/file_handler.rs b/crates/axumkit-errors/src/handlers/file_handler.rs index e43cfdb..fdfe18a 100644 --- a/crates/axumkit-errors/src/handlers/file_handler.rs +++ b/crates/axumkit-errors/src/handlers/file_handler.rs @@ -3,10 +3,10 @@ use crate::protocol::file::*; use axum::http::StatusCode; use tracing::warn; -/// 파일 관련 에러 로깅 처리 +/// File-related error logging handler pub fn log_error(error: &Errors) { match error { - // 파일 관련 에러 - warn! 레벨 + // File-related errors - warn! level Errors::FileUploadError(_) | Errors::FileNotFound | Errors::FileReadError(_) => { warn!("File/processing error: {:?}", error); } @@ -28,6 +28,6 @@ pub fn map_response(error: &Errors) -> Option<(StatusCode, &'static str, Option< Some((StatusCode::BAD_REQUEST, FILE_READ_ERROR, Some(msg.clone()))) } - _ => None, // 다른 도메인의 에러는 None 반환 + _ => None, // Return None for errors from other domains } } diff --git a/crates/axumkit-errors/src/handlers/general_handler.rs b/crates/axumkit-errors/src/handlers/general_handler.rs index c642d3c..c89759f 100644 --- a/crates/axumkit-errors/src/handlers/general_handler.rs +++ b/crates/axumkit-errors/src/handlers/general_handler.rs @@ -1,26 +1,18 @@ use crate::errors::Errors; use crate::protocol::general::*; -use crate::protocol::post::*; use axum::http::StatusCode; -use tracing::{debug, warn}; +use tracing::debug; -/// 일반 에러 로깅 처리 +/// General domain error logging. pub fn log_error(error: &Errors) { match error { - // 리소스 찾을 수 없음 - warn! 레벨 - Errors::PostNotFound => { - warn!("Resource not found: {:?}", error); - } - - // 비즈니스 로직 에러 - debug! 레벨 (클라이언트 실수) Errors::ForbiddenError(_) | Errors::BadRequestError(_) | Errors::ValidationError(_) | Errors::FileTooLargeError(_) | Errors::InvalidIpAddress => { - debug!("Client error: {:?}", error); + debug!(error = ?error, "Client error"); } - _ => {} } } @@ -29,7 +21,6 @@ pub fn log_error(error: &Errors) { pub fn map_response(error: &Errors) -> Option<(StatusCode, &'static str, Option)> { match error { Errors::ForbiddenError(msg) => Some((StatusCode::FORBIDDEN, FORBIDDEN, Some(msg.clone()))), - Errors::PostNotFound => Some((StatusCode::NOT_FOUND, POST_NOT_FOUND, None)), Errors::BadRequestError(msg) => { Some((StatusCode::BAD_REQUEST, BAD_REQUEST, Some(msg.clone()))) } @@ -42,7 +33,6 @@ pub fn map_response(error: &Errors) -> Option<(StatusCode, &'static str, Option< Some(msg.clone()), )), Errors::InvalidIpAddress => Some((StatusCode::BAD_REQUEST, INVALID_IP_ADDRESS, None)), - - _ => None, // 다른 도메인의 에러는 None 반환 + _ => None, } } diff --git a/crates/axumkit-errors/src/handlers/meilisearch_handler.rs b/crates/axumkit-errors/src/handlers/meilisearch_handler.rs index 5b47627..08757f5 100644 --- a/crates/axumkit-errors/src/handlers/meilisearch_handler.rs +++ b/crates/axumkit-errors/src/handlers/meilisearch_handler.rs @@ -3,7 +3,7 @@ use crate::protocol::meilisearch::*; use axum::http::StatusCode; use tracing::error; -/// MeiliSearch 에러 로깅 처리 +/// MeiliSearch error logging handler pub fn log_error(err: &Errors) { match err { Errors::MeiliSearchQueryFailed => { diff --git a/crates/axumkit-errors/src/handlers/mod.rs b/crates/axumkit-errors/src/handlers/mod.rs index 44252d8..cd13124 100644 --- a/crates/axumkit-errors/src/handlers/mod.rs +++ b/crates/axumkit-errors/src/handlers/mod.rs @@ -5,7 +5,6 @@ pub mod general_handler; pub mod meilisearch_handler; pub mod oauth_handler; pub mod password_handler; -pub mod post_handler; pub mod rate_limit_handler; pub mod session_handler; pub mod system_handler; diff --git a/crates/axumkit-errors/src/handlers/oauth_handler.rs b/crates/axumkit-errors/src/handlers/oauth_handler.rs index aa28e9d..225dcf5 100644 --- a/crates/axumkit-errors/src/handlers/oauth_handler.rs +++ b/crates/axumkit-errors/src/handlers/oauth_handler.rs @@ -3,15 +3,28 @@ use crate::protocol::oauth::*; use axum::http::StatusCode; use tracing::{debug, error, warn}; -/// OAuth 관련 에러 로깅 처리 +/// OAuth error logging handler pub fn log_error(error: &Errors) { match error { - // 시스템 심각도 에러 - error! 레벨 + // Critical system errors - error! level Errors::OauthUserInfoParseFailed(msg) => { error!("OAuth user info parse failed: {}", msg); } - // OAuth 에러 - warn! 레벨 (외부 서비스 관련) + // Google One Tap - warn! level (invalid tokens) + Errors::GoogleInvalidIdToken => { + warn!("Google One Tap: invalid ID token"); + } + + // Google One Tap - error! level (JWKS failures) + Errors::GoogleJwksFetchFailed => { + error!("Google One Tap: failed to fetch JWKS"); + } + Errors::GoogleJwksParseFailed => { + error!("Google One Tap: failed to parse JWKS"); + } + + // OAuth errors - warn! level (external service related) Errors::OauthInvalidAuthUrl | Errors::OauthInvalidTokenUrl | Errors::OauthInvalidRedirectUrl @@ -20,7 +33,7 @@ pub fn log_error(error: &Errors) { warn!("OAuth error: {:?}", error); } - // 비즈니스 로직 에러 - debug! 레벨 (클라이언트 실수) + // Business logic errors - debug! level (client mistakes) Errors::OauthAccountAlreadyLinked | Errors::OauthConnectionNotFound | Errors::OauthCannotUnlinkLastConnection @@ -84,6 +97,20 @@ pub fn map_response(error: &Errors) -> Option<(StatusCode, &'static str, Option< Some((StatusCode::BAD_REQUEST, OAUTH_EMAIL_NOT_VERIFIED, None)) } - _ => None, // 다른 도메인의 에러는 None 반환 + Errors::GoogleInvalidIdToken => { + Some((StatusCode::BAD_REQUEST, GOOGLE_INVALID_ID_TOKEN, None)) + } + Errors::GoogleJwksFetchFailed => Some(( + StatusCode::INTERNAL_SERVER_ERROR, + GOOGLE_JWKS_FETCH_FAILED, + None, + )), + Errors::GoogleJwksParseFailed => Some(( + StatusCode::INTERNAL_SERVER_ERROR, + GOOGLE_JWKS_PARSE_FAILED, + None, + )), + + _ => None, // Return None for errors from other domains } } diff --git a/crates/axumkit-errors/src/handlers/password_handler.rs b/crates/axumkit-errors/src/handlers/password_handler.rs index 70d7888..a8fbdde 100644 --- a/crates/axumkit-errors/src/handlers/password_handler.rs +++ b/crates/axumkit-errors/src/handlers/password_handler.rs @@ -34,6 +34,6 @@ pub fn map_response(error: &Errors) -> Option<(StatusCode, &'static str, Option< } Errors::PasswordAlreadySet => Some((StatusCode::BAD_REQUEST, PASSWORD_ALREADY_SET, None)), - _ => None, // 다른 도메인의 에러는 None 반환 + _ => None, // Return None for errors from other domains } } diff --git a/crates/axumkit-errors/src/handlers/post_handler.rs b/crates/axumkit-errors/src/handlers/post_handler.rs deleted file mode 100644 index 7d3ba34..0000000 --- a/crates/axumkit-errors/src/handlers/post_handler.rs +++ /dev/null @@ -1,22 +0,0 @@ -use crate::errors::Errors; -use crate::protocol::post::*; -use axum::http::StatusCode; -use tracing::warn; - -/// Post 관련 에러 로깅 처리 -pub fn log_error(error: &Errors) { - match error { - Errors::PostNotFound => { - warn!("Post error: {:?}", error); - } - _ => {} - } -} - -/// Returns: (StatusCode, error_code, details) -pub fn map_response(error: &Errors) -> Option<(StatusCode, &'static str, Option)> { - match error { - Errors::PostNotFound => Some((StatusCode::NOT_FOUND, POST_NOT_FOUND, None)), - _ => None, - } -} diff --git a/crates/axumkit-errors/src/handlers/rate_limit_handler.rs b/crates/axumkit-errors/src/handlers/rate_limit_handler.rs index c0d0a83..20358af 100644 --- a/crates/axumkit-errors/src/handlers/rate_limit_handler.rs +++ b/crates/axumkit-errors/src/handlers/rate_limit_handler.rs @@ -3,7 +3,7 @@ use crate::protocol::rate_limit::*; use axum::http::StatusCode; use tracing::warn; -/// Rate limit 에러 로깅 처리 +/// Rate limit error logging handler pub fn log_error(error: &Errors) { match error { Errors::RateLimitExceeded => { diff --git a/crates/axumkit-errors/src/handlers/session_handler.rs b/crates/axumkit-errors/src/handlers/session_handler.rs index 108287a..b9f9579 100644 --- a/crates/axumkit-errors/src/handlers/session_handler.rs +++ b/crates/axumkit-errors/src/handlers/session_handler.rs @@ -3,10 +3,10 @@ use crate::protocol::session::*; use axum::http::StatusCode; use tracing::debug; -/// 세션 관련 에러 로깅 처리 +/// Session-related error logging handler pub fn log_error(error: &Errors) { match error { - // 비즈니스 로직 에러 - debug! 레벨 (클라이언트 실수) + // Business logic errors - debug! level (client mistakes) Errors::SessionInvalidUserId | Errors::SessionExpired | Errors::SessionNotFound => { debug!("Client error: {:?}", error); } @@ -24,6 +24,6 @@ pub fn map_response(error: &Errors) -> Option<(StatusCode, &'static str, Option< Errors::SessionExpired => Some((StatusCode::UNAUTHORIZED, SESSION_EXPIRED, None)), Errors::SessionNotFound => Some((StatusCode::UNAUTHORIZED, SESSION_NOT_FOUND, None)), - _ => None, // 다른 도메인의 에러는 None 반환 + _ => None, // Return None for errors from other domains } } diff --git a/crates/axumkit-errors/src/handlers/system_handler.rs b/crates/axumkit-errors/src/handlers/system_handler.rs index 27e0fb3..c7da939 100644 --- a/crates/axumkit-errors/src/handlers/system_handler.rs +++ b/crates/axumkit-errors/src/handlers/system_handler.rs @@ -3,10 +3,10 @@ use crate::protocol::system::*; use axum::http::StatusCode; use tracing::{error, warn}; -/// 시스템 관련 에러 로깅 처리 +/// System-related error logging handler pub fn log_error(err: &Errors) { match err { - // 시스템 심각도 에러 - error! 레벨 + // Critical system errors - error! level Errors::SysInternalError(_) | Errors::DatabaseError(_) | Errors::TransactionError(_) @@ -15,7 +15,7 @@ pub fn log_error(err: &Errors) { error!("System error occurred: {:?}", err); } - // 리소스 찾을 수 없음 - warn! 레벨 + // Resource not found - warn! level Errors::NotFound(_) => { warn!("Resource not found: {:?}", err); } @@ -54,6 +54,6 @@ pub fn map_response(error: &Errors) -> Option<(StatusCode, &'static str, Option< Some(msg.clone()), )), - _ => None, // 다른 도메인의 에러는 None 반환 + _ => None, // Return None for errors from other domains } } diff --git a/crates/axumkit-errors/src/handlers/token_handler.rs b/crates/axumkit-errors/src/handlers/token_handler.rs index 5082b01..5cb9093 100644 --- a/crates/axumkit-errors/src/handlers/token_handler.rs +++ b/crates/axumkit-errors/src/handlers/token_handler.rs @@ -3,10 +3,10 @@ use crate::protocol::token::*; use axum::http::StatusCode; use tracing::debug; -/// 토큰 관련 에러 로깅 처리 +/// Token-related error logging handler pub fn log_error(error: &Errors) { match error { - // 비즈니스 로직 에러 - debug! 레벨 (클라이언트 실수) + // Business logic errors - debug! level (client mistakes) Errors::TokenInvalidVerification | Errors::TokenExpiredVerification | Errors::TokenEmailMismatch @@ -36,6 +36,6 @@ pub fn map_response(error: &Errors) -> Option<(StatusCode, &'static str, Option< Some((StatusCode::BAD_REQUEST, TOKEN_INVALID_EMAIL_CHANGE, None)) } - _ => None, // 다른 도메인의 에러는 None 반환 + _ => None, // Return None for errors from other domains } } diff --git a/crates/axumkit-errors/src/handlers/totp_handler.rs b/crates/axumkit-errors/src/handlers/totp_handler.rs index c1cd22a..fdbaa52 100644 --- a/crates/axumkit-errors/src/handlers/totp_handler.rs +++ b/crates/axumkit-errors/src/handlers/totp_handler.rs @@ -3,10 +3,10 @@ use crate::protocol::totp::*; use axum::http::StatusCode; use tracing::debug; -/// TOTP 관련 에러 로깅 처리 +/// TOTP error logging handler pub fn log_error(error: &Errors) { match error { - // 비즈니스 로직 에러 - debug! 레벨 (클라이언트 실수) + // Business logic errors - debug! level (client mistakes) Errors::TotpAlreadyEnabled | Errors::TotpNotEnabled | Errors::TotpInvalidCode @@ -16,7 +16,7 @@ pub fn log_error(error: &Errors) { debug!("TOTP client error: {:?}", error); } - // 시스템 에러 - error! 레벨 + // System errors - error! level Errors::TotpSecretGenerationFailed | Errors::TotpQrGenerationFailed => { tracing::error!("TOTP system error: {:?}", error); } @@ -51,6 +51,6 @@ pub fn map_response(error: &Errors) -> Option<(StatusCode, &'static str, Option< None, )), - _ => None, // 다른 도메인의 에러는 None 반환 + _ => None, // Return None for errors from other domains } } diff --git a/crates/axumkit-errors/src/handlers/turnstile_handler.rs b/crates/axumkit-errors/src/handlers/turnstile_handler.rs index e407f78..634257c 100644 --- a/crates/axumkit-errors/src/handlers/turnstile_handler.rs +++ b/crates/axumkit-errors/src/handlers/turnstile_handler.rs @@ -3,17 +3,17 @@ use crate::protocol::turnstile::*; use axum::http::StatusCode; use tracing::{debug, warn}; -/// Turnstile 관련 에러 로깅 처리 +/// Turnstile error logging handler pub fn log_error(error: &Errors) { match error { - // 클라이언트 에러 - debug! 레벨 + // Client errors - debug! level Errors::TurnstileTokenMissing => { debug!("Client error: missing turnstile token"); } Errors::TurnstileVerificationFailed => { debug!("Client error: turnstile verification failed"); } - // 서비스 에러 - warn! 레벨 + // Service errors - warn! level Errors::TurnstileServiceError => { warn!("Turnstile service error: failed to call Cloudflare API"); } diff --git a/crates/axumkit-errors/src/handlers/user_handler.rs b/crates/axumkit-errors/src/handlers/user_handler.rs index 088d5e3..5c70f13 100644 --- a/crates/axumkit-errors/src/handlers/user_handler.rs +++ b/crates/axumkit-errors/src/handlers/user_handler.rs @@ -1,18 +1,20 @@ use crate::errors::Errors; +use crate::protocol::auth::*; use crate::protocol::user::*; use axum::http::StatusCode; use tracing::{debug, warn}; -/// 사용자 관련 에러 로깅 처리 +/// User-related error logging handler pub fn log_error(error: &Errors) { match error { - // 리소스 찾을 수 없음 - warn! 레벨 + // Resource not found - warn! level Errors::UserNotFound => { warn!("Resource not found: {:?}", error); } - // 비즈니스 로직 에러 - debug! 레벨 (클라이언트 실수) - Errors::UserInvalidPassword + // Business logic errors - debug! level (client mistakes) + Errors::InvalidCredentials + | Errors::UserInvalidPassword | Errors::UserPasswordNotSet | Errors::UserInvalidSession | Errors::UserNotVerified @@ -28,11 +30,12 @@ pub fn log_error(error: &Errors) { | Errors::UserAlreadyBanned | Errors::UserDoesNotHaveRole | Errors::UserAlreadyHasRole + | Errors::CannotManageSelf | Errors::CannotManageHigherOrEqualRole => { debug!("Client error: {:?}", error); } - // ACL 에러 - debug! 레벨 (ACL 규칙에 의해 거부됨) + // ACL errors - debug! level (denied by ACL rules) Errors::AclDenied(_) => { debug!("ACL denied: {:?}", error); } @@ -44,6 +47,9 @@ pub fn log_error(error: &Errors) { /// Returns: (StatusCode, error_code, details) pub fn map_response(error: &Errors) -> Option<(StatusCode, &'static str, Option)> { match error { + Errors::InvalidCredentials => { + Some((StatusCode::UNAUTHORIZED, AUTH_INVALID_CREDENTIALS, None)) + } Errors::UserInvalidPassword => { Some((StatusCode::UNAUTHORIZED, USER_INVALID_PASSWORD, None)) } @@ -66,6 +72,19 @@ pub fn map_response(error: &Errors) -> Option<(StatusCode, &'static str, Option< Errors::UserNoRefreshToken => Some((StatusCode::UNAUTHORIZED, USER_NO_REFRESH_TOKEN, None)), Errors::UserInvalidToken => Some((StatusCode::UNAUTHORIZED, USER_INVALID_TOKEN, None)), - _ => None, // 다른 도메인의 에러는 None 반환 + Errors::UserNotBanned => Some((StatusCode::BAD_REQUEST, USER_NOT_BANNED, None)), + Errors::UserAlreadyBanned => Some((StatusCode::CONFLICT, USER_ALREADY_BANNED, None)), + Errors::UserDoesNotHaveRole => { + Some((StatusCode::BAD_REQUEST, USER_DOES_NOT_HAVE_ROLE, None)) + } + Errors::UserAlreadyHasRole => Some((StatusCode::CONFLICT, USER_ALREADY_HAS_ROLE, None)), + Errors::CannotManageSelf => Some((StatusCode::FORBIDDEN, USER_CANNOT_MANAGE_SELF, None)), + Errors::CannotManageHigherOrEqualRole => Some(( + StatusCode::FORBIDDEN, + USER_CANNOT_MANAGE_HIGHER_OR_EQUAL_ROLE, + None, + )), + + _ => None, // Return None for errors from other domains } } diff --git a/crates/axumkit-errors/src/handlers/worker_handler.rs b/crates/axumkit-errors/src/handlers/worker_handler.rs index 2d603a4..7249ae1 100644 --- a/crates/axumkit-errors/src/handlers/worker_handler.rs +++ b/crates/axumkit-errors/src/handlers/worker_handler.rs @@ -3,10 +3,10 @@ use crate::protocol::worker::*; use axum::http::StatusCode; use tracing::warn; -/// Worker Service 관련 에러 로깅 처리 +/// Worker Service error logging handler pub fn log_error(error: &Errors) { match error { - // Worker Service 에러 - warn! 레벨 (외부 서비스 관련) + // Worker Service errors - warn! level (external service related) Errors::WorkerServiceConnectionFailed | Errors::WorkerServiceResponseInvalid | Errors::VerificationEmailSendFailed @@ -40,6 +40,6 @@ pub fn map_response(error: &Errors) -> Option<(StatusCode, &'static str, Option< None, )), - _ => None, // 다른 도메인의 에러는 None 반환 + _ => None, // Return None for errors from other domains } } diff --git a/crates/axumkit-errors/src/protocol.rs b/crates/axumkit-errors/src/protocol.rs index 4914771..741c694 100644 --- a/crates/axumkit-errors/src/protocol.rs +++ b/crates/axumkit-errors/src/protocol.rs @@ -1,5 +1,9 @@ //! Error code constants +pub mod auth { + pub const AUTH_INVALID_CREDENTIALS: &str = "auth:invalid_credentials"; +} + pub mod user { pub const USER_INVALID_PASSWORD: &str = "user:invalid_password"; pub const USER_PASSWORD_NOT_SET: &str = "user:password_not_set"; @@ -14,10 +18,13 @@ pub mod user { pub const USER_TOKEN_EXPIRED: &str = "user:token_expired"; pub const USER_NO_REFRESH_TOKEN: &str = "user:no_refresh_token"; pub const USER_INVALID_TOKEN: &str = "user:invalid_token"; -} - -pub mod post { - pub const POST_NOT_FOUND: &str = "post:not_found"; + pub const USER_NOT_BANNED: &str = "user:not_banned"; + pub const USER_ALREADY_BANNED: &str = "user:already_banned"; + pub const USER_DOES_NOT_HAVE_ROLE: &str = "user:does_not_have_role"; + pub const USER_ALREADY_HAS_ROLE: &str = "user:already_has_role"; + pub const USER_CANNOT_MANAGE_SELF: &str = "user:cannot_manage_self"; + pub const USER_CANNOT_MANAGE_HIGHER_OR_EQUAL_ROLE: &str = + "user:cannot_manage_higher_or_equal_role"; } pub mod oauth { @@ -36,6 +43,11 @@ pub mod oauth { pub const OAUTH_HANDLE_REQUIRED: &str = "oauth:handle_required"; pub const OAUTH_EMAIL_ALREADY_EXISTS: &str = "oauth:email_already_exists"; pub const OAUTH_EMAIL_NOT_VERIFIED: &str = "oauth:email_not_verified"; + + // Google One Tap + pub const GOOGLE_INVALID_ID_TOKEN: &str = "google:invalid_id_token"; + pub const GOOGLE_JWKS_FETCH_FAILED: &str = "google:jwks_fetch_failed"; + pub const GOOGLE_JWKS_PARSE_FAILED: &str = "google:jwks_parse_failed"; } pub mod general { diff --git a/crates/axumkit-server/Cargo.toml b/crates/axumkit-server/Cargo.toml index bfb2fc3..5c1eff5 100644 --- a/crates/axumkit-server/Cargo.toml +++ b/crates/axumkit-server/Cargo.toml @@ -62,3 +62,4 @@ futures.workspace = true similar.workspace = true async-nats.workspace = true tokio-stream.workspace = true +jsonwebtoken.workspace = true diff --git a/crates/axumkit-server/src/api/routes.rs b/crates/axumkit-server/src/api/routes.rs index 3c99130..d2fa72d 100644 --- a/crates/axumkit-server/src/api/routes.rs +++ b/crates/axumkit-server/src/api/routes.rs @@ -6,7 +6,7 @@ use axum::Router; use axumkit_errors::errors::handler_404; use utoipa_swagger_ui::SwaggerUi; -/// 최상위 API 라우터 (health + versioned APIs) +/// Top-level API router (health + versioned APIs) pub fn api_routes(state: AppState) -> Router { let mut router = Router::new(); diff --git a/crates/axumkit-server/src/api/v0/routes/action_logs/recent_changes.rs b/crates/axumkit-server/src/api/v0/routes/action_logs/recent_changes.rs index 0d89acc..4086c86 100644 --- a/crates/axumkit-server/src/api/v0/routes/action_logs/recent_changes.rs +++ b/crates/axumkit-server/src/api/v0/routes/action_logs/recent_changes.rs @@ -20,5 +20,5 @@ pub async fn get_action_logs( State(state): State, ValidatedQuery(payload): ValidatedQuery, ) -> Result { - service_get_action_logs(&state.read_db, payload).await + service_get_action_logs(&state.db, payload).await } diff --git a/crates/axumkit-server/src/api/v0/routes/auth/change_email.rs b/crates/axumkit-server/src/api/v0/routes/auth/change_email.rs index 9c35810..df4117b 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/change_email.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/change_email.rs @@ -29,7 +29,7 @@ pub async fn auth_change_email( ValidatedJson(payload): ValidatedJson, ) -> Result { service_change_email( - &state.write_db, + &state.db, &state.redis_session, &state.worker, session.user_id, diff --git a/crates/axumkit-server/src/api/v0/routes/auth/change_password.rs b/crates/axumkit-server/src/api/v0/routes/auth/change_password.rs index 1b21cd4..2732b1b 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/change_password.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/change_password.rs @@ -29,7 +29,7 @@ pub async fn auth_change_password( ValidatedJson(payload): ValidatedJson, ) -> Result { service_change_password( - &state.write_db, + &state.db, &state.redis_session, session.user_id, &session.session_id, diff --git a/crates/axumkit-server/src/api/v0/routes/auth/complete_signup.rs b/crates/axumkit-server/src/api/v0/routes/auth/complete_signup.rs index f94b620..bf04d88 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/complete_signup.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/complete_signup.rs @@ -16,10 +16,7 @@ use axumkit_dto::validator::json_validator::ValidatedJson; use axumkit_errors::errors::Errors; use std::net::SocketAddr; -/// OAuth pending signup을 완료합니다. /// -/// OAuth 로그인 시 신규 사용자인 경우 반환된 pending_token과 함께 -/// handle을 제출하여 가입을 완료합니다. #[utoipa::path( post, path = "/v0/auth/complete-signup", @@ -44,15 +41,15 @@ pub async fn auth_complete_signup( let user_agent_str = extract_user_agent(user_agent); let ip_address = extract_ip_address(&headers, addr); - // OAuth pending signup 완료 let session_id = service_complete_signup( - &state.write_db, + &state.db, &state.redis_session, &state.http_client, &state.r2_client, &state.worker, &payload.pending_token, &payload.handle, + &payload.display_name, &anonymous.anonymous_user_id, Some(user_agent_str), Some(ip_address), diff --git a/crates/axumkit-server/src/api/v0/routes/auth/confirm_email_change.rs b/crates/axumkit-server/src/api/v0/routes/auth/confirm_email_change.rs index 535985c..85d7cca 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/confirm_email_change.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/confirm_email_change.rs @@ -22,7 +22,7 @@ pub async fn auth_confirm_email_change( State(state): State, ValidatedJson(payload): ValidatedJson, ) -> Result { - service_confirm_email_change(&state.write_db, &state.redis_session, &payload.token).await?; + service_confirm_email_change(&state.db, &state.redis_session, &payload.token).await?; Ok(StatusCode::NO_CONTENT) } diff --git a/crates/axumkit-server/src/api/v0/routes/auth/forgot_password.rs b/crates/axumkit-server/src/api/v0/routes/auth/forgot_password.rs index a93758a..9c83560 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/forgot_password.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/forgot_password.rs @@ -23,7 +23,7 @@ pub async fn auth_forgot_password( ValidatedJson(payload): ValidatedJson, ) -> Result { service_forgot_password( - &state.write_db, + &state.db, &state.redis_session, &state.worker, &payload.email, diff --git a/crates/axumkit-server/src/api/v0/routes/auth/login.rs b/crates/axumkit-server/src/api/v0/routes/auth/login.rs index 66630bb..3386f59 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/login.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/login.rs @@ -41,9 +41,8 @@ pub async fn auth_login( let user_agent = extract_user_agent(user_agent); let ip_address = extract_ip_address(&headers, addr); - // 로그인 처리 let result = service_login( - &state.write_db, + &state.db, &state.redis_session, payload, Some(user_agent), @@ -55,12 +54,8 @@ pub async fn auth_login( LoginResult::SessionCreated { session_id, remember_me, - } => { - // 쿠키 설정하는 204 응답 반환 - create_login_response(session_id, remember_me) - } + } => create_login_response(session_id, remember_me), LoginResult::TotpRequired(temp_token) => { - // TOTP 필요: 202 + temp_token 반환 Ok(TotpRequiredResponse { temp_token }.into_response()) } } diff --git a/crates/axumkit-server/src/api/v0/routes/auth/logout.rs b/crates/axumkit-server/src/api/v0/routes/auth/logout.rs index 5f7bedd..e2f02e7 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/logout.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/logout.rs @@ -22,9 +22,7 @@ pub async fn auth_logout( State(state): State, RequiredSession(session_context): RequiredSession, ) -> Result { - // 로그아웃 처리 service_logout(&state.redis_session, &session_context.session_id).await?; - // 쿠키 클리어하는 204 응답 반환 create_logout_response() } diff --git a/crates/axumkit-server/src/api/v0/routes/auth/mod.rs b/crates/axumkit-server/src/api/v0/routes/auth/mod.rs index f1afe9c..e5938b3 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/mod.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/mod.rs @@ -10,5 +10,6 @@ pub mod openapi; pub mod resend_verification_email; pub mod reset_password; pub mod routes; +pub mod signup; pub mod totp; pub mod verify_email; diff --git a/crates/axumkit-server/src/api/v0/routes/auth/oauth/github/github_authorize.rs b/crates/axumkit-server/src/api/v0/routes/auth/oauth/github/github_authorize.rs index 9218d43..e05beac 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/oauth/github/github_authorize.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/oauth/github/github_authorize.rs @@ -8,7 +8,6 @@ use axumkit_dto::oauth::response::OAuthUrlResponse; use axumkit_dto::validator::query_validator::ValidatedQuery; use axumkit_errors::errors::Errors; -/// GitHub OAuth 인증 URL을 생성합니다. #[utoipa::path( get, path = "/v0/auth/oauth/github/authorize", diff --git a/crates/axumkit-server/src/api/v0/routes/auth/oauth/github/github_link.rs b/crates/axumkit-server/src/api/v0/routes/auth/oauth/github/github_link.rs index 896008e..08a0285 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/oauth/github/github_link.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/oauth/github/github_link.rs @@ -9,7 +9,6 @@ use axumkit_dto::oauth::request::link::GithubLinkRequest; use axumkit_dto::validator::json_validator::ValidatedJson; use axumkit_errors::errors::Errors; -/// GitHub OAuth를 현재 계정에 연결합니다. #[utoipa::path( post, path = "/v0/auth/oauth/github/link", @@ -33,7 +32,7 @@ pub async fn auth_github_link( ValidatedJson(payload): ValidatedJson, ) -> Result { service_link_github_oauth( - &state.write_db, + &state.db, &state.redis_session, &state.http_client, session_context.user_id, diff --git a/crates/axumkit-server/src/api/v0/routes/auth/oauth/github/github_login.rs b/crates/axumkit-server/src/api/v0/routes/auth/oauth/github/github_login.rs index d349ade..8d5fd45 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/oauth/github/github_login.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/oauth/github/github_login.rs @@ -16,10 +16,7 @@ use axumkit_dto::validator::json_validator::ValidatedJson; use axumkit_errors::errors::Errors; use std::net::SocketAddr; -/// GitHub OAuth 로그인을 처리합니다. /// -/// - 기존 사용자: 204 No Content + Set-Cookie -/// - 신규 사용자: 200 OK + pending signup 정보 (complete-signup 필요) #[utoipa::path( post, path = "/v0/auth/oauth/github/login", @@ -44,9 +41,8 @@ pub async fn auth_github_login( let user_agent_str = extract_user_agent(user_agent); let ip_address = extract_ip_address(&headers, addr); - // GitHub OAuth 로그인 처리 let result = service_github_sign_in( - &state.write_db, + &state.db, &state.redis_session, &state.http_client, &payload.code, @@ -57,6 +53,5 @@ pub async fn auth_github_login( ) .await?; - // SignInResult를 HTTP 응답으로 변환 OAuthSignInResponse::from_result(result).into_response_result() } diff --git a/crates/axumkit-server/src/api/v0/routes/auth/oauth/google/google_authorize.rs b/crates/axumkit-server/src/api/v0/routes/auth/oauth/google/google_authorize.rs index c1d94bd..1ccfe12 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/oauth/google/google_authorize.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/oauth/google/google_authorize.rs @@ -8,7 +8,6 @@ use axumkit_dto::oauth::response::OAuthUrlResponse; use axumkit_dto::validator::query_validator::ValidatedQuery; use axumkit_errors::errors::Errors; -/// Google OAuth 인증 URL을 생성합니다. #[utoipa::path( get, path = "/v0/auth/oauth/google/authorize", diff --git a/crates/axumkit-server/src/api/v0/routes/auth/oauth/google/google_link.rs b/crates/axumkit-server/src/api/v0/routes/auth/oauth/google/google_link.rs index 5dc14db..afc3a02 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/oauth/google/google_link.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/oauth/google/google_link.rs @@ -9,7 +9,6 @@ use axumkit_dto::oauth::request::link::GoogleLinkRequest; use axumkit_dto::validator::json_validator::ValidatedJson; use axumkit_errors::errors::Errors; -/// Google OAuth를 현재 계정에 연결합니다. #[utoipa::path( post, path = "/v0/auth/oauth/google/link", @@ -33,7 +32,7 @@ pub async fn auth_google_link( ValidatedJson(payload): ValidatedJson, ) -> Result { service_link_google_oauth( - &state.write_db, + &state.db, &state.redis_session, &state.http_client, session_context.user_id, diff --git a/crates/axumkit-server/src/api/v0/routes/auth/oauth/google/google_login.rs b/crates/axumkit-server/src/api/v0/routes/auth/oauth/google/google_login.rs index d38a988..e65a47c 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/oauth/google/google_login.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/oauth/google/google_login.rs @@ -16,10 +16,7 @@ use axumkit_dto::validator::json_validator::ValidatedJson; use axumkit_errors::errors::Errors; use std::net::SocketAddr; -/// Google OAuth 로그인을 처리합니다. /// -/// - 기존 사용자: 204 No Content + Set-Cookie -/// - 신규 사용자: 200 OK + pending signup 정보 (complete-signup 필요) #[utoipa::path( post, path = "/v0/auth/oauth/google/login", @@ -44,9 +41,8 @@ pub async fn auth_google_login( let user_agent_str = extract_user_agent(user_agent); let ip_address = extract_ip_address(&headers, addr); - // Google OAuth 로그인 처리 let result = service_google_sign_in( - &state.write_db, + &state.db, &state.redis_session, &state.http_client, &payload.code, @@ -57,6 +53,5 @@ pub async fn auth_google_login( ) .await?; - // SignInResult를 HTTP 응답으로 변환 OAuthSignInResponse::from_result(result).into_response_result() } diff --git a/crates/axumkit-server/src/api/v0/routes/auth/oauth/google/google_one_tap_login.rs b/crates/axumkit-server/src/api/v0/routes/auth/oauth/google/google_one_tap_login.rs new file mode 100644 index 0000000..1fd8d5a --- /dev/null +++ b/crates/axumkit-server/src/api/v0/routes/auth/oauth/google/google_one_tap_login.rs @@ -0,0 +1,57 @@ +use crate::middleware::anonymous_user::AnonymousUserContext; +use crate::service::oauth::google::service_google_one_tap_sign_in; +use crate::state::AppState; +use crate::utils::extract::extract_ip_address::extract_ip_address; +use crate::utils::extract::extract_user_agent::extract_user_agent; +use axum::Extension; +use axum::{ + extract::{ConnectInfo, State}, + http::HeaderMap, + response::Response, +}; +use axum_extra::{TypedHeader, headers::UserAgent}; +use axumkit_dto::oauth::request::google::GoogleOneTapLoginRequest; +use axumkit_dto::oauth::response::{OAuthPendingSignupResponse, OAuthSignInResponse}; +use axumkit_dto::validator::json_validator::ValidatedJson; +use axumkit_errors::errors::{ErrorResponse, Errors}; +use std::net::SocketAddr; + +#[utoipa::path( + post, + path = "/v0/auth/oauth/google/one-tap/login", + summary = "Sign in with Google One Tap", + description = "Validates the Google ID token on the server. Existing linked accounts receive a session immediately. New identities receive a pending signup token that must be completed via POST /v0/auth/complete-signup.", + request_body = GoogleOneTapLoginRequest, + responses( + (status = 200, description = "Google identity was accepted but profile completion is still required", body = OAuthPendingSignupResponse), + (status = 204, description = "Google identity matched an existing account and a session cookie was issued"), + (status = 400, description = "Malformed JSON payload, validation error, invalid ID token, or the Google account email is not verified", body = ErrorResponse), + (status = 409, description = "A local account already uses the same email address", body = ErrorResponse), + (status = 500, description = "Unexpected database, Redis, JWKS, or Google OAuth error", body = ErrorResponse) + ), + tag = "Auth" +)] +pub async fn auth_google_one_tap_login( + user_agent: Option>, + headers: HeaderMap, + ConnectInfo(addr): ConnectInfo, + State(state): State, + Extension(anonymous): Extension, + ValidatedJson(payload): ValidatedJson, +) -> Result { + let user_agent_str = extract_user_agent(user_agent); + let ip_address = extract_ip_address(&headers, addr); + + let result = service_google_one_tap_sign_in( + &state.db, + &state.redis_session, + &state.http_client, + &payload.credential, + &anonymous.anonymous_user_id, + Some(user_agent_str), + Some(ip_address), + ) + .await?; + + OAuthSignInResponse::from_result(result).into_response_result() +} diff --git a/crates/axumkit-server/src/api/v0/routes/auth/oauth/google/mod.rs b/crates/axumkit-server/src/api/v0/routes/auth/oauth/google/mod.rs index 2e75ec5..0480409 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/oauth/google/mod.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/oauth/google/mod.rs @@ -1,3 +1,4 @@ pub mod google_authorize; pub mod google_link; pub mod google_login; +pub mod google_one_tap_login; diff --git a/crates/axumkit-server/src/api/v0/routes/auth/oauth/list_oauth_connections.rs b/crates/axumkit-server/src/api/v0/routes/auth/oauth/list_oauth_connections.rs index 44aa6ea..f812fc5 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/oauth/list_oauth_connections.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/oauth/list_oauth_connections.rs @@ -5,7 +5,6 @@ use axum::extract::State; use axumkit_dto::oauth::response::OAuthConnectionListResponse; use axumkit_errors::errors::Errors; -/// 현재 사용자의 OAuth 연결 목록을 조회합니다. #[utoipa::path( get, path = "/v0/auth/oauth/connections", @@ -23,7 +22,7 @@ pub async fn list_oauth_connections( State(state): State, RequiredSession(session_context): RequiredSession, ) -> Result { - let result = service_list_oauth_connections(&state.read_db, session_context.user_id).await?; + let result = service_list_oauth_connections(&state.db, session_context.user_id).await?; Ok(result) } diff --git a/crates/axumkit-server/src/api/v0/routes/auth/oauth/unlink_oauth_connection.rs b/crates/axumkit-server/src/api/v0/routes/auth/oauth/unlink_oauth_connection.rs index a787d7c..4b60c32 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/oauth/unlink_oauth_connection.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/oauth/unlink_oauth_connection.rs @@ -7,7 +7,6 @@ use axumkit_dto::oauth::request::unlink::UnlinkOAuthRequest; use axumkit_dto::validator::json_validator::ValidatedJson; use axumkit_errors::errors::Errors; -/// OAuth 연결을 해제합니다. #[utoipa::path( post, path = "/v0/auth/oauth/connections/unlink", @@ -29,7 +28,7 @@ pub async fn unlink_oauth_connection( RequiredSession(session_context): RequiredSession, ValidatedJson(payload): ValidatedJson, ) -> Result { - service_unlink_oauth(&state.write_db, session_context.user_id, payload.provider).await?; + service_unlink_oauth(&state.db, session_context.user_id, payload.provider).await?; Ok(StatusCode::NO_CONTENT) } diff --git a/crates/axumkit-server/src/api/v0/routes/auth/openapi.rs b/crates/axumkit-server/src/api/v0/routes/auth/openapi.rs index 04b8f48..229b498 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/openapi.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/openapi.rs @@ -1,7 +1,8 @@ use axumkit_dto::auth::request::{ ChangeEmailRequest, ChangePasswordRequest, CompleteSignupRequest, ConfirmEmailChangeRequest, - ForgotPasswordRequest, LoginRequest, ResetPasswordRequest, TotpDisableRequest, - TotpEnableRequest, TotpRegenerateBackupCodesRequest, TotpVerifyRequest, VerifyEmailRequest, + ForgotPasswordRequest, LoginRequest, ResendVerificationEmailRequest, ResetPasswordRequest, + TotpDisableRequest, TotpEnableRequest, TotpRegenerateBackupCodesRequest, TotpVerifyRequest, + VerifyEmailRequest, }; use axumkit_dto::auth::response::{ TotpBackupCodesResponse, TotpEnableResponse, TotpRequiredResponse, TotpSetupResponse, @@ -9,17 +10,19 @@ use axumkit_dto::auth::response::{ }; use axumkit_dto::oauth::request::{ GithubLinkRequest, GithubLoginRequest, GoogleLinkRequest, GoogleLoginRequest, - UnlinkOAuthRequest, + GoogleOneTapLoginRequest, OAuthAuthorizeFlow, OAuthAuthorizeQuery, UnlinkOAuthRequest, }; use axumkit_dto::oauth::response::OAuthPendingSignupResponse; use axumkit_dto::oauth::response::{ OAuthConnectionListResponse, OAuthConnectionResponse, OAuthUrlResponse, }; +use axumkit_dto::user::{CreateUserRequest, CreateUserResponse}; use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( + super::signup::auth_signup, super::login::auth_login, super::logout::auth_logout, super::forgot_password::auth_forgot_password, @@ -33,6 +36,7 @@ use utoipa::OpenApi; super::totp::regenerate_backup_codes::totp_regenerate_backup_codes, super::oauth::google::google_authorize::auth_google_authorize, super::oauth::google::google_login::auth_google_login, + super::oauth::google::google_one_tap_login::auth_google_one_tap_login, super::oauth::google::google_link::auth_google_link, super::oauth::github::github_authorize::auth_github_authorize, super::oauth::github::github_login::auth_github_login, @@ -47,14 +51,20 @@ use utoipa::OpenApi; ), components( schemas( + CreateUserRequest, + CreateUserResponse, LoginRequest, VerifyEmailRequest, + ResendVerificationEmailRequest, ForgotPasswordRequest, ResetPasswordRequest, CompleteSignupRequest, OAuthUrlResponse, OAuthPendingSignupResponse, + OAuthAuthorizeFlow, + OAuthAuthorizeQuery, GoogleLoginRequest, + GoogleOneTapLoginRequest, GithubLoginRequest, GoogleLinkRequest, GithubLinkRequest, diff --git a/crates/axumkit-server/src/api/v0/routes/auth/resend_verification_email.rs b/crates/axumkit-server/src/api/v0/routes/auth/resend_verification_email.rs index d284c38..2d03ed1 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/resend_verification_email.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/resend_verification_email.rs @@ -1,39 +1,32 @@ -use crate::extractors::RequiredSession; use crate::service::auth::resend_verification_email::service_resend_verification_email; use crate::state::AppState; use axum::extract::State; use axum::http::StatusCode; use axum::response::IntoResponse; -use axumkit_errors::errors::Errors; +use axumkit_dto::auth::request::ResendVerificationEmailRequest; +use axumkit_dto::validator::json_validator::ValidatedJson; +use axumkit_errors::errors::{ErrorResponse, Errors}; #[utoipa::path( post, path = "/v0/auth/resend-verification-email", + summary = "Resend the pending signup verification email", + description = "Looks up an existing email and password signup that is still pending and resends the same verification token with its remaining validity window. Returns 204 No Content even when no pending signup exists to avoid email enumeration.", + request_body = ResendVerificationEmailRequest, responses( - (status = 204, description = "Verification email sent successfully"), - (status = 401, description = "Unauthorized - Invalid or expired session, or OAuth user without password"), - (status = 404, description = "Not Found - User not found"), - (status = 409, description = "Conflict - Email already verified"), - (status = 500, description = "Internal Server Error - Database or Redis error"), - (status = 502, description = "Bad Gateway - Worker service request failed or returned invalid response"), - (status = 503, description = "Service Unavailable - Worker service connection failed") - ), - security( - ("session_id_cookie" = []) + (status = 204, description = "Verification email was resent when a pending signup existed"), + (status = 400, description = "Malformed JSON payload or validation error", body = ErrorResponse), + (status = 500, description = "Unexpected Redis error", body = ErrorResponse), + (status = 502, description = "Worker service rejected the verification email job or returned an invalid response", body = ErrorResponse), + (status = 503, description = "Worker service could not be reached", body = ErrorResponse) ), tag = "Auth" )] pub async fn auth_resend_verification_email( State(state): State, - RequiredSession(session_context): RequiredSession, + ValidatedJson(payload): ValidatedJson, ) -> Result { - service_resend_verification_email( - &state.write_db, - &state.redis_session, - &state.worker, - session_context.user_id, - ) - .await?; + service_resend_verification_email(&state.redis_session, &state.worker, &payload.email).await?; Ok(StatusCode::NO_CONTENT) } diff --git a/crates/axumkit-server/src/api/v0/routes/auth/reset_password.rs b/crates/axumkit-server/src/api/v0/routes/auth/reset_password.rs index 5381c32..1fc5ec4 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/reset_password.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/reset_password.rs @@ -23,7 +23,7 @@ pub async fn auth_reset_password( ValidatedJson(payload): ValidatedJson, ) -> Result { service_reset_password( - &state.write_db, + &state.db, &state.redis_session, &payload.token, &payload.new_password, diff --git a/crates/axumkit-server/src/api/v0/routes/auth/routes.rs b/crates/axumkit-server/src/api/v0/routes/auth/routes.rs index 6932cf6..48b8413 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/routes.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/routes.rs @@ -7,6 +7,7 @@ use super::login::auth_login; use super::logout::auth_logout; use super::resend_verification_email::auth_resend_verification_email; use super::reset_password::auth_reset_password; +use super::signup::auth_signup; use super::totp::disable::totp_disable; use super::totp::enable::totp_enable; use super::totp::regenerate_backup_codes::totp_regenerate_backup_codes; @@ -20,6 +21,7 @@ use crate::api::v0::routes::auth::oauth::github::github_login::auth_github_login use crate::api::v0::routes::auth::oauth::google::google_authorize::auth_google_authorize; use crate::api::v0::routes::auth::oauth::google::google_link::auth_google_link; use crate::api::v0::routes::auth::oauth::google::google_login::auth_google_login; +use crate::api::v0::routes::auth::oauth::google::google_one_tap_login::auth_google_one_tap_login; use crate::api::v0::routes::auth::oauth::list_oauth_connections::list_oauth_connections; use crate::api::v0::routes::auth::oauth::unlink_oauth_connection::unlink_oauth_connection; use crate::state::AppState; @@ -34,10 +36,6 @@ pub fn auth_routes(_state: AppState) -> Router { "/auth/oauth/connections/unlink", post(unlink_oauth_connection), ) - .route( - "/auth/resend-verification-email", - post(auth_resend_verification_email), - ) // TOTP protected routes (require session) .route("/auth/totp/status", get(totp_status)) .route("/auth/totp/disable", post(totp_disable)) @@ -46,6 +44,10 @@ pub fn auth_routes(_state: AppState) -> Router { .route("/auth/oauth/github/authorize", get(auth_github_authorize)) // OAuth login routes (code exchange) .route("/auth/oauth/google/login", post(auth_google_login)) + .route( + "/auth/oauth/google/one-tap/login", + post(auth_google_one_tap_login), + ) .route("/auth/oauth/github/login", post(auth_github_login)) // OAuth complete signup (pending token + handle) .route("/auth/complete-signup", post(auth_complete_signup)) @@ -54,8 +56,15 @@ pub fn auth_routes(_state: AppState) -> Router { .route("/auth/oauth/github/link", post(auth_github_link)) // Email/password login route .route("/auth/login", post(auth_login)) + // Email signup route (public, deferred creation) + .route("/auth/signup", post(auth_signup)) // Email verification route (public) .route("/auth/verify-email", post(auth_verify_email)) + // Resend verification email (public, email-based) + .route( + "/auth/resend-verification-email", + post(auth_resend_verification_email), + ) // TOTP setup/enable routes (require session) .route("/auth/totp/setup", post(totp_setup)) .route("/auth/totp/enable", post(totp_enable)) diff --git a/crates/axumkit-server/src/api/v0/routes/auth/signup.rs b/crates/axumkit-server/src/api/v0/routes/auth/signup.rs new file mode 100644 index 0000000..99978cd --- /dev/null +++ b/crates/axumkit-server/src/api/v0/routes/auth/signup.rs @@ -0,0 +1,31 @@ +use crate::service::auth::signup::service_signup; +use crate::state::AppState; +use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; +use axumkit_dto::user::{CreateUserRequest, CreateUserResponse}; +use axumkit_dto::validator::json_validator::ValidatedJson; +use axumkit_errors::errors::{ErrorResponse, Errors}; + +#[utoipa::path( + post, + path = "/v0/auth/signup", + summary = "Start an email and password signup", + description = "Validates the requested email, handle, and password, stores a pending signup in Redis, and queues a verification email. The user account is created only after the token is submitted to POST /v0/auth/verify-email.", + request_body = CreateUserRequest, + responses( + (status = 202, description = "Verification email queued and pending signup stored", body = CreateUserResponse), + (status = 400, description = "Malformed JSON payload or validation error", body = ErrorResponse), + (status = 409, description = "The email or handle is already in use or reserved by another pending signup", body = ErrorResponse), + (status = 500, description = "Unexpected database or Redis error", body = ErrorResponse), + (status = 502, description = "Worker service rejected the verification email job or returned an invalid response", body = ErrorResponse), + (status = 503, description = "Worker service could not be reached", body = ErrorResponse), + ), + tag = "Auth" +)] +pub async fn auth_signup( + State(state): State, + ValidatedJson(payload): ValidatedJson, +) -> Result { + let response = service_signup(&state.db, &state.redis_session, &state.worker, payload).await?; + + Ok((StatusCode::ACCEPTED, Json(response))) +} diff --git a/crates/axumkit-server/src/api/v0/routes/auth/totp/disable.rs b/crates/axumkit-server/src/api/v0/routes/auth/totp/disable.rs index a71f0d6..f0280ad 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/totp/disable.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/totp/disable.rs @@ -1,5 +1,4 @@ use crate::extractors::RequiredSession; -use crate::repository::user::repository_get_user_by_id; use crate::service::auth::totp::service_totp_disable; use crate::state::AppState; use axum::extract::State; @@ -28,7 +27,6 @@ pub async fn totp_disable( RequiredSession(session): RequiredSession, ValidatedJson(payload): ValidatedJson, ) -> Result { - let user = repository_get_user_by_id(&state.write_db, session.user_id).await?; - service_totp_disable(&state.write_db, session.user_id, &user.email, &payload.code).await?; + service_totp_disable(&state.db, session.user_id, &payload.code).await?; Ok(StatusCode::NO_CONTENT) } diff --git a/crates/axumkit-server/src/api/v0/routes/auth/totp/enable.rs b/crates/axumkit-server/src/api/v0/routes/auth/totp/enable.rs index a82c5f9..7d89f22 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/totp/enable.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/totp/enable.rs @@ -1,5 +1,4 @@ use crate::extractors::RequiredSession; -use crate::repository::user::repository_get_user_by_id; use crate::service::auth::totp::service_totp_enable; use crate::state::AppState; use axum::extract::State; @@ -29,6 +28,5 @@ pub async fn totp_enable( RequiredSession(session): RequiredSession, ValidatedJson(payload): ValidatedJson, ) -> Result { - let user = repository_get_user_by_id(&state.write_db, session.user_id).await?; - service_totp_enable(&state.write_db, session.user_id, &user.email, &payload.code).await + service_totp_enable(&state.db, session.user_id, &payload.code).await } diff --git a/crates/axumkit-server/src/api/v0/routes/auth/totp/regenerate_backup_codes.rs b/crates/axumkit-server/src/api/v0/routes/auth/totp/regenerate_backup_codes.rs index 04ddcd9..2de1f86 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/totp/regenerate_backup_codes.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/totp/regenerate_backup_codes.rs @@ -1,5 +1,4 @@ use crate::extractors::RequiredSession; -use crate::repository::user::repository_get_user_by_id; use crate::service::auth::totp::service_regenerate_backup_codes; use crate::state::AppState; use axum::extract::State; @@ -28,7 +27,5 @@ pub async fn totp_regenerate_backup_codes( RequiredSession(session): RequiredSession, ValidatedJson(payload): ValidatedJson, ) -> Result { - let user = repository_get_user_by_id(&state.write_db, session.user_id).await?; - service_regenerate_backup_codes(&state.write_db, session.user_id, &user.email, &payload.code) - .await + service_regenerate_backup_codes(&state.db, session.user_id, &payload.code).await } diff --git a/crates/axumkit-server/src/api/v0/routes/auth/totp/setup.rs b/crates/axumkit-server/src/api/v0/routes/auth/totp/setup.rs index 6000823..4b34aef 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/totp/setup.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/totp/setup.rs @@ -1,5 +1,4 @@ use crate::extractors::RequiredSession; -use crate::repository::user::repository_get_user_by_id; use crate::service::auth::totp::service_totp_setup; use crate::state::AppState; use axum::extract::State; @@ -24,6 +23,5 @@ pub async fn totp_setup( State(state): State, RequiredSession(session): RequiredSession, ) -> Result { - let user = repository_get_user_by_id(&state.write_db, session.user_id).await?; - service_totp_setup(&state.write_db, session.user_id, &user.email).await + service_totp_setup(&state.db, session.user_id).await } diff --git a/crates/axumkit-server/src/api/v0/routes/auth/totp/status.rs b/crates/axumkit-server/src/api/v0/routes/auth/totp/status.rs index 58f57cc..d440193 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/totp/status.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/totp/status.rs @@ -22,5 +22,5 @@ pub async fn totp_status( State(state): State, RequiredSession(session): RequiredSession, ) -> Result { - service_totp_status(&state.read_db, session.user_id).await + service_totp_status(&state.db, session.user_id).await } diff --git a/crates/axumkit-server/src/api/v0/routes/auth/totp/verify.rs b/crates/axumkit-server/src/api/v0/routes/auth/totp/verify.rs index 3edbfa3..081cdf7 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/totp/verify.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/totp/verify.rs @@ -23,7 +23,7 @@ pub async fn totp_verify( ValidatedJson(payload): ValidatedJson, ) -> Result { let result = service_totp_verify( - &state.write_db, + &state.db, &state.redis_session, &payload.temp_token, &payload.code, diff --git a/crates/axumkit-server/src/api/v0/routes/auth/verify_email.rs b/crates/axumkit-server/src/api/v0/routes/auth/verify_email.rs index 9de3659..9a0c607 100644 --- a/crates/axumkit-server/src/api/v0/routes/auth/verify_email.rs +++ b/crates/axumkit-server/src/api/v0/routes/auth/verify_email.rs @@ -1,3 +1,4 @@ +use crate::bridge::worker_client; use crate::service::auth::verify_email::service_verify_email; use crate::state::AppState; use axum::extract::State; @@ -5,18 +6,19 @@ use axum::http::StatusCode; use axum::response::IntoResponse; use axumkit_dto::auth::request::VerifyEmailRequest; use axumkit_dto::validator::json_validator::ValidatedJson; -use axumkit_errors::errors::Errors; +use axumkit_errors::errors::{ErrorResponse, Errors}; #[utoipa::path( post, path = "/v0/auth/verify-email", + summary = "Complete an email signup with a verification token", + description = "Consumes the pending email verification token, creates the user account if the email and handle are still available, and then schedules background indexing. The token is only cleaned up after the database commit succeeds.", request_body = VerifyEmailRequest, responses( - (status = 204, description = "Email verified successfully"), - (status = 400, description = "Bad request - Invalid JSON, validation error, invalid or expired token"), - (status = 404, description = "Not Found - User not found"), - (status = 409, description = "Conflict - Email already verified"), - (status = 500, description = "Internal Server Error - Database or Redis error") + (status = 204, description = "Verification token accepted and the account was created"), + (status = 400, description = "Malformed JSON payload, validation error, or invalid verification token", body = ErrorResponse), + (status = 409, description = "The email or handle became unavailable before the account was created", body = ErrorResponse), + (status = 500, description = "Unexpected database or Redis error", body = ErrorResponse) ), tag = "Auth" )] @@ -24,7 +26,9 @@ pub async fn auth_verify_email( State(state): State, ValidatedJson(payload): ValidatedJson, ) -> Result { - service_verify_email(&state.write_db, &state.redis_session, &payload.token).await?; + let user_id = service_verify_email(&state.db, &state.redis_session, &payload.token).await?; + + worker_client::index_user(&state.worker, user_id).await.ok(); Ok(StatusCode::NO_CONTENT) } diff --git a/crates/axumkit-server/src/api/v0/routes/mod.rs b/crates/axumkit-server/src/api/v0/routes/mod.rs index f8f1a19..4e1f2e3 100644 --- a/crates/axumkit-server/src/api/v0/routes/mod.rs +++ b/crates/axumkit-server/src/api/v0/routes/mod.rs @@ -1,7 +1,7 @@ mod action_logs; mod auth; +mod moderation; pub mod openapi; -mod posts; pub mod routes; mod search; mod stream; diff --git a/crates/axumkit-server/src/api/v0/routes/moderation/list_logs.rs b/crates/axumkit-server/src/api/v0/routes/moderation/list_logs.rs new file mode 100644 index 0000000..a7094f2 --- /dev/null +++ b/crates/axumkit-server/src/api/v0/routes/moderation/list_logs.rs @@ -0,0 +1,24 @@ +use crate::service::moderation::service_list_moderation_logs; +use crate::state::AppState; +use axum::extract::State; +use axumkit_dto::moderation::{ListModerationLogsRequest, ListModerationLogsResponse}; +use axumkit_dto::validator::query_validator::ValidatedQuery; +use axumkit_errors::errors::Errors; + +#[utoipa::path( + get, + path = "/v0/moderation/logs", + params(ListModerationLogsRequest), + responses( + (status = 200, description = "Moderation logs retrieved successfully", body = ListModerationLogsResponse), + (status = 400, description = "Bad request - Invalid query parameters or validation error"), + (status = 500, description = "Internal Server Error - Database error") + ), + tag = "Moderation" +)] +pub async fn list_moderation_logs( + State(state): State, + ValidatedQuery(payload): ValidatedQuery, +) -> Result { + service_list_moderation_logs(&state.db, payload).await +} diff --git a/crates/axumkit-server/src/api/v0/routes/moderation/mod.rs b/crates/axumkit-server/src/api/v0/routes/moderation/mod.rs new file mode 100644 index 0000000..0fc7547 --- /dev/null +++ b/crates/axumkit-server/src/api/v0/routes/moderation/mod.rs @@ -0,0 +1,3 @@ +pub mod list_logs; +pub mod openapi; +pub mod routes; diff --git a/crates/axumkit-server/src/api/v0/routes/moderation/openapi.rs b/crates/axumkit-server/src/api/v0/routes/moderation/openapi.rs new file mode 100644 index 0000000..deda9fb --- /dev/null +++ b/crates/axumkit-server/src/api/v0/routes/moderation/openapi.rs @@ -0,0 +1,20 @@ +use axumkit_dto::moderation::{ + ListModerationLogsRequest, ListModerationLogsResponse, ModerationLogListItem, +}; +use utoipa::OpenApi; + +use super::list_logs::__path_list_moderation_logs; + +#[derive(OpenApi)] +#[openapi( + paths(list_moderation_logs), + components(schemas( + ListModerationLogsRequest, + ModerationLogListItem, + ListModerationLogsResponse, + )), + tags( + (name = "Moderation", description = "Moderation logs") + ) +)] +pub struct ModerationOpenApi; diff --git a/crates/axumkit-server/src/api/v0/routes/moderation/routes.rs b/crates/axumkit-server/src/api/v0/routes/moderation/routes.rs new file mode 100644 index 0000000..fcc1113 --- /dev/null +++ b/crates/axumkit-server/src/api/v0/routes/moderation/routes.rs @@ -0,0 +1,8 @@ +use crate::state::AppState; +use axum::{Router, routing::get}; + +use super::list_logs::list_moderation_logs; + +pub fn moderation_routes() -> Router { + Router::new().route("/moderation/logs", get(list_moderation_logs)) +} diff --git a/crates/axumkit-server/src/api/v0/routes/openapi.rs b/crates/axumkit-server/src/api/v0/routes/openapi.rs index 5c4f867..6d1491a 100644 --- a/crates/axumkit-server/src/api/v0/routes/openapi.rs +++ b/crates/axumkit-server/src/api/v0/routes/openapi.rs @@ -1,6 +1,6 @@ use super::action_logs::openapi::ActionLogsOpenApi; use super::auth::openapi::AuthApiDoc; -use super::posts::openapi::PostsApiDoc; +use super::moderation::openapi::ModerationOpenApi; use super::search::openapi::SearchApiDoc; use super::stream::openapi::StreamOpenApi; use super::user::openapi::UserApiDoc; @@ -15,9 +15,9 @@ impl V0ApiDoc { let mut openapi = Self::openapi(); openapi.merge(AuthApiDoc::openapi()); openapi.merge(UserApiDoc::openapi()); - openapi.merge(PostsApiDoc::openapi()); openapi.merge(SearchApiDoc::openapi()); openapi.merge(ActionLogsOpenApi::openapi()); + openapi.merge(ModerationOpenApi::openapi()); openapi.merge(StreamOpenApi::openapi()); openapi } diff --git a/crates/axumkit-server/src/api/v0/routes/posts/create_post.rs b/crates/axumkit-server/src/api/v0/routes/posts/create_post.rs deleted file mode 100644 index d165b9e..0000000 --- a/crates/axumkit-server/src/api/v0/routes/posts/create_post.rs +++ /dev/null @@ -1,37 +0,0 @@ -use crate::extractors::session::RequiredSession; -use crate::service::posts::service_create_post; -use crate::state::AppState; -use axum::{extract::State, response::IntoResponse}; -use axumkit_dto::posts::{CreatePostRequest, CreatePostResponse}; -use axumkit_dto::validator::json_validator::ValidatedJson; -use axumkit_errors::errors::Errors; - -#[utoipa::path( - post, - path = "/v0/posts", - request_body = CreatePostRequest, - responses( - (status = 201, description = "Post created successfully", body = CreatePostResponse), - (status = 400, description = "Bad request - Invalid JSON or validation error"), - (status = 401, description = "Unauthorized - User not authenticated"), - (status = 500, description = "Internal Server Error"), - ), - tag = "Posts" -)] -pub async fn create_post( - State(state): State, - RequiredSession(session): RequiredSession, - ValidatedJson(payload): ValidatedJson, -) -> Result { - let response = service_create_post( - &state.write_db, - &state.seaweedfs_client, - &state.worker, - session.user_id, - payload.title, - payload.content, - ) - .await?; - - Ok(response) -} diff --git a/crates/axumkit-server/src/api/v0/routes/posts/delete_post.rs b/crates/axumkit-server/src/api/v0/routes/posts/delete_post.rs deleted file mode 100644 index 1f25728..0000000 --- a/crates/axumkit-server/src/api/v0/routes/posts/delete_post.rs +++ /dev/null @@ -1,27 +0,0 @@ -use crate::extractors::session::RequiredSession; -use crate::service::posts::service_delete_post; -use crate::state::AppState; -use axum::extract::State; -use axumkit_dto::posts::{DeletePostResponse, GetPostPath}; -use axumkit_dto::validator::path_validator::ValidatedPath; -use axumkit_errors::errors::Errors; - -#[utoipa::path( - delete, - path = "/v0/posts/{id}", - params(GetPostPath), - responses( - (status = 200, description = "Post deleted successfully", body = DeletePostResponse), - (status = 401, description = "Unauthorized - User not authenticated or not the author"), - (status = 404, description = "Post not found"), - (status = 500, description = "Internal Server Error"), - ), - tag = "Posts" -)] -pub async fn delete_post( - State(state): State, - RequiredSession(session): RequiredSession, - ValidatedPath(path): ValidatedPath, -) -> Result { - service_delete_post(&state.write_db, &state.worker, path.id, session.user_id).await -} diff --git a/crates/axumkit-server/src/api/v0/routes/posts/get_post.rs b/crates/axumkit-server/src/api/v0/routes/posts/get_post.rs deleted file mode 100644 index e51b85e..0000000 --- a/crates/axumkit-server/src/api/v0/routes/posts/get_post.rs +++ /dev/null @@ -1,24 +0,0 @@ -use crate::service::posts::service_get_post; -use crate::state::AppState; -use axum::extract::State; -use axumkit_dto::posts::{GetPostPath, PostResponse}; -use axumkit_dto::validator::path_validator::ValidatedPath; -use axumkit_errors::errors::Errors; - -#[utoipa::path( - get, - path = "/v0/posts/{id}", - params(GetPostPath), - responses( - (status = 200, description = "Post retrieved successfully", body = PostResponse), - (status = 404, description = "Post not found"), - (status = 500, description = "Internal Server Error"), - ), - tag = "Posts" -)] -pub async fn get_post( - State(state): State, - ValidatedPath(path): ValidatedPath, -) -> Result { - service_get_post(&state.read_db, &state.seaweedfs_client, path.id).await -} diff --git a/crates/axumkit-server/src/api/v0/routes/posts/list_posts.rs b/crates/axumkit-server/src/api/v0/routes/posts/list_posts.rs deleted file mode 100644 index aff6267..0000000 --- a/crates/axumkit-server/src/api/v0/routes/posts/list_posts.rs +++ /dev/null @@ -1,29 +0,0 @@ -use crate::service::posts::service_list_posts; -use crate::state::AppState; -use axum::{ - extract::{Query, State}, - response::IntoResponse, -}; -use axumkit_dto::posts::{ListPostsQuery, ListPostsResponse}; -use axumkit_errors::errors::Errors; - -#[utoipa::path( - get, - path = "/v0/posts", - params( - ("limit" = u64, Query, description = "Number of posts to return"), - ("offset" = u64, Query, description = "Number of posts to skip") - ), - responses( - (status = 200, description = "Posts retrieved successfully", body = ListPostsResponse), - (status = 500, description = "Internal Server Error"), - ), - tag = "Posts" -)] -pub async fn list_posts( - State(state): State, - Query(query): Query, -) -> Result { - let response = service_list_posts(&state.read_db, query.limit, query.offset).await?; - Ok(response) -} diff --git a/crates/axumkit-server/src/api/v0/routes/posts/mod.rs b/crates/axumkit-server/src/api/v0/routes/posts/mod.rs deleted file mode 100644 index 6e3e867..0000000 --- a/crates/axumkit-server/src/api/v0/routes/posts/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod create_post; -pub mod delete_post; -pub mod get_post; -pub mod list_posts; -pub mod openapi; -pub mod routes; -pub mod update_post; diff --git a/crates/axumkit-server/src/api/v0/routes/posts/openapi.rs b/crates/axumkit-server/src/api/v0/routes/posts/openapi.rs deleted file mode 100644 index c6d0ec0..0000000 --- a/crates/axumkit-server/src/api/v0/routes/posts/openapi.rs +++ /dev/null @@ -1,33 +0,0 @@ -use axumkit_dto::posts::{ - CreatePostRequest, CreatePostResponse, DeletePostResponse, GetPostPath, ListPostsQuery, - ListPostsResponse, PostListItem, PostResponse, UpdatePostRequest, -}; -use utoipa::OpenApi; - -#[derive(OpenApi)] -#[openapi( - paths( - super::create_post::create_post, - super::get_post::get_post, - super::list_posts::list_posts, - super::update_post::update_post, - super::delete_post::delete_post, - ), - components( - schemas( - CreatePostRequest, - CreatePostResponse, - GetPostPath, - ListPostsQuery, - ListPostsResponse, - PostListItem, - PostResponse, - UpdatePostRequest, - DeletePostResponse, - ) - ), - tags( - (name = "Posts", description = "Posts endpoints") - ) -)] -pub struct PostsApiDoc; diff --git a/crates/axumkit-server/src/api/v0/routes/posts/routes.rs b/crates/axumkit-server/src/api/v0/routes/posts/routes.rs deleted file mode 100644 index 45a621a..0000000 --- a/crates/axumkit-server/src/api/v0/routes/posts/routes.rs +++ /dev/null @@ -1,19 +0,0 @@ -use super::create_post::create_post; -use super::delete_post::delete_post; -use super::get_post::get_post; -use super::list_posts::list_posts; -use super::update_post::update_post; -use crate::state::AppState; -use axum::{ - Router, - routing::{get, post}, -}; - -pub fn posts_routes() -> Router { - Router::new() - .route("/posts", post(create_post).get(list_posts)) - .route( - "/posts/{id}", - get(get_post).patch(update_post).delete(delete_post), - ) -} diff --git a/crates/axumkit-server/src/api/v0/routes/posts/update_post.rs b/crates/axumkit-server/src/api/v0/routes/posts/update_post.rs deleted file mode 100644 index 1d2d3ae..0000000 --- a/crates/axumkit-server/src/api/v0/routes/posts/update_post.rs +++ /dev/null @@ -1,40 +0,0 @@ -use crate::extractors::session::RequiredSession; -use crate::service::posts::service_update_post; -use crate::state::AppState; -use axum::extract::State; -use axumkit_dto::posts::{GetPostPath, PostResponse, UpdatePostRequest}; -use axumkit_dto::validator::json_validator::ValidatedJson; -use axumkit_dto::validator::path_validator::ValidatedPath; -use axumkit_errors::errors::Errors; - -#[utoipa::path( - patch, - path = "/v0/posts/{id}", - params(GetPostPath), - request_body = UpdatePostRequest, - responses( - (status = 200, description = "Post updated successfully", body = PostResponse), - (status = 400, description = "Bad request - Invalid JSON or validation error"), - (status = 401, description = "Unauthorized - User not authenticated or not the author"), - (status = 404, description = "Post not found"), - (status = 500, description = "Internal Server Error"), - ), - tag = "Posts" -)] -pub async fn update_post( - State(state): State, - RequiredSession(session): RequiredSession, - ValidatedPath(path): ValidatedPath, - ValidatedJson(payload): ValidatedJson, -) -> Result { - service_update_post( - &state.write_db, - &state.seaweedfs_client, - &state.worker, - path.id, - session.user_id, - payload.title, - payload.content, - ) - .await -} diff --git a/crates/axumkit-server/src/api/v0/routes/routes.rs b/crates/axumkit-server/src/api/v0/routes/routes.rs index 1f131b4..35ab109 100644 --- a/crates/axumkit-server/src/api/v0/routes/routes.rs +++ b/crates/axumkit-server/src/api/v0/routes/routes.rs @@ -1,19 +1,19 @@ use super::action_logs::routes::action_logs_routes as ActionLogsRoutes; use super::auth::routes::auth_routes as AuthRoutes; -use super::posts::routes::posts_routes as PostsRoutes; +use super::moderation::routes::moderation_routes as ModerationRoutes; use super::search::routes::search_routes as SearchRoutes; use super::stream::routes::stream_routes as StreamRoutes; use super::user::routes::user_routes as UserRoutes; use crate::state::AppState; use axum::Router; -/// v0 API 라우터 +/// v0 API router pub fn v0_routes(state: AppState) -> Router { Router::new() .merge(UserRoutes()) .merge(AuthRoutes(state.clone())) - .merge(PostsRoutes()) .merge(SearchRoutes()) .merge(ActionLogsRoutes()) + .merge(ModerationRoutes()) .merge(StreamRoutes()) } diff --git a/crates/axumkit-server/src/api/v0/routes/search/mod.rs b/crates/axumkit-server/src/api/v0/routes/search/mod.rs index 505d934..a8e8179 100644 --- a/crates/axumkit-server/src/api/v0/routes/search/mod.rs +++ b/crates/axumkit-server/src/api/v0/routes/search/mod.rs @@ -1,4 +1,3 @@ pub mod openapi; pub mod routes; -pub mod search_posts; pub mod search_users; diff --git a/crates/axumkit-server/src/api/v0/routes/search/openapi.rs b/crates/axumkit-server/src/api/v0/routes/search/openapi.rs index c95e6f9..83a3947 100644 --- a/crates/axumkit-server/src/api/v0/routes/search/openapi.rs +++ b/crates/axumkit-server/src/api/v0/routes/search/openapi.rs @@ -1,20 +1,13 @@ -use axumkit_dto::search::{ - PostSearchHit, SearchPostsRequest, SearchPostsResponse, SearchUsersRequest, - SearchUsersResponse, SortOrder, UserSearchItem, -}; +use axumkit_dto::search::{SearchUsersRequest, SearchUsersResponse, SortOrder, UserSearchItem}; use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - super::search_posts::search_posts, super::search_users::search_users, ), components( schemas( - SearchPostsRequest, - SearchPostsResponse, - PostSearchHit, SortOrder, SearchUsersRequest, SearchUsersResponse, diff --git a/crates/axumkit-server/src/api/v0/routes/search/routes.rs b/crates/axumkit-server/src/api/v0/routes/search/routes.rs index 008fef4..ff1b59f 100644 --- a/crates/axumkit-server/src/api/v0/routes/search/routes.rs +++ b/crates/axumkit-server/src/api/v0/routes/search/routes.rs @@ -1,12 +1,9 @@ use crate::state::AppState; use axum::{Router, routing::get}; -use super::search_posts::search_posts; use super::search_users::search_users; pub fn search_routes() -> Router { // Public routes (no authentication required) - Router::new() - .route("/search/posts", get(search_posts)) - .route("/search/users", get(search_users)) + Router::new().route("/search/users", get(search_users)) } diff --git a/crates/axumkit-server/src/api/v0/routes/search/search_posts.rs b/crates/axumkit-server/src/api/v0/routes/search/search_posts.rs deleted file mode 100644 index 18e1329..0000000 --- a/crates/axumkit-server/src/api/v0/routes/search/search_posts.rs +++ /dev/null @@ -1,26 +0,0 @@ -use crate::service::search::service_search_posts; -use crate::state::AppState; -use axum::extract::State; -use axumkit_dto::search::{SearchPostsRequest, SearchPostsResponse}; -use axumkit_dto::validator::query_validator::ValidatedQuery; -use axumkit_errors::errors::Errors; - -#[utoipa::path( - get, - path = "/v0/search/posts", - params(SearchPostsRequest), - responses( - (status = 200, description = "Posts found successfully", body = SearchPostsResponse), - (status = 400, description = "Bad request - Invalid query parameters or validation error"), - (status = 500, description = "Internal Server Error - MeiliSearch query failed") - ), - tag = "Search" -)] -pub async fn search_posts( - State(state): State, - ValidatedQuery(payload): ValidatedQuery, -) -> Result { - let result = service_search_posts(&state.meilisearch_client, &payload).await?; - - Ok(result) -} diff --git a/crates/axumkit-server/src/api/v0/routes/user/check_handle_available.rs b/crates/axumkit-server/src/api/v0/routes/user/check_handle_available.rs index 9c60c77..03a9eb0 100644 --- a/crates/axumkit-server/src/api/v0/routes/user/check_handle_available.rs +++ b/crates/axumkit-server/src/api/v0/routes/user/check_handle_available.rs @@ -20,5 +20,5 @@ pub async fn check_handle_available( State(state): State, ValidatedPath(path): ValidatedPath, ) -> Result { - service_check_handle_available(&state.read_db, &path.handle).await + service_check_handle_available(&state.db, &path.handle).await } diff --git a/crates/axumkit-server/src/api/v0/routes/user/create_user.rs b/crates/axumkit-server/src/api/v0/routes/user/create_user.rs deleted file mode 100644 index 15d84fa..0000000 --- a/crates/axumkit-server/src/api/v0/routes/user/create_user.rs +++ /dev/null @@ -1,35 +0,0 @@ -use crate::service::user::create_user::service_create_user; -use crate::state::AppState; -use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; -use axumkit_dto::user::{CreateUserRequest, CreateUserResponse}; -use axumkit_dto::validator::json_validator::ValidatedJson; -use axumkit_errors::errors::Errors; - -#[utoipa::path( - post, - path = "/v0/users", - request_body = CreateUserRequest, - responses( - (status = 201, description = "User created successfully", body = CreateUserResponse), - (status = 400, description = "Bad request - Invalid JSON or validation error"), - (status = 409, description = "Conflict - User with this email or handle already exists"), - (status = 500, description = "Internal Server Error - Database or Redis error"), - (status = 502, description = "Bad Gateway - Worker service request failed or returned invalid response"), - (status = 503, description = "Service Unavailable - Worker service connection failed"), - ), - tag = "User" -)] -pub async fn create_user( - State(state): State, - ValidatedJson(payload): ValidatedJson, -) -> Result { - let response = service_create_user( - &state.write_db, - &state.redis_session, - &state.worker, - payload, - ) - .await?; - - Ok((StatusCode::CREATED, Json(response))) -} diff --git a/crates/axumkit-server/src/api/v0/routes/user/delete_banner_image.rs b/crates/axumkit-server/src/api/v0/routes/user/delete_banner_image.rs index ace4fe4..f6a5239 100644 --- a/crates/axumkit-server/src/api/v0/routes/user/delete_banner_image.rs +++ b/crates/axumkit-server/src/api/v0/routes/user/delete_banner_image.rs @@ -23,6 +23,6 @@ pub async fn delete_banner_image( State(state): State, RequiredSession(session_context): RequiredSession, ) -> Result { - service_delete_banner_image(&state.write_db, &state.r2_client, &session_context).await?; + service_delete_banner_image(&state.db, &state.r2_client, &session_context).await?; Ok(StatusCode::NO_CONTENT) } diff --git a/crates/axumkit-server/src/api/v0/routes/user/delete_profile_image.rs b/crates/axumkit-server/src/api/v0/routes/user/delete_profile_image.rs index 3b4a636..d9fca54 100644 --- a/crates/axumkit-server/src/api/v0/routes/user/delete_profile_image.rs +++ b/crates/axumkit-server/src/api/v0/routes/user/delete_profile_image.rs @@ -23,6 +23,6 @@ pub async fn delete_profile_image( State(state): State, RequiredSession(session_context): RequiredSession, ) -> Result { - service_delete_profile_image(&state.write_db, &state.r2_client, &session_context).await?; + service_delete_profile_image(&state.db, &state.r2_client, &session_context).await?; Ok(StatusCode::NO_CONTENT) } diff --git a/crates/axumkit-server/src/api/v0/routes/user/get_my_profile.rs b/crates/axumkit-server/src/api/v0/routes/user/get_my_profile.rs index ef18d2d..87737fd 100644 --- a/crates/axumkit-server/src/api/v0/routes/user/get_my_profile.rs +++ b/crates/axumkit-server/src/api/v0/routes/user/get_my_profile.rs @@ -22,5 +22,5 @@ pub async fn get_my_profile( State(state): State, RequiredSession(session_context): RequiredSession, ) -> Result { - service_get_my_profile(&state.read_db, &session_context).await + service_get_my_profile(&state.db, &session_context).await } diff --git a/crates/axumkit-server/src/api/v0/routes/user/get_user_profile.rs b/crates/axumkit-server/src/api/v0/routes/user/get_user_profile.rs index 53bbd99..f9f012e 100644 --- a/crates/axumkit-server/src/api/v0/routes/user/get_user_profile.rs +++ b/crates/axumkit-server/src/api/v0/routes/user/get_user_profile.rs @@ -21,6 +21,6 @@ pub async fn get_user_profile( State(state): State, ValidatedQuery(payload): ValidatedQuery, ) -> Result { - let profile = service_get_user_profile_by_handle(&state.read_db, &payload.handle).await?; + let profile = service_get_user_profile_by_handle(&state.db, &payload.handle).await?; Ok(profile) } diff --git a/crates/axumkit-server/src/api/v0/routes/user/get_user_profile_by_id.rs b/crates/axumkit-server/src/api/v0/routes/user/get_user_profile_by_id.rs index 51223fa..bdba746 100644 --- a/crates/axumkit-server/src/api/v0/routes/user/get_user_profile_by_id.rs +++ b/crates/axumkit-server/src/api/v0/routes/user/get_user_profile_by_id.rs @@ -21,6 +21,6 @@ pub async fn get_user_profile_by_id( State(state): State, ValidatedQuery(payload): ValidatedQuery, ) -> Result { - let profile = service_get_user_profile_by_id(&state.read_db, payload.user_id).await?; + let profile = service_get_user_profile_by_id(&state.db, payload.user_id).await?; Ok(profile) } diff --git a/crates/axumkit-server/src/api/v0/routes/user/management/ban_user.rs b/crates/axumkit-server/src/api/v0/routes/user/management/ban_user.rs new file mode 100644 index 0000000..ef67ba4 --- /dev/null +++ b/crates/axumkit-server/src/api/v0/routes/user/management/ban_user.rs @@ -0,0 +1,43 @@ +use crate::extractors::RequiredSession; +use crate::service::user::management::ban_user::service_ban_user; +use crate::state::AppState; +use axum::extract::State; +use axumkit_dto::user::request::BanUserRequest; +use axumkit_dto::user::response::BanUserResponse; +use axumkit_dto::validator::json_validator::ValidatedJson; +use axumkit_errors::errors::{ErrorResponse, Errors}; + +#[utoipa::path( + post, + path = "/v0/users/ban", + summary = "Ban a user", + description = "Bans the requested user account and returns the resulting moderation record.", + request_body = BanUserRequest, + responses( + (status = 200, description = "User banned successfully", body = BanUserResponse), + (status = 400, description = "Bad request - Invalid JSON or validation error", body = ErrorResponse), + (status = 401, description = "Unauthorized - Login required", body = ErrorResponse), + (status = 403, description = "Forbidden - Insufficient permissions", body = ErrorResponse), + (status = 404, description = "Not Found - User not found", body = ErrorResponse), + (status = 409, description = "Conflict - User already banned", body = ErrorResponse), + (status = 500, description = "Internal Server Error - Database or transaction error", body = ErrorResponse) + ), + security( + ("session_id_cookie" = []) + ), + tag = "User Management" +)] +pub async fn ban_user( + State(state): State, + RequiredSession(session): RequiredSession, + ValidatedJson(payload): ValidatedJson, +) -> Result { + service_ban_user( + &state.db, + payload.user_id, + payload.expires_at, + payload.reason, + &session, + ) + .await +} diff --git a/crates/axumkit-server/src/api/v0/routes/user/management/grant_role.rs b/crates/axumkit-server/src/api/v0/routes/user/management/grant_role.rs new file mode 100644 index 0000000..3ae5c18 --- /dev/null +++ b/crates/axumkit-server/src/api/v0/routes/user/management/grant_role.rs @@ -0,0 +1,44 @@ +use crate::extractors::RequiredSession; +use crate::service::user::management::grant_role::service_grant_role; +use crate::state::AppState; +use axum::extract::State; +use axumkit_dto::user::request::GrantRoleRequest; +use axumkit_dto::user::response::GrantRoleResponse; +use axumkit_dto::validator::json_validator::ValidatedJson; +use axumkit_errors::errors::{ErrorResponse, Errors}; + +#[utoipa::path( + post, + path = "/v0/users/roles/grant", + summary = "Grant a user role", + description = "Grants the requested role to the target user account.", + request_body = GrantRoleRequest, + responses( + (status = 200, description = "Role granted successfully", body = GrantRoleResponse), + (status = 400, description = "Bad request - Invalid JSON or validation error", body = ErrorResponse), + (status = 401, description = "Unauthorized - Login required", body = ErrorResponse), + (status = 403, description = "Forbidden - Insufficient permissions", body = ErrorResponse), + (status = 404, description = "Not Found - User not found", body = ErrorResponse), + (status = 409, description = "Conflict - User already has this role", body = ErrorResponse), + (status = 500, description = "Internal Server Error - Database or transaction error", body = ErrorResponse) + ), + security( + ("session_id_cookie" = []) + ), + tag = "User Management" +)] +pub async fn grant_role( + State(state): State, + RequiredSession(session): RequiredSession, + ValidatedJson(payload): ValidatedJson, +) -> Result { + service_grant_role( + &state.db, + payload.user_id, + payload.role, + payload.expires_at, + payload.reason, + &session, + ) + .await +} diff --git a/crates/axumkit-server/src/api/v0/routes/user/management/mod.rs b/crates/axumkit-server/src/api/v0/routes/user/management/mod.rs new file mode 100644 index 0000000..508e16c --- /dev/null +++ b/crates/axumkit-server/src/api/v0/routes/user/management/mod.rs @@ -0,0 +1,4 @@ +pub mod ban_user; +pub mod grant_role; +pub mod revoke_role; +pub mod unban_user; diff --git a/crates/axumkit-server/src/api/v0/routes/user/management/revoke_role.rs b/crates/axumkit-server/src/api/v0/routes/user/management/revoke_role.rs new file mode 100644 index 0000000..c138794 --- /dev/null +++ b/crates/axumkit-server/src/api/v0/routes/user/management/revoke_role.rs @@ -0,0 +1,42 @@ +use crate::extractors::RequiredSession; +use crate::service::user::management::revoke_role::service_revoke_role; +use crate::state::AppState; +use axum::extract::State; +use axumkit_dto::user::request::RevokeRoleRequest; +use axumkit_dto::user::response::RevokeRoleResponse; +use axumkit_dto::validator::json_validator::ValidatedJson; +use axumkit_errors::errors::{ErrorResponse, Errors}; + +#[utoipa::path( + post, + path = "/v0/users/roles/revoke", + summary = "Revoke a user role", + description = "Removes the requested role from the target user account.", + request_body = RevokeRoleRequest, + responses( + (status = 200, description = "Role revoked successfully", body = RevokeRoleResponse), + (status = 400, description = "Bad request - User does not have this role", body = ErrorResponse), + (status = 401, description = "Unauthorized - Login required", body = ErrorResponse), + (status = 403, description = "Forbidden - Insufficient permissions", body = ErrorResponse), + (status = 404, description = "Not Found - User not found", body = ErrorResponse), + (status = 500, description = "Internal Server Error - Database or transaction error", body = ErrorResponse) + ), + security( + ("session_id_cookie" = []) + ), + tag = "User Management" +)] +pub async fn revoke_role( + State(state): State, + RequiredSession(session): RequiredSession, + ValidatedJson(payload): ValidatedJson, +) -> Result { + service_revoke_role( + &state.db, + payload.user_id, + payload.role, + payload.reason, + &session, + ) + .await +} diff --git a/crates/axumkit-server/src/api/v0/routes/user/management/unban_user.rs b/crates/axumkit-server/src/api/v0/routes/user/management/unban_user.rs new file mode 100644 index 0000000..e2db409 --- /dev/null +++ b/crates/axumkit-server/src/api/v0/routes/user/management/unban_user.rs @@ -0,0 +1,35 @@ +use crate::extractors::RequiredSession; +use crate::service::user::management::unban_user::service_unban_user; +use crate::state::AppState; +use axum::extract::State; +use axumkit_dto::user::request::UnbanUserRequest; +use axumkit_dto::user::response::UnbanUserResponse; +use axumkit_dto::validator::json_validator::ValidatedJson; +use axumkit_errors::errors::{ErrorResponse, Errors}; + +#[utoipa::path( + post, + path = "/v0/users/unban", + summary = "Unban a user", + description = "Removes the active ban from the requested user account.", + request_body = UnbanUserRequest, + responses( + (status = 200, description = "User unbanned successfully", body = UnbanUserResponse), + (status = 400, description = "Bad request - User is not banned", body = ErrorResponse), + (status = 401, description = "Unauthorized - Login required", body = ErrorResponse), + (status = 403, description = "Forbidden - Insufficient permissions", body = ErrorResponse), + (status = 404, description = "Not Found - User not found", body = ErrorResponse), + (status = 500, description = "Internal Server Error - Database or transaction error", body = ErrorResponse) + ), + security( + ("session_id_cookie" = []) + ), + tag = "User Management" +)] +pub async fn unban_user( + State(state): State, + RequiredSession(session): RequiredSession, + ValidatedJson(payload): ValidatedJson, +) -> Result { + service_unban_user(&state.db, payload.user_id, payload.reason, &session).await +} diff --git a/crates/axumkit-server/src/api/v0/routes/user/mod.rs b/crates/axumkit-server/src/api/v0/routes/user/mod.rs index 49190ce..edcf014 100644 --- a/crates/axumkit-server/src/api/v0/routes/user/mod.rs +++ b/crates/axumkit-server/src/api/v0/routes/user/mod.rs @@ -1,10 +1,10 @@ pub mod check_handle_available; -pub mod create_user; pub mod delete_banner_image; pub mod delete_profile_image; pub mod get_my_profile; pub mod get_user_profile; pub mod get_user_profile_by_id; +pub mod management; pub mod openapi; pub mod routes; pub mod update_my_profile; diff --git a/crates/axumkit-server/src/api/v0/routes/user/openapi.rs b/crates/axumkit-server/src/api/v0/routes/user/openapi.rs index 1ffebaf..c914b1d 100644 --- a/crates/axumkit-server/src/api/v0/routes/user/openapi.rs +++ b/crates/axumkit-server/src/api/v0/routes/user/openapi.rs @@ -1,14 +1,14 @@ use axumkit_dto::user::{ - CheckHandleAvailablePath, CheckHandleAvailableResponse, GetUserProfileByIdRequest, - GetUserProfileRequest, PublicUserProfile, UpdateMyProfileRequest, UploadUserImageRequest, - UploadUserImageResponse, UserResponse, + BanUserRequest, BanUserResponse, CheckHandleAvailablePath, CheckHandleAvailableResponse, + GetUserProfileByIdRequest, GetUserProfileRequest, GrantRoleRequest, GrantRoleResponse, + PublicUserProfile, RevokeRoleRequest, RevokeRoleResponse, UnbanUserRequest, UnbanUserResponse, + UpdateMyProfileRequest, UploadUserImageRequest, UploadUserImageResponse, UserResponse, }; use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( paths( - super::create_user::create_user, super::get_my_profile::get_my_profile, super::update_my_profile::update_my_profile, super::upload_profile_image::upload_profile_image, @@ -18,6 +18,10 @@ use utoipa::OpenApi; super::get_user_profile::get_user_profile, super::get_user_profile_by_id::get_user_profile_by_id, super::check_handle_available::check_handle_available, + super::management::ban_user::ban_user, + super::management::unban_user::unban_user, + super::management::grant_role::grant_role, + super::management::revoke_role::revoke_role, ), components( schemas( @@ -30,10 +34,19 @@ use utoipa::OpenApi; PublicUserProfile, CheckHandleAvailablePath, CheckHandleAvailableResponse, + BanUserRequest, + BanUserResponse, + UnbanUserRequest, + UnbanUserResponse, + GrantRoleRequest, + GrantRoleResponse, + RevokeRoleRequest, + RevokeRoleResponse, ) ), tags( - (name = "User", description = "User endpoints") + (name = "User", description = "User endpoints"), + (name = "User Management", description = "User management endpoints (admin only)") ) )] pub struct UserApiDoc; diff --git a/crates/axumkit-server/src/api/v0/routes/user/routes.rs b/crates/axumkit-server/src/api/v0/routes/user/routes.rs index 43a7efd..e31d751 100644 --- a/crates/axumkit-server/src/api/v0/routes/user/routes.rs +++ b/crates/axumkit-server/src/api/v0/routes/user/routes.rs @@ -1,10 +1,13 @@ use super::check_handle_available::check_handle_available; -use super::create_user::create_user; use super::delete_banner_image::delete_banner_image; use super::delete_profile_image::delete_profile_image; use super::get_my_profile::get_my_profile; use super::get_user_profile::get_user_profile; use super::get_user_profile_by_id::get_user_profile_by_id; +use super::management::ban_user::ban_user; +use super::management::grant_role::grant_role; +use super::management::revoke_role::revoke_role; +use super::management::unban_user::unban_user; use super::update_my_profile::update_my_profile; use super::upload_banner_image::upload_banner_image; use super::upload_profile_image::upload_profile_image; @@ -34,12 +37,16 @@ pub fn user_routes() -> Router { .layer(DefaultBodyLimit::max(BANNER_IMAGE_MAX_SIZE)); // Protected routes (authentication via extractors) - let protected_routes = - Router::new().route("/user/me", get(get_my_profile).patch(update_my_profile)); + let protected_routes = Router::new() + .route("/user/me", get(get_my_profile).patch(update_my_profile)) + // User Management (admin actions) + .route("/users/ban", post(ban_user)) + .route("/users/unban", post(unban_user)) + .route("/users/roles/grant", post(grant_role)) + .route("/users/roles/revoke", post(revoke_role)); // Public routes (no authentication required) let public_routes = Router::new() - .route("/users", post(create_user)) .route("/users/profile", get(get_user_profile)) .route("/users/profile/id", get(get_user_profile_by_id)) .route( diff --git a/crates/axumkit-server/src/api/v0/routes/user/update_my_profile.rs b/crates/axumkit-server/src/api/v0/routes/user/update_my_profile.rs index efd0d17..88cc20c 100644 --- a/crates/axumkit-server/src/api/v0/routes/user/update_my_profile.rs +++ b/crates/axumkit-server/src/api/v0/routes/user/update_my_profile.rs @@ -27,5 +27,5 @@ pub async fn update_my_profile( RequiredSession(session_context): RequiredSession, ValidatedJson(payload): ValidatedJson, ) -> Result { - service_update_my_profile(&state.write_db, &session_context, payload).await + service_update_my_profile(&state.db, &session_context, payload).await } diff --git a/crates/axumkit-server/src/api/v0/routes/user/upload_banner_image.rs b/crates/axumkit-server/src/api/v0/routes/user/upload_banner_image.rs index 253d4b0..d44c6fb 100644 --- a/crates/axumkit-server/src/api/v0/routes/user/upload_banner_image.rs +++ b/crates/axumkit-server/src/api/v0/routes/user/upload_banner_image.rs @@ -28,5 +28,5 @@ pub async fn upload_banner_image( RequiredSession(session_context): RequiredSession, ValidatedMultipart(payload): ValidatedMultipart, ) -> Result { - service_upload_banner_image(&state.write_db, &state.r2_client, &session_context, payload).await + service_upload_banner_image(&state.db, &state.r2_client, &session_context, payload).await } diff --git a/crates/axumkit-server/src/api/v0/routes/user/upload_profile_image.rs b/crates/axumkit-server/src/api/v0/routes/user/upload_profile_image.rs index c1ab83f..1a94c6f 100644 --- a/crates/axumkit-server/src/api/v0/routes/user/upload_profile_image.rs +++ b/crates/axumkit-server/src/api/v0/routes/user/upload_profile_image.rs @@ -28,5 +28,5 @@ pub async fn upload_profile_image( RequiredSession(session_context): RequiredSession, ValidatedMultipart(payload): ValidatedMultipart, ) -> Result { - service_upload_profile_image(&state.write_db, &state.r2_client, &session_context, payload).await + service_upload_profile_image(&state.db, &state.r2_client, &session_context, payload).await } diff --git a/crates/axumkit-server/src/bridge/turnstile_client.rs b/crates/axumkit-server/src/bridge/turnstile_client.rs index a6d7065..92b84ee 100644 --- a/crates/axumkit-server/src/bridge/turnstile_client.rs +++ b/crates/axumkit-server/src/bridge/turnstile_client.rs @@ -4,29 +4,29 @@ use serde::{Deserialize, Serialize}; const TURNSTILE_VERIFY_URL: &str = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; -/// Cloudflare Turnstile 검증 응답 +/// Cloudflare Turnstile verification response #[derive(Debug, Deserialize)] pub struct TurnstileResponse { - /// 검증 성공 여부 + /// Whether verification succeeded pub success: bool, - /// 에러 코드 목록 (실패 시) + /// Error code list (on failure) #[serde(rename = "error-codes", default)] pub error_codes: Vec, - /// 챌린지 완료 시간 (ISO 8601) + /// Challenge completion time (ISO 8601) #[serde(default)] pub challenge_ts: Option, - /// 챌린지가 표시된 호스트 + /// Host where the challenge was displayed #[serde(default)] pub hostname: Option, - /// 클라이언트에서 전달한 action + /// Action passed from the client #[serde(default)] pub action: Option, - /// 클라이언트에서 전달한 cdata + /// cdata passed from the client #[serde(default)] pub cdata: Option, } -/// Cloudflare Turnstile API 요청 +/// Cloudflare Turnstile API request #[derive(Debug, Serialize)] struct TurnstileRequest<'a> { secret: &'a str, @@ -35,17 +35,17 @@ struct TurnstileRequest<'a> { remoteip: Option<&'a str>, } -/// Cloudflare Turnstile 토큰 검증 +/// Cloudflare Turnstile token verification /// /// # Arguments -/// * `http_client` - HTTP 클라이언트 -/// * `secret_key` - Turnstile 시크릿 키 -/// * `token` - 클라이언트에서 받은 토큰 -/// * `remote_ip` - 클라이언트 IP (선택) +/// * `http_client` - HTTP client +/// * `secret_key` - Turnstile secret key +/// * `token` - Token received from the client +/// * `remote_ip` - Client IP (optional) /// /// # Returns -/// * `Ok(TurnstileResponse)` - 검증 응답 (success 필드 확인 필요) -/// * `Err(Errors::TurnstileServiceError)` - API 호출 실패 +/// * `Ok(TurnstileResponse)` - Verification response (check the success field) +/// * `Err(Errors::TurnstileServiceError)` - API call failed pub async fn verify_turnstile_token( http_client: &HttpClient, secret_key: &str, diff --git a/crates/axumkit-server/src/bridge/worker_client/index.rs b/crates/axumkit-server/src/bridge/worker_client/index.rs index f188775..86e2c21 100644 --- a/crates/axumkit-server/src/bridge/worker_client/index.rs +++ b/crates/axumkit-server/src/bridge/worker_client/index.rs @@ -1,47 +1,14 @@ use super::publish_job; use crate::state::WorkerClient; use axumkit_errors::errors::Errors; -use axumkit_worker::jobs::{ - post_index::{IndexAction, IndexPostJob}, - user_index::{IndexUserJob, UserIndexAction}, -}; -use axumkit_worker::nats::streams::{INDEX_POST_SUBJECT, INDEX_USER_SUBJECT}; +use axumkit_worker::jobs::user_index::{IndexUserJob, UserIndexAction}; +use axumkit_worker::nats::streams::INDEX_USER_SUBJECT; use tracing::info; use uuid::Uuid; -/// Push a post indexing job to the worker queue -pub async fn index_post(worker: &WorkerClient, post_id: Uuid) -> Result<(), Errors> { - info!("Queuing post index job for {}", post_id); - - let job = IndexPostJob { - post_id, - action: IndexAction::Index, - }; - - publish_job(worker, INDEX_POST_SUBJECT, &job).await?; - - info!("Post index job queued for {}", post_id); - Ok(()) -} - -/// Push a post deletion job to the worker queue -pub async fn delete_post_from_index(worker: &WorkerClient, post_id: Uuid) -> Result<(), Errors> { - info!("Queuing post delete job for {}", post_id); - - let job = IndexPostJob { - post_id, - action: IndexAction::Delete, - }; - - publish_job(worker, INDEX_POST_SUBJECT, &job).await?; - - info!("Post delete job queued for {}", post_id); - Ok(()) -} - /// Push a user indexing job to the worker queue pub async fn index_user(worker: &WorkerClient, user_id: Uuid) -> Result<(), Errors> { - info!("Queuing user index job for {}", user_id); + info!(user_id = %user_id, "Queuing user index job"); let job = IndexUserJob { user_id, @@ -50,13 +17,13 @@ pub async fn index_user(worker: &WorkerClient, user_id: Uuid) -> Result<(), Erro publish_job(worker, INDEX_USER_SUBJECT, &job).await?; - info!("User index job queued for {}", user_id); + info!(user_id = %user_id, "User index job queued"); Ok(()) } /// Push a user deletion job to the worker queue pub async fn delete_user_from_index(worker: &WorkerClient, user_id: Uuid) -> Result<(), Errors> { - info!("Queuing user delete job for {}", user_id); + info!(user_id = %user_id, "Queuing user delete job"); let job = IndexUserJob { user_id, @@ -65,6 +32,6 @@ pub async fn delete_user_from_index(worker: &WorkerClient, user_id: Uuid) -> Res publish_job(worker, INDEX_USER_SUBJECT, &job).await?; - info!("User delete job queued for {}", user_id); + info!(user_id = %user_id, "User delete job queued"); Ok(()) } diff --git a/crates/axumkit-server/src/bridge/worker_client/mod.rs b/crates/axumkit-server/src/bridge/worker_client/mod.rs index eb114b4..553494f 100644 --- a/crates/axumkit-server/src/bridge/worker_client/mod.rs +++ b/crates/axumkit-server/src/bridge/worker_client/mod.rs @@ -2,13 +2,11 @@ mod cache; mod email; mod index; mod reindex; -mod storage; // Re-export all functions for backwards compatibility pub use email::*; pub use index::*; pub use reindex::*; -pub use storage::*; use crate::state::WorkerClient; use axumkit_errors::errors::Errors; diff --git a/crates/axumkit-server/src/bridge/worker_client/reindex.rs b/crates/axumkit-server/src/bridge/worker_client/reindex.rs index a217f7c..dd04084 100644 --- a/crates/axumkit-server/src/bridge/worker_client/reindex.rs +++ b/crates/axumkit-server/src/bridge/worker_client/reindex.rs @@ -1,30 +1,11 @@ use super::publish_job; use crate::state::WorkerClient; use axumkit_errors::errors::Errors; -use axumkit_worker::jobs::reindex::{create_reindex_posts_job, create_reindex_users_job}; -use axumkit_worker::nats::streams::{REINDEX_POSTS_SUBJECT, REINDEX_USERS_SUBJECT}; +use axumkit_worker::jobs::reindex::create_reindex_users_job; +use axumkit_worker::nats::streams::REINDEX_USERS_SUBJECT; use tracing::info; use uuid::Uuid; -/// Start a full reindex of all posts -pub async fn start_reindex_posts( - worker: &WorkerClient, - batch_size: Option, -) -> Result { - let reindex_id = Uuid::now_v7(); - info!( - "Starting post reindex job: reindex_id={}, batch_size={:?}", - reindex_id, batch_size - ); - - let job = create_reindex_posts_job(reindex_id, batch_size); - - publish_job(worker, REINDEX_POSTS_SUBJECT, &job).await?; - - info!("Post reindex job started: reindex_id={}", reindex_id); - Ok(reindex_id) -} - /// Start a full reindex of all users pub async fn start_reindex_users( worker: &WorkerClient, @@ -32,14 +13,15 @@ pub async fn start_reindex_users( ) -> Result { let reindex_id = Uuid::now_v7(); info!( - "Starting user reindex job: reindex_id={}, batch_size={:?}", - reindex_id, batch_size + reindex_id = %reindex_id, + batch_size = ?batch_size, + "Starting user reindex job" ); let job = create_reindex_users_job(reindex_id, batch_size); publish_job(worker, REINDEX_USERS_SUBJECT, &job).await?; - info!("User reindex job started: reindex_id={}", reindex_id); + info!(reindex_id = %reindex_id, "User reindex job started"); Ok(reindex_id) } diff --git a/crates/axumkit-server/src/bridge/worker_client/storage.rs b/crates/axumkit-server/src/bridge/worker_client/storage.rs deleted file mode 100644 index 46ddc56..0000000 --- a/crates/axumkit-server/src/bridge/worker_client/storage.rs +++ /dev/null @@ -1,25 +0,0 @@ -use super::publish_job; -use crate::state::WorkerClient; -use axumkit_errors::errors::Errors; -use axumkit_worker::jobs::storage::DeleteContentJob; -use axumkit_worker::nats::streams::DELETE_CONTENT_SUBJECT; -use tracing::info; - -/// Push a content deletion job to the worker queue -pub async fn delete_content( - worker: &WorkerClient, - storage_keys: Vec, -) -> Result<(), Errors> { - if storage_keys.is_empty() { - return Ok(()); - } - - info!("Queuing content delete job for {} keys", storage_keys.len()); - - let job = DeleteContentJob { storage_keys }; - - publish_job(worker, DELETE_CONTENT_SUBJECT, &job).await?; - - info!("Content delete job queued"); - Ok(()) -} diff --git a/crates/axumkit-server/src/connection/database_conn.rs b/crates/axumkit-server/src/connection/database_conn.rs index afb56c1..9923880 100644 --- a/crates/axumkit-server/src/connection/database_conn.rs +++ b/crates/axumkit-server/src/connection/database_conn.rs @@ -58,38 +58,20 @@ async fn establish_connection_with_config( } } -/// Establishes and returns a Write (Primary) database connection. -pub async fn establish_write_connection() -> DatabaseConnection { +/// Establishes and returns a database connection (via PgDog connection pooler). +pub async fn establish_connection() -> DatabaseConnection { let db_config = ServerConfig::get(); establish_connection_with_config( DbConnConfig { - user: &db_config.db_write_user, - password: &db_config.db_write_password, - host: &db_config.db_write_host, - port: &db_config.db_write_port, - name: &db_config.db_write_name, - max_connections: db_config.db_write_max_connection, - min_connections: db_config.db_write_min_connection, + user: &db_config.db_user, + password: &db_config.db_password, + host: &db_config.db_host, + port: &db_config.db_port, + name: &db_config.db_name, + max_connections: db_config.db_max_connection, + min_connections: db_config.db_min_connection, }, - "Write", - ) - .await -} - -/// Establishes and returns a Read (Replica) database connection. -pub async fn establish_read_connection() -> DatabaseConnection { - let db_config = ServerConfig::get(); - establish_connection_with_config( - DbConnConfig { - user: &db_config.db_read_user, - password: &db_config.db_read_password, - host: &db_config.db_read_host, - port: &db_config.db_read_port, - name: &db_config.db_read_name, - max_connections: db_config.db_read_max_connection, - min_connections: db_config.db_read_min_connection, - }, - "Read", + "PostgreSQL", ) .await } diff --git a/crates/axumkit-server/src/connection/http_conn.rs b/crates/axumkit-server/src/connection/http_conn.rs index f63feac..81c6f12 100644 --- a/crates/axumkit-server/src/connection/http_conn.rs +++ b/crates/axumkit-server/src/connection/http_conn.rs @@ -6,11 +6,11 @@ pub async fn create_http_client() -> Result { info!("Creating HTTP client"); let client = Client::builder() - .timeout(Duration::from_secs(30)) // 전체 요청 타임아웃 - .connect_timeout(Duration::from_secs(10)) // 연결 타임아웃 - .pool_idle_timeout(Duration::from_secs(90)) // 유휴 연결 타임아웃 - .pool_max_idle_per_host(10) // 호스트당 최대 유휴 연결 수 - .user_agent("axumkit-server/1.0") // User-Agent 설정 + .timeout(Duration::from_secs(30)) // Overall request timeout + .connect_timeout(Duration::from_secs(10)) // Connection timeout + .pool_idle_timeout(Duration::from_secs(90)) // Idle connection timeout + .pool_max_idle_per_host(10) // Max idle connections per host + .user_agent("axumkit-server/1.0") // User-Agent configuration .tcp_keepalive(Duration::from_secs(60)) // TCP keep-alive .build() .map_err(|e| { diff --git a/crates/axumkit-server/src/connection/mod.rs b/crates/axumkit-server/src/connection/mod.rs index b4a4965..860cf2f 100644 --- a/crates/axumkit-server/src/connection/mod.rs +++ b/crates/axumkit-server/src/connection/mod.rs @@ -3,11 +3,9 @@ pub mod http_conn; pub mod meilisearch_conn; pub mod r2_conn; pub mod redis_conn; -pub mod seaweedfs_conn; pub use database_conn::*; pub use http_conn::*; pub use meilisearch_conn::*; pub use r2_conn::*; pub use redis_conn::*; -pub use seaweedfs_conn::*; diff --git a/crates/axumkit-server/src/connection/r2_conn.rs b/crates/axumkit-server/src/connection/r2_conn.rs index 82a66be..f1c59e4 100644 --- a/crates/axumkit-server/src/connection/r2_conn.rs +++ b/crates/axumkit-server/src/connection/r2_conn.rs @@ -84,10 +84,10 @@ impl R2Client { { Ok(_) => Ok(true), Err(err) => { - // SdkError를 사용하여 더 정확한 에러 처리 + // Use SdkError for more precise error handling match &err { SdkError::ServiceError(service_err) => { - // 404 Not Found 에러인지 확인 + // Check if it's a 404 Not Found error if service_err.err().is_not_found() { Ok(false) } else { @@ -100,7 +100,7 @@ impl R2Client { } } - // 추가: 스트림으로 업로드 (큰 파일용) + // Additional: stream upload (for large files) pub async fn upload_file( &self, key: &str, @@ -144,8 +144,8 @@ pub async fn establish_r2_connection() -> Result, - bucket: String, -} - -impl SeaweedFsClient { - pub fn new(client: Client, bucket: String) -> Self { - Self { - client: Arc::new(client), - bucket, - } - } - - /// 콘텐츠 업로드 (zstd 압축 적용) - pub async fn upload_content( - &self, - key: &str, - content: &str, - ) -> Result<(), Box> { - let compressed = zstd::encode_all(content.as_bytes(), 3)?; - - self.client - .put_object() - .bucket(&self.bucket) - .key(key) - .body(compressed.into()) - .content_type("application/zstd") - .send() - .await?; - - Ok(()) - } - - /// 콘텐츠 다운로드 (zstd 압축 해제) - pub async fn download_content( - &self, - key: &str, - ) -> Result> { - let resp = self - .client - .get_object() - .bucket(&self.bucket) - .key(key) - .send() - .await?; - - let data = resp.body.collect().await?; - let bytes = data.into_bytes(); - - let decompressed = zstd::decode_all(bytes.as_ref())?; - let content = String::from_utf8(decompressed)?; - - Ok(content) - } - - /// raw 바이트 업로드 (압축 없음) - pub async fn upload(&self, key: &str, body: Vec) -> Result<(), S3Error> { - self.client - .put_object() - .bucket(&self.bucket) - .key(key) - .body(body.into()) - .send() - .await?; - Ok(()) - } - - /// raw 바이트 다운로드 - pub async fn download( - &self, - key: &str, - ) -> Result, Box> { - let resp = self - .client - .get_object() - .bucket(&self.bucket) - .key(key) - .send() - .await?; - - let data = resp.body.collect().await?; - Ok(data.into_bytes().to_vec()) - } - - /// 오브젝트 삭제 - pub async fn delete(&self, key: &str) -> Result<(), S3Error> { - self.client - .delete_object() - .bucket(&self.bucket) - .key(key) - .send() - .await?; - Ok(()) - } - - /// 오브젝트 존재 여부 확인 - pub async fn exists( - &self, - key: &str, - ) -> Result> { - match self - .client - .head_object() - .bucket(&self.bucket) - .key(key) - .send() - .await - { - Ok(_) => Ok(true), - Err(err) => match &err { - SdkError::ServiceError(service_err) => { - if service_err.err().is_not_found() { - Ok(false) - } else { - Err(Box::new(err)) - } - } - _ => Err(Box::new(err)), - }, - } - } -} - -/// 버킷 이름 (하드코딩 - 변경할 이유 없음) -const BUCKET_NAME: &str = "axumkit-content"; - -pub async fn establish_seaweedfs_connection() --> Result> { - let config = ServerConfig::get(); - - info!("Connecting to SeaweedFS at: {}", config.seaweedfs_endpoint); - - // SeaweedFS S3 API - 내부 네트워크, 인증 없음 - let aws_config = aws_config::defaults(BehaviorVersion::latest()) - .region(Region::new("us-east-1")) - .endpoint_url(&config.seaweedfs_endpoint) - .credentials_provider(aws_sdk_s3::config::Credentials::new( - "", - "", - None, - None, - "anonymous", - )) - .load() - .await; - - let s3_config = aws_sdk_s3::config::Builder::from(&aws_config) - .force_path_style(true) - .build(); - - let client = Client::from_conf(s3_config); - let seaweedfs_client = SeaweedFsClient::new(client, BUCKET_NAME.to_string()); - - // 버킷 생성 (없으면) - if seaweedfs_client - .client - .head_bucket() - .bucket(BUCKET_NAME) - .send() - .await - .is_err() - { - info!("Creating SeaweedFS bucket: {}", BUCKET_NAME); - seaweedfs_client - .client - .create_bucket() - .bucket(BUCKET_NAME) - .send() - .await?; - } - - info!("Successfully connected to SeaweedFS"); - Ok(seaweedfs_client) -} diff --git a/crates/axumkit-server/src/extractors/turnstile.rs b/crates/axumkit-server/src/extractors/turnstile.rs index d832555..0fec22e 100644 --- a/crates/axumkit-server/src/extractors/turnstile.rs +++ b/crates/axumkit-server/src/extractors/turnstile.rs @@ -6,26 +6,26 @@ use crate::state::AppState; use axumkit_config::ServerConfig; use axumkit_errors::errors::Errors; -/// Turnstile 헤더 이름 +/// Turnstile header name pub const TURNSTILE_TOKEN_HEADER: &str = "X-Turnstile-Token"; -/// Cloudflare Turnstile 검증 완료를 나타내는 extractor +/// Extractor indicating Cloudflare Turnstile verification is complete /// -/// 핸들러에 이 extractor를 추가하면 Turnstile 토큰 검증이 자동으로 수행됩니다. -/// 검증 실패 시 요청이 거부되고 핸들러 본문은 실행되지 않습니다. +/// Adding this extractor to a handler automatically performs Turnstile token verification. +/// On verification failure, the request is rejected and the handler body is not executed. /// -/// # 사용 예시 +/// # Usage Example /// ```rust,ignore /// pub async fn create_document( /// State(state): State, -/// _turnstile: TurnstileVerified, // 이 줄 추가하면 검증됨 +/// _turnstile: TurnstileVerified, // Adding this line enables verification /// Json(req): Json, /// ) -> Result { -/// // Turnstile 검증 통과한 요청만 여기 도달 +/// // Only requests that passed Turnstile verification reach here /// } /// ``` /// -/// # 클라이언트 사용법 +/// # Client Usage /// ```typescript /// const response = await fetch('/api/v0/document', { /// method: 'POST', @@ -49,20 +49,20 @@ where async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { let app_state = AppState::from_ref(state); - // 1. 헤더에서 토큰 추출 + // 1. Extract token from header let token = parts .headers .get(TURNSTILE_TOKEN_HEADER) .and_then(|v| v.to_str().ok()) .ok_or(Errors::TurnstileTokenMissing)?; - // 2. 클라이언트 IP 추출 (Cloudflare 환경에서는 CF-Connecting-IP 사용) + // 2. Extract client IP (uses CF-Connecting-IP in Cloudflare environments) let remote_ip = parts .headers .get("CF-Connecting-IP") .and_then(|v| v.to_str().ok()); - // 3. Cloudflare API로 검증 + // 3. Verify via Cloudflare API let config = ServerConfig::get(); let response = verify_turnstile_token( &app_state.http_client, @@ -72,7 +72,7 @@ where ) .await?; - // 4. 검증 결과 확인 + // 4. Check verification result if !response.success { return Err(Errors::TurnstileVerificationFailed); } diff --git a/crates/axumkit-server/src/lib.rs b/crates/axumkit-server/src/lib.rs index 905c48b..b61918b 100644 --- a/crates/axumkit-server/src/lib.rs +++ b/crates/axumkit-server/src/lib.rs @@ -4,6 +4,7 @@ pub mod connection; pub mod eventstream; pub mod extractors; pub mod middleware; +pub mod permission; pub mod repository; pub mod service; pub mod state; diff --git a/crates/axumkit-server/src/main.rs b/crates/axumkit-server/src/main.rs index 4b2334f..2c8222d 100644 --- a/crates/axumkit-server/src/main.rs +++ b/crates/axumkit-server/src/main.rs @@ -1,12 +1,11 @@ use axum::error_handling::HandleErrorLayer; -use axum::handler::Handler; use axum::{Router, extract::DefaultBodyLimit, middleware}; use axumkit_config::ServerConfig; use axumkit_dto::action_logs::ActionLogResponse; use axumkit_server::api::routes::api_routes; use axumkit_server::connection::{ - MeilisearchClient, create_http_client, establish_r2_connection, establish_read_connection, - establish_redis_connection, establish_seaweedfs_connection, establish_write_connection, + MeilisearchClient, create_http_client, establish_connection, establish_r2_connection, + establish_redis_connection, }; use axumkit_server::eventstream::start_eventstream_subscriber; use axumkit_server::middleware::anonymous_user::anonymous_user_middleware; @@ -30,16 +29,11 @@ use tower_http::trace::TraceLayer; use tracing::{Level, error}; pub async fn run_server() -> anyhow::Result<()> { - let write_db = establish_write_connection().await; - let read_db = establish_read_connection().await; + let db = establish_connection().await; let r2_client = establish_r2_connection().await.map_err(|e| { error!("Failed to establish cloudflare_r2 connection: {}", e); anyhow::anyhow!("R2 connection failed: {}", e) })?; - let seaweedfs_client = establish_seaweedfs_connection().await.map_err(|e| { - error!("Failed to establish SeaweedFS connection: {}", e); - anyhow::anyhow!("SeaweedFS connection failed: {}", e) - })?; let redis_session = establish_redis_connection( &ServerConfig::get().redis_session_host, &ServerConfig::get().redis_session_port, @@ -99,10 +93,8 @@ pub async fn run_server() -> anyhow::Result<()> { ); let state = AppState { - write_db, - read_db, + db, r2_client, - seaweedfs_client, redis_session, redis_cache, worker, @@ -159,7 +151,7 @@ pub async fn run_server() -> anyhow::Result<()> { #[tokio::main] async fn main() { dotenvy::dotenv().ok(); - // tracing 초기화 + // Initialize tracing init_tracing(); if let Err(err) = run_server().await { diff --git a/crates/axumkit-server/src/middleware/anonymous_user.rs b/crates/axumkit-server/src/middleware/anonymous_user.rs index 2c4f667..00ba8fb 100644 --- a/crates/axumkit-server/src/middleware/anonymous_user.rs +++ b/crates/axumkit-server/src/middleware/anonymous_user.rs @@ -20,20 +20,20 @@ pub async fn anonymous_user_middleware( mut req: Request, next: Next, ) -> Response { - // 쿠키에서 anonymous_user_id 확인 + // Check for anonymous_user_id in cookies let (final_anonymous_id, has_anonymous_id) = match cookies.get(ANONYMOUS_USER_COOKIE_NAME) { Some(cookie) => (cookie.value().to_string(), true), None => (Uuid::now_v7().to_string(), false), }; - // Extension에 익명 사용자 컨텍스트 추가 + // Add anonymous user context to extensions req.extensions_mut().insert(AnonymousUserContext { anonymous_user_id: final_anonymous_id.clone(), }); let response = next.run(req).await; - // 쿠키가 없었다면 새로 생성해서 설정 + // If cookie was missing, create and set a new one if !has_anonymous_id { let is_dev = ServerConfig::get().is_dev; @@ -49,7 +49,7 @@ pub async fn anonymous_user_middleware( .secure(true) .same_site(same_site_attribute) .path("/") - .max_age(Duration::days(365)); // 1년 + .max_age(Duration::days(365)); // 1 year if !is_dev { if let Some(ref domain) = config.cookie_domain { diff --git a/crates/axumkit-server/src/permission/mod.rs b/crates/axumkit-server/src/permission/mod.rs new file mode 100644 index 0000000..66fa78e --- /dev/null +++ b/crates/axumkit-server/src/permission/mod.rs @@ -0,0 +1,6 @@ +mod permission_service; +pub mod rule; +#[cfg(test)] +mod tests; + +pub use permission_service::{PermissionService, UserContext}; diff --git a/crates/axumkit-server/src/permission/permission_service.rs b/crates/axumkit-server/src/permission/permission_service.rs new file mode 100644 index 0000000..09ebdd7 --- /dev/null +++ b/crates/axumkit-server/src/permission/permission_service.rs @@ -0,0 +1,126 @@ +use crate::repository::user::repository_find_user_by_id; +use crate::repository::user::user_bans::repository_find_user_ban; +use crate::repository::user::user_roles::repository_find_user_roles; +use crate::service::auth::session_types::SessionContext; +use axumkit_entity::common::Role; +use axumkit_errors::errors::Errors; +use sea_orm::ConnectionTrait; +use uuid::Uuid; + +#[derive(Debug, Clone)] +pub struct UserContext { + pub roles: Vec, + pub is_banned: bool, + pub is_authenticated: bool, +} + +impl UserContext { + pub fn has_role(&self, role: Role) -> bool { + self.roles.contains(&role) + } + + pub fn require_role(&self, role: Role) -> Result<(), Errors> { + self.require_not_banned()?; + if !self.is_admin() && !self.has_role(role) { + return Err(Errors::UserPermissionInsufficient); + } + Ok(()) + } + + pub fn require_not_banned(&self) -> Result<(), Errors> { + if self.is_banned { + return Err(Errors::UserBanned); + } + Ok(()) + } + + pub fn is_admin(&self) -> bool { + self.roles.contains(&Role::Admin) + } +} + +pub struct PermissionService; + +impl PermissionService { + pub async fn get_context( + conn: &C, + session: Option<&SessionContext>, + ) -> Result + where + C: ConnectionTrait, + { + let is_authenticated = session.is_some(); + + let (roles, is_banned) = match session { + Some(session) => { + let roles = Self::fetch_roles(conn, session.user_id).await?; + let is_banned = Self::fetch_ban(conn, session.user_id).await?; + (roles, is_banned) + } + None => (vec![], false), + }; + + Ok(UserContext { + roles, + is_banned, + is_authenticated, + }) + } + + pub async fn require_role( + conn: &C, + session: Option<&SessionContext>, + role: Role, + ) -> Result + where + C: ConnectionTrait, + { + let ctx = Self::get_context(conn, session).await?; + ctx.require_role(role)?; + Ok(ctx) + } + + pub async fn require_admin_for_target( + conn: &C, + session: Option<&SessionContext>, + target_user_id: Uuid, + ) -> Result + where + C: ConnectionTrait, + { + let ctx = Self::get_context(conn, session).await?; + ctx.require_role(Role::Admin)?; + + if let Some(session) = session + && session.user_id == target_user_id + { + return Err(Errors::CannotManageSelf); + } + + repository_find_user_by_id(conn, target_user_id) + .await? + .ok_or(Errors::UserNotFound)?; + + let target_roles = Self::fetch_roles(conn, target_user_id).await?; + if target_roles.contains(&Role::Admin) { + return Err(Errors::CannotManageHigherOrEqualRole); + } + + Ok(ctx) + } + + async fn fetch_roles(conn: &C, user_id: Uuid) -> Result, Errors> + where + C: ConnectionTrait, + { + repository_find_user_roles(conn, user_id).await + } + + async fn fetch_ban(conn: &C, user_id: Uuid) -> Result + where + C: ConnectionTrait, + { + let ban = repository_find_user_ban(conn, user_id).await?; + Ok(ban.is_some()) + } +} diff --git a/crates/axumkit-server/src/permission/rule.rs b/crates/axumkit-server/src/permission/rule.rs new file mode 100644 index 0000000..c0ff710 --- /dev/null +++ b/crates/axumkit-server/src/permission/rule.rs @@ -0,0 +1,10 @@ +use crate::permission::UserContext; +use axumkit_errors::errors::Errors; + +pub trait Rule { + fn check(&self, ctx: &UserContext) -> Result<(), Errors>; + + fn is_allowed(&self, ctx: &UserContext) -> bool { + self.check(ctx).is_ok() + } +} diff --git a/crates/axumkit-server/src/permission/tests.rs b/crates/axumkit-server/src/permission/tests.rs new file mode 100644 index 0000000..23a5039 --- /dev/null +++ b/crates/axumkit-server/src/permission/tests.rs @@ -0,0 +1,85 @@ +use super::UserContext; +use axumkit_entity::common::Role; +use axumkit_errors::errors::Errors; + +fn make_context(roles: Vec, is_banned: bool, is_authenticated: bool) -> UserContext { + UserContext { + roles, + is_banned, + is_authenticated, + } +} + +#[test] +fn test_user_context_has_role() { + let ctx = make_context(vec![Role::Mod], false, true); + assert!(ctx.has_role(Role::Mod)); + assert!(!ctx.has_role(Role::Admin)); +} + +#[test] +fn test_admin_has_all_capabilities() { + let ctx = make_context(vec![Role::Admin], false, true); + assert!(ctx.is_admin()); + assert!(ctx.require_role(Role::Mod).is_ok()); + assert!(ctx.require_role(Role::Admin).is_ok()); +} + +#[test] +fn test_admin_capabilities_do_not_change_direct_role_membership() { + let ctx = make_context(vec![Role::Admin], false, true); + // Admin can pass any require_role check + assert!(ctx.require_role(Role::Mod).is_ok()); + // But has_role still reports actual membership + assert!(!ctx.has_role(Role::Mod)); +} + +#[test] +fn test_anonymous_has_no_role() { + let ctx = make_context(vec![], false, false); + assert!(!ctx.is_admin()); + assert!(!ctx.has_role(Role::Mod)); + assert!(matches!( + ctx.require_role(Role::Mod), + Err(Errors::UserPermissionInsufficient) + )); +} + +#[test] +fn test_banned_user_denied() { + let ctx = make_context(vec![Role::Mod], true, true); + assert!(matches!( + ctx.require_role(Role::Mod), + Err(Errors::UserBanned) + )); +} + +#[test] +fn test_banned_admin_denied() { + let ctx = make_context(vec![Role::Admin], true, true); + assert!(matches!( + ctx.require_role(Role::Admin), + Err(Errors::UserBanned) + )); +} + +#[test] +fn test_mod_cannot_require_admin() { + let ctx = make_context(vec![Role::Mod], false, true); + assert!(matches!( + ctx.require_role(Role::Admin), + Err(Errors::UserPermissionInsufficient) + )); +} + +#[test] +fn test_require_not_banned_passes_for_unbanned() { + let ctx = make_context(vec![], false, true); + assert!(ctx.require_not_banned().is_ok()); +} + +#[test] +fn test_require_not_banned_fails_for_banned() { + let ctx = make_context(vec![], true, true); + assert!(matches!(ctx.require_not_banned(), Err(Errors::UserBanned))); +} diff --git a/crates/axumkit-server/src/repository/action_logs/create.rs b/crates/axumkit-server/src/repository/action_logs/create.rs index e0aa7d2..943b2b1 100644 --- a/crates/axumkit-server/src/repository/action_logs/create.rs +++ b/crates/axumkit-server/src/repository/action_logs/create.rs @@ -2,7 +2,6 @@ use axumkit_constants::ActionLogAction; use axumkit_entity::action_logs::{ActiveModel as ActionLogActiveModel, Model as ActionLogModel}; use axumkit_entity::common::ActionResourceType; use axumkit_errors::errors::Errors; -use sea_orm::prelude::IpNetwork; use sea_orm::{ActiveModelTrait, ConnectionTrait, Set}; use serde_json::Value as JsonValue; use uuid::Uuid; @@ -11,7 +10,6 @@ pub async fn repository_create_action_log( conn: &C, action: ActionLogAction, actor_id: Option, - actor_ip: Option, resource_type: ActionResourceType, resource_id: Option, summary: String, @@ -24,7 +22,6 @@ where id: Default::default(), action: Set(action.as_str().to_string()), actor_id: Set(actor_id), - actor_ip: Set(actor_ip), resource_type: Set(resource_type), resource_id: Set(resource_id), summary: Set(summary), diff --git a/crates/axumkit-server/src/repository/action_logs/exists/newer.rs b/crates/axumkit-server/src/repository/action_logs/exists/newer.rs index f45a35e..7e4ffba 100644 --- a/crates/axumkit-server/src/repository/action_logs/exists/newer.rs +++ b/crates/axumkit-server/src/repository/action_logs/exists/newer.rs @@ -1,4 +1,4 @@ -use super::super::find::ActionLogFilter; +use super::super::filter::{ActionLogFilter, apply_action_log_filter}; use axumkit_entity::action_logs::{Column as ActionLogColumn, Entity as ActionLogEntity}; use axumkit_errors::errors::Errors; use sea_orm::{ @@ -14,30 +14,10 @@ pub async fn repository_exists_newer_action_log( where C: ConnectionTrait, { - let mut query = ActionLogEntity::find().filter(ActionLogColumn::Id.gt(cursor_id)); - - if let Some(actor_id) = filter.actor_id { - query = query.filter(ActionLogColumn::ActorId.eq(actor_id)); - } - - if let Some(actor_ip) = filter.actor_ip { - query = query.filter(ActionLogColumn::ActorIp.eq(actor_ip)); - } - - if let Some(resource_id) = filter.resource_id { - query = query.filter(ActionLogColumn::ResourceId.eq(resource_id)); - } - - if let Some(ref resource_type) = filter.resource_type { - query = query.filter(ActionLogColumn::ResourceType.eq(resource_type.clone())); - } - - if let Some(actions) = &filter.actions { - if !actions.is_empty() { - let action_strs: Vec<&str> = actions.iter().map(|a| a.as_str()).collect(); - query = query.filter(ActionLogColumn::Action.is_in(action_strs)); - } - } + let query = apply_action_log_filter( + ActionLogEntity::find().filter(ActionLogColumn::Id.gt(cursor_id)), + filter, + ); let count = query.limit(1).count(conn).await?; Ok(count > 0) diff --git a/crates/axumkit-server/src/repository/action_logs/exists/older.rs b/crates/axumkit-server/src/repository/action_logs/exists/older.rs index 9914307..6e3bd46 100644 --- a/crates/axumkit-server/src/repository/action_logs/exists/older.rs +++ b/crates/axumkit-server/src/repository/action_logs/exists/older.rs @@ -1,4 +1,4 @@ -use super::super::find::ActionLogFilter; +use super::super::filter::{ActionLogFilter, apply_action_log_filter}; use axumkit_entity::action_logs::{Column as ActionLogColumn, Entity as ActionLogEntity}; use axumkit_errors::errors::Errors; use sea_orm::{ @@ -14,30 +14,10 @@ pub async fn repository_exists_older_action_log( where C: ConnectionTrait, { - let mut query = ActionLogEntity::find().filter(ActionLogColumn::Id.lt(cursor_id)); - - if let Some(actor_id) = filter.actor_id { - query = query.filter(ActionLogColumn::ActorId.eq(actor_id)); - } - - if let Some(actor_ip) = filter.actor_ip { - query = query.filter(ActionLogColumn::ActorIp.eq(actor_ip)); - } - - if let Some(resource_id) = filter.resource_id { - query = query.filter(ActionLogColumn::ResourceId.eq(resource_id)); - } - - if let Some(ref resource_type) = filter.resource_type { - query = query.filter(ActionLogColumn::ResourceType.eq(resource_type.clone())); - } - - if let Some(actions) = &filter.actions { - if !actions.is_empty() { - let action_strs: Vec<&str> = actions.iter().map(|a| a.as_str()).collect(); - query = query.filter(ActionLogColumn::Action.is_in(action_strs)); - } - } + let query = apply_action_log_filter( + ActionLogEntity::find().filter(ActionLogColumn::Id.lt(cursor_id)), + filter, + ); let count = query.limit(1).count(conn).await?; Ok(count > 0) diff --git a/crates/axumkit-server/src/repository/action_logs/filter.rs b/crates/axumkit-server/src/repository/action_logs/filter.rs new file mode 100644 index 0000000..7191485 --- /dev/null +++ b/crates/axumkit-server/src/repository/action_logs/filter.rs @@ -0,0 +1,39 @@ +use axumkit_constants::ActionLogAction; +use axumkit_entity::action_logs::{Column as ActionLogColumn, Entity as ActionLogEntity}; +use axumkit_entity::common::ActionResourceType; +use sea_orm::{ColumnTrait, QueryFilter, Select}; +use uuid::Uuid; + +#[derive(Debug, Default, Clone)] +pub struct ActionLogFilter { + pub actor_id: Option, + pub resource_id: Option, + pub resource_type: Option, + pub actions: Option>, +} + +pub(crate) fn apply_action_log_filter( + mut query: Select, + filter: &ActionLogFilter, +) -> Select { + if let Some(actor_id) = filter.actor_id { + query = query.filter(ActionLogColumn::ActorId.eq(actor_id)); + } + + if let Some(resource_id) = filter.resource_id { + query = query.filter(ActionLogColumn::ResourceId.eq(resource_id)); + } + + if let Some(resource_type) = filter.resource_type { + query = query.filter(ActionLogColumn::ResourceType.eq(resource_type)); + } + + if let Some(actions) = &filter.actions + && !actions.is_empty() + { + let action_strs: Vec<&str> = actions.iter().map(|a| a.as_str()).collect(); + query = query.filter(ActionLogColumn::Action.is_in(action_strs)); + } + + query +} diff --git a/crates/axumkit-server/src/repository/action_logs/find.rs b/crates/axumkit-server/src/repository/action_logs/find.rs index e5aa6fa..d9a0a66 100644 --- a/crates/axumkit-server/src/repository/action_logs/find.rs +++ b/crates/axumkit-server/src/repository/action_logs/find.rs @@ -1,23 +1,12 @@ -use axumkit_constants::ActionLogAction; +use super::filter::{ActionLogFilter, apply_action_log_filter}; use axumkit_dto::pagination::CursorDirection; use axumkit_entity::action_logs::{ Column as ActionLogColumn, Entity as ActionLogEntity, Model as ActionLogModel, }; -use axumkit_entity::common::ActionResourceType; use axumkit_errors::errors::Errors; -use sea_orm::prelude::IpNetwork; use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect}; use uuid::Uuid; -#[derive(Debug, Default, Clone)] -pub struct ActionLogFilter { - pub actor_id: Option, - pub actor_ip: Option, - pub resource_id: Option, - pub resource_type: Option, - pub actions: Option>, -} - pub async fn repository_find_action_logs( conn: &C, filter: &ActionLogFilter, @@ -28,30 +17,7 @@ pub async fn repository_find_action_logs( where C: ConnectionTrait, { - let mut query = ActionLogEntity::find(); - - if let Some(actor_id) = filter.actor_id { - query = query.filter(ActionLogColumn::ActorId.eq(actor_id)); - } - - if let Some(actor_ip) = filter.actor_ip { - query = query.filter(ActionLogColumn::ActorIp.eq(actor_ip)); - } - - if let Some(resource_id) = filter.resource_id { - query = query.filter(ActionLogColumn::ResourceId.eq(resource_id)); - } - - if let Some(resource_type) = &filter.resource_type { - query = query.filter(ActionLogColumn::ResourceType.eq(resource_type.clone())); - } - - if let Some(actions) = &filter.actions { - if !actions.is_empty() { - let action_strs: Vec<&str> = actions.iter().map(|a| a.as_str()).collect(); - query = query.filter(ActionLogColumn::Action.is_in(action_strs)); - } - } + let mut query = apply_action_log_filter(ActionLogEntity::find(), filter); // Apply cursor-based filtering (UUIDv7 is time-sortable) if let Some(id) = cursor_id { diff --git a/crates/axumkit-server/src/repository/action_logs/mod.rs b/crates/axumkit-server/src/repository/action_logs/mod.rs index 4a3ce0f..7ecf8f3 100644 --- a/crates/axumkit-server/src/repository/action_logs/mod.rs +++ b/crates/axumkit-server/src/repository/action_logs/mod.rs @@ -1,7 +1,9 @@ pub mod create; pub mod exists; +mod filter; pub mod find; pub use create::*; pub use exists::*; +pub use filter::ActionLogFilter; pub use find::*; diff --git a/crates/axumkit-server/src/repository/mod.rs b/crates/axumkit-server/src/repository/mod.rs index b29783f..bc37f86 100644 --- a/crates/axumkit-server/src/repository/mod.rs +++ b/crates/axumkit-server/src/repository/mod.rs @@ -1,4 +1,4 @@ pub mod action_logs; +pub mod moderation; pub mod oauth; -pub mod posts; pub mod user; diff --git a/crates/axumkit-server/src/repository/moderation/create.rs b/crates/axumkit-server/src/repository/moderation/create.rs new file mode 100644 index 0000000..b1b6add --- /dev/null +++ b/crates/axumkit-server/src/repository/moderation/create.rs @@ -0,0 +1,37 @@ +use axumkit_constants::ModerationAction; +use axumkit_entity::common::ModerationResourceType; +use axumkit_entity::moderation_logs::{ + ActiveModel as ModerationLogActiveModel, Model as ModerationLogModel, +}; +use axumkit_errors::errors::Errors; +use sea_orm::{ActiveModelTrait, ConnectionTrait, Set}; +use serde_json::Value as JsonValue; +use uuid::Uuid; + +pub async fn repository_create_moderation_log( + conn: &C, + action: ModerationAction, + actor_id: Option, + resource_type: ModerationResourceType, + resource_id: Option, + reason: String, + metadata: Option, +) -> Result +where + C: ConnectionTrait, +{ + let log = ModerationLogActiveModel { + id: Default::default(), + action: Set(action.as_str().to_string()), + actor_id: Set(actor_id), + resource_type: Set(resource_type), + resource_id: Set(resource_id), + reason: Set(reason), + metadata: Set(metadata), + created_at: Default::default(), + }; + + let log = log.insert(conn).await?; + + Ok(log) +} diff --git a/crates/axumkit-server/src/repository/moderation/exists/mod.rs b/crates/axumkit-server/src/repository/moderation/exists/mod.rs new file mode 100644 index 0000000..62aee00 --- /dev/null +++ b/crates/axumkit-server/src/repository/moderation/exists/mod.rs @@ -0,0 +1,5 @@ +mod newer; +mod older; + +pub use newer::repository_exists_newer_moderation_log; +pub use older::repository_exists_older_moderation_log; diff --git a/crates/axumkit-server/src/repository/moderation/exists/newer.rs b/crates/axumkit-server/src/repository/moderation/exists/newer.rs new file mode 100644 index 0000000..6e2dd1c --- /dev/null +++ b/crates/axumkit-server/src/repository/moderation/exists/newer.rs @@ -0,0 +1,26 @@ +use super::super::filter::{ModerationLogFilter, apply_moderation_log_filter}; +use axumkit_entity::moderation_logs::{ + Column as ModerationLogColumn, Entity as ModerationLogEntity, +}; +use axumkit_errors::errors::Errors; +use sea_orm::{ + ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect, +}; +use uuid::Uuid; + +pub async fn repository_exists_newer_moderation_log( + conn: &C, + filter: &ModerationLogFilter, + cursor_id: Uuid, +) -> Result +where + C: ConnectionTrait, +{ + let query = apply_moderation_log_filter( + ModerationLogEntity::find().filter(ModerationLogColumn::Id.gt(cursor_id)), + filter, + ); + + let count = query.limit(1).count(conn).await?; + Ok(count > 0) +} diff --git a/crates/axumkit-server/src/repository/moderation/exists/older.rs b/crates/axumkit-server/src/repository/moderation/exists/older.rs new file mode 100644 index 0000000..54a472e --- /dev/null +++ b/crates/axumkit-server/src/repository/moderation/exists/older.rs @@ -0,0 +1,26 @@ +use super::super::filter::{ModerationLogFilter, apply_moderation_log_filter}; +use axumkit_entity::moderation_logs::{ + Column as ModerationLogColumn, Entity as ModerationLogEntity, +}; +use axumkit_errors::errors::Errors; +use sea_orm::{ + ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect, +}; +use uuid::Uuid; + +pub async fn repository_exists_older_moderation_log( + conn: &C, + filter: &ModerationLogFilter, + cursor_id: Uuid, +) -> Result +where + C: ConnectionTrait, +{ + let query = apply_moderation_log_filter( + ModerationLogEntity::find().filter(ModerationLogColumn::Id.lt(cursor_id)), + filter, + ); + + let count = query.limit(1).count(conn).await?; + Ok(count > 0) +} diff --git a/crates/axumkit-server/src/repository/moderation/filter.rs b/crates/axumkit-server/src/repository/moderation/filter.rs new file mode 100644 index 0000000..477b4fe --- /dev/null +++ b/crates/axumkit-server/src/repository/moderation/filter.rs @@ -0,0 +1,41 @@ +use axumkit_constants::ModerationAction; +use axumkit_entity::common::ModerationResourceType; +use axumkit_entity::moderation_logs::{ + Column as ModerationLogColumn, Entity as ModerationLogEntity, +}; +use sea_orm::{ColumnTrait, QueryFilter, Select}; +use uuid::Uuid; + +#[derive(Debug, Default, Clone)] +pub struct ModerationLogFilter { + pub actor_id: Option, + pub resource_type: Option, + pub resource_id: Option, + pub actions: Option>, +} + +pub(crate) fn apply_moderation_log_filter( + mut query: Select, + filter: &ModerationLogFilter, +) -> Select { + if let Some(actor_id) = filter.actor_id { + query = query.filter(ModerationLogColumn::ActorId.eq(actor_id)); + } + + if let Some(resource_type) = filter.resource_type.clone() { + query = query.filter(ModerationLogColumn::ResourceType.eq(resource_type)); + } + + if let Some(resource_id) = filter.resource_id { + query = query.filter(ModerationLogColumn::ResourceId.eq(resource_id)); + } + + if let Some(actions) = &filter.actions + && !actions.is_empty() + { + let action_strs: Vec<&str> = actions.iter().map(|action| action.as_str()).collect(); + query = query.filter(ModerationLogColumn::Action.is_in(action_strs)); + } + + query +} diff --git a/crates/axumkit-server/src/repository/moderation/find_list.rs b/crates/axumkit-server/src/repository/moderation/find_list.rs new file mode 100644 index 0000000..8217379 --- /dev/null +++ b/crates/axumkit-server/src/repository/moderation/find_list.rs @@ -0,0 +1,38 @@ +use super::filter::{ModerationLogFilter, apply_moderation_log_filter}; +use axumkit_dto::pagination::CursorDirection; +use axumkit_entity::moderation_logs::{ + Column as ModerationLogColumn, Entity as ModerationLogEntity, Model as ModerationLogModel, +}; +use axumkit_errors::errors::Errors; +use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect}; +use uuid::Uuid; + +pub async fn repository_find_moderation_logs( + conn: &C, + filter: &ModerationLogFilter, + cursor_id: Option, + cursor_direction: Option, + limit: u64, +) -> Result, Errors> +where + C: ConnectionTrait, +{ + let mut query = apply_moderation_log_filter(ModerationLogEntity::find(), filter); + + if let Some(id) = cursor_id { + let direction = cursor_direction.unwrap_or(CursorDirection::Older); + query = match direction { + CursorDirection::Older => query + .filter(ModerationLogColumn::Id.lt(id)) + .order_by_desc(ModerationLogColumn::Id), + CursorDirection::Newer => query + .filter(ModerationLogColumn::Id.gt(id)) + .order_by_asc(ModerationLogColumn::Id), + }; + } else { + query = query.order_by_desc(ModerationLogColumn::Id); + } + + let results = query.limit(limit).all(conn).await?; + Ok(results) +} diff --git a/crates/axumkit-server/src/repository/moderation/mod.rs b/crates/axumkit-server/src/repository/moderation/mod.rs new file mode 100644 index 0000000..450e9b8 --- /dev/null +++ b/crates/axumkit-server/src/repository/moderation/mod.rs @@ -0,0 +1,9 @@ +pub mod create; +pub mod exists; +mod filter; +pub mod find_list; + +pub use create::repository_create_moderation_log; +pub use exists::*; +pub use filter::ModerationLogFilter; +pub use find_list::repository_find_moderation_logs; diff --git a/crates/axumkit-server/src/repository/oauth/count_oauth_connections.rs b/crates/axumkit-server/src/repository/oauth/count_oauth_connections.rs index 1216906..580f5b1 100644 --- a/crates/axumkit-server/src/repository/oauth/count_oauth_connections.rs +++ b/crates/axumkit-server/src/repository/oauth/count_oauth_connections.rs @@ -6,8 +6,8 @@ use sea_orm::PaginatorTrait; use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter}; use uuid::Uuid; -/// 사용자의 OAuth 연결 개수를 조회합니다. -/// 마지막 인증 수단 보호에 사용됩니다. +/// Queries the number of OAuth connections for a user. +/// Used to protect the last authentication method. pub async fn repository_count_oauth_connections(conn: &C, user_id: Uuid) -> Result where C: ConnectionTrait, diff --git a/crates/axumkit-server/src/repository/oauth/create_oauth_connection.rs b/crates/axumkit-server/src/repository/oauth/create_oauth_connection.rs index 9be297a..62520e8 100644 --- a/crates/axumkit-server/src/repository/oauth/create_oauth_connection.rs +++ b/crates/axumkit-server/src/repository/oauth/create_oauth_connection.rs @@ -5,7 +5,7 @@ use sea_orm::{ActiveModelTrait, ConnectionTrait, Set}; use tracing::error; use uuid::Uuid; -/// OAuth 연결 생성 +/// Create an OAuth connection pub async fn repository_create_oauth_connection( conn: &C, user_id: &Uuid, diff --git a/crates/axumkit-server/src/repository/oauth/create_oauth_user.rs b/crates/axumkit-server/src/repository/oauth/create_oauth_user.rs index e488d59..fbcc1f4 100644 --- a/crates/axumkit-server/src/repository/oauth/create_oauth_user.rs +++ b/crates/axumkit-server/src/repository/oauth/create_oauth_user.rs @@ -3,7 +3,7 @@ use axumkit_errors::errors::Errors; use chrono::Utc; use sea_orm::{ActiveModelTrait, ConnectionTrait, Set}; -/// OAuth를 통한 새 유저 생성 (비밀번호 없음, 이메일 인증 완료 상태) +/// Create a new user via OAuth (no password, email verified) pub async fn repository_create_oauth_user( conn: &C, email: &str, @@ -20,8 +20,8 @@ where handle: Set(handle.to_string()), bio: Set(None), email: Set(email.to_string()), - password: Set(None), // OAuth 유저는 비밀번호 없음 - verified_at: Set(Some(Utc::now())), // OAuth 제공자가 이미 이메일 검증함 + password: Set(None), // OAuth users have no password + verified_at: Set(Some(Utc::now())), // OAuth provider already verified email profile_image: Set(profile_image), banner_image: Set(None), totp_secret: Set(None), diff --git a/crates/axumkit-server/src/repository/oauth/delete_oauth_connection.rs b/crates/axumkit-server/src/repository/oauth/delete_oauth_connection.rs index cbbfbde..ee14e7c 100644 --- a/crates/axumkit-server/src/repository/oauth/delete_oauth_connection.rs +++ b/crates/axumkit-server/src/repository/oauth/delete_oauth_connection.rs @@ -6,7 +6,7 @@ use axumkit_errors::errors::Errors; use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter}; use uuid::Uuid; -/// 특정 사용자의 특정 provider OAuth 연결을 삭제합니다. +/// Deletes a specific provider's OAuth connection for a user. pub async fn repository_delete_oauth_connection( conn: &C, user_id: Uuid, diff --git a/crates/axumkit-server/src/repository/oauth/find_oauth_connection.rs b/crates/axumkit-server/src/repository/oauth/find_oauth_connection.rs index 63e44ce..f1cbe51 100644 --- a/crates/axumkit-server/src/repository/oauth/find_oauth_connection.rs +++ b/crates/axumkit-server/src/repository/oauth/find_oauth_connection.rs @@ -7,7 +7,7 @@ use axumkit_errors::errors::Errors; use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter}; use uuid::Uuid; -/// 특정 사용자의 특정 provider OAuth 연결을 조회합니다. +/// Queries a specific provider's OAuth connection for a user. pub async fn repository_find_oauth_connection( conn: &C, user_id: Uuid, diff --git a/crates/axumkit-server/src/repository/oauth/find_user_by_oauth.rs b/crates/axumkit-server/src/repository/oauth/find_user_by_oauth.rs index d9ccf99..f07ebfa 100644 --- a/crates/axumkit-server/src/repository/oauth/find_user_by_oauth.rs +++ b/crates/axumkit-server/src/repository/oauth/find_user_by_oauth.rs @@ -6,7 +6,7 @@ use sea_orm::{ ColumnTrait, ConnectionTrait, EntityTrait, JoinType, QueryFilter, QuerySelect, RelationTrait, }; -/// OAuth provider와 provider_user_id로 유저 조회 +/// Find a user by OAuth provider and provider_user_id pub async fn repository_find_user_by_oauth( conn: &C, provider: OAuthProvider, diff --git a/crates/axumkit-server/src/repository/oauth/list_oauth_connections.rs b/crates/axumkit-server/src/repository/oauth/list_oauth_connections.rs index e6584a3..66e1286 100644 --- a/crates/axumkit-server/src/repository/oauth/list_oauth_connections.rs +++ b/crates/axumkit-server/src/repository/oauth/list_oauth_connections.rs @@ -6,7 +6,7 @@ use axumkit_errors::errors::Errors; use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, Order, QueryFilter, QueryOrder}; use uuid::Uuid; -/// 사용자의 모든 OAuth 연결을 조회합니다. +/// Queries all OAuth connections for a user. pub async fn repository_list_oauth_connections_by_user_id( conn: &C, user_id: Uuid, diff --git a/crates/axumkit-server/src/repository/posts/create.rs b/crates/axumkit-server/src/repository/posts/create.rs deleted file mode 100644 index e440cb5..0000000 --- a/crates/axumkit-server/src/repository/posts/create.rs +++ /dev/null @@ -1,27 +0,0 @@ -use axumkit_entity::posts::{ActiveModel as PostActiveModel, Model as PostModel}; -use axumkit_errors::errors::Errors; -use sea_orm::{ActiveModelTrait, ConnectionTrait, Set}; -use uuid::Uuid; - -pub async fn repository_create_post( - conn: &C, - author_id: Uuid, - title: String, - storage_key: String, -) -> Result -where - C: ConnectionTrait, -{ - let new_post = PostActiveModel { - id: Default::default(), - author_id: Set(author_id), - title: Set(title), - storage_key: Set(storage_key), - created_at: Default::default(), - updated_at: Default::default(), - }; - - let post = new_post.insert(conn).await?; - - Ok(post) -} diff --git a/crates/axumkit-server/src/repository/posts/delete.rs b/crates/axumkit-server/src/repository/posts/delete.rs deleted file mode 100644 index f0c6431..0000000 --- a/crates/axumkit-server/src/repository/posts/delete.rs +++ /dev/null @@ -1,18 +0,0 @@ -use axumkit_entity::posts::Entity as PostEntity; -use axumkit_errors::errors::Errors; -use sea_orm::{ConnectionTrait, EntityTrait, ModelTrait}; -use uuid::Uuid; - -pub async fn repository_delete_post(conn: &C, id: Uuid) -> Result<(), Errors> -where - C: ConnectionTrait, -{ - let post = PostEntity::find_by_id(id) - .one(conn) - .await? - .ok_or(Errors::PostNotFound)?; - - post.delete(conn).await?; - - Ok(()) -} diff --git a/crates/axumkit-server/src/repository/posts/find_by_author.rs b/crates/axumkit-server/src/repository/posts/find_by_author.rs deleted file mode 100644 index 2025c43..0000000 --- a/crates/axumkit-server/src/repository/posts/find_by_author.rs +++ /dev/null @@ -1,20 +0,0 @@ -use axumkit_entity::posts::{Column, Entity as PostEntity, Model as PostModel}; -use axumkit_errors::errors::Errors; -use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder}; -use uuid::Uuid; - -pub async fn repository_find_posts_by_author( - conn: &C, - author_id: Uuid, -) -> Result, Errors> -where - C: ConnectionTrait, -{ - let posts = PostEntity::find() - .filter(Column::AuthorId.eq(author_id)) - .order_by_desc(Column::CreatedAt) - .all(conn) - .await?; - - Ok(posts) -} diff --git a/crates/axumkit-server/src/repository/posts/find_by_id.rs b/crates/axumkit-server/src/repository/posts/find_by_id.rs deleted file mode 100644 index 90aaed9..0000000 --- a/crates/axumkit-server/src/repository/posts/find_by_id.rs +++ /dev/null @@ -1,12 +0,0 @@ -use axumkit_entity::posts::{Entity as PostEntity, Model as PostModel}; -use axumkit_errors::errors::Errors; -use sea_orm::{ConnectionTrait, EntityTrait}; -use uuid::Uuid; - -pub async fn repository_find_post_by_id(conn: &C, id: Uuid) -> Result, Errors> -where - C: ConnectionTrait, -{ - let post = PostEntity::find_by_id(id).one(conn).await?; - Ok(post) -} diff --git a/crates/axumkit-server/src/repository/posts/get_by_id.rs b/crates/axumkit-server/src/repository/posts/get_by_id.rs deleted file mode 100644 index 295eefb..0000000 --- a/crates/axumkit-server/src/repository/posts/get_by_id.rs +++ /dev/null @@ -1,15 +0,0 @@ -use axumkit_entity::posts::Model as PostModel; -use axumkit_errors::errors::Errors; -use sea_orm::ConnectionTrait; -use uuid::Uuid; - -use super::find_by_id::repository_find_post_by_id; - -pub async fn repository_get_post_by_id(conn: &C, id: Uuid) -> Result -where - C: ConnectionTrait, -{ - repository_find_post_by_id(conn, id) - .await? - .ok_or(Errors::PostNotFound) -} diff --git a/crates/axumkit-server/src/repository/posts/list.rs b/crates/axumkit-server/src/repository/posts/list.rs deleted file mode 100644 index 1aebc71..0000000 --- a/crates/axumkit-server/src/repository/posts/list.rs +++ /dev/null @@ -1,21 +0,0 @@ -use axumkit_entity::posts::{Column, Entity as PostEntity, Model as PostModel}; -use axumkit_errors::errors::Errors; -use sea_orm::{ConnectionTrait, EntityTrait, QueryOrder, QuerySelect}; - -pub async fn repository_list_posts( - conn: &C, - limit: u64, - offset: u64, -) -> Result, Errors> -where - C: ConnectionTrait, -{ - let posts = PostEntity::find() - .order_by_desc(Column::CreatedAt) - .offset(offset) - .limit(limit) - .all(conn) - .await?; - - Ok(posts) -} diff --git a/crates/axumkit-server/src/repository/posts/mod.rs b/crates/axumkit-server/src/repository/posts/mod.rs deleted file mode 100644 index eda6667..0000000 --- a/crates/axumkit-server/src/repository/posts/mod.rs +++ /dev/null @@ -1,15 +0,0 @@ -pub mod create; -pub mod delete; -pub mod find_by_author; -pub mod find_by_id; -pub mod get_by_id; -pub mod list; -pub mod update; - -pub use create::repository_create_post; -pub use delete::repository_delete_post; -pub use find_by_author::repository_find_posts_by_author; -pub use find_by_id::repository_find_post_by_id; -pub use get_by_id::repository_get_post_by_id; -pub use list::repository_list_posts; -pub use update::{PostUpdateParams, repository_update_post}; diff --git a/crates/axumkit-server/src/repository/posts/update.rs b/crates/axumkit-server/src/repository/posts/update.rs deleted file mode 100644 index f69732d..0000000 --- a/crates/axumkit-server/src/repository/posts/update.rs +++ /dev/null @@ -1,34 +0,0 @@ -use axumkit_entity::posts::{ActiveModel as PostActiveModel, Model as PostModel}; -use axumkit_errors::errors::Errors; -use chrono::Utc; -use sea_orm::{ActiveModelTrait, ConnectionTrait, IntoActiveModel, Set}; - -pub struct PostUpdateParams { - pub title: Option, - pub storage_key: Option, -} - -pub async fn repository_update_post( - conn: &C, - post: PostModel, - params: PostUpdateParams, -) -> Result -where - C: ConnectionTrait, -{ - let mut active_model: PostActiveModel = post.into_active_model(); - - if let Some(title) = params.title { - active_model.title = Set(title); - } - - if let Some(storage_key) = params.storage_key { - active_model.storage_key = Set(storage_key); - } - - active_model.updated_at = Set(Utc::now().into()); - - let updated_post = active_model.update(conn).await?; - - Ok(updated_post) -} diff --git a/crates/axumkit-server/src/repository/user/create.rs b/crates/axumkit-server/src/repository/user/create.rs index fb0c243..9c692eb 100644 --- a/crates/axumkit-server/src/repository/user/create.rs +++ b/crates/axumkit-server/src/repository/user/create.rs @@ -36,3 +36,34 @@ where Ok(user) } + +/// Create a user with a pre-hashed password (used by email verification flow). +pub async fn repository_create_user_with_password_hash( + conn: &C, + email: String, + handle: String, + display_name: String, + password_hash: String, +) -> Result +where + C: ConnectionTrait, +{ + let new_user = UserActiveModel { + id: Default::default(), + display_name: Set(display_name), + handle: Set(handle), + bio: Set(None), + email: Set(email), + password: Set(Some(password_hash)), + verified_at: Set(None), + profile_image: Set(None), + banner_image: Set(None), + totp_secret: Set(None), + totp_enabled_at: Set(None), + totp_backup_codes: Set(None), + created_at: Default::default(), + }; + + let user = new_user.insert(conn).await?; + Ok(user) +} diff --git a/crates/axumkit-server/src/repository/user/mod.rs b/crates/axumkit-server/src/repository/user/mod.rs index d02350e..eccc37b 100644 --- a/crates/axumkit-server/src/repository/user/mod.rs +++ b/crates/axumkit-server/src/repository/user/mod.rs @@ -7,8 +7,10 @@ pub mod get_by_email; pub mod get_by_handle; pub mod get_by_id; pub mod update; +pub mod user_bans; +pub mod user_roles; -pub use create::repository_create_user; +pub use create::{repository_create_user, repository_create_user_with_password_hash}; pub use find_by_email::repository_find_user_by_email; pub use find_by_handle::repository_find_user_by_handle; pub use find_by_id::repository_find_user_by_id; diff --git a/crates/axumkit-server/src/repository/user/update.rs b/crates/axumkit-server/src/repository/user/update.rs index 6b7053c..38a66b1 100644 --- a/crates/axumkit-server/src/repository/user/update.rs +++ b/crates/axumkit-server/src/repository/user/update.rs @@ -6,11 +6,11 @@ use sea_orm::prelude::DateTimeUtc; use sea_orm::{ActiveModelTrait, ConnectionTrait, EntityTrait, IntoActiveModel, Set}; use uuid::Uuid; -/// 사용자 업데이트 파라미터 -/// - `Option`: None = 변경 안 함, Some(value) = 값으로 변경 -/// - `Option>`: None = 변경 안 함, Some(None) = NULL로 설정, Some(Some(value)) = 값으로 설정 +/// User update parameters +/// - `Option`: None = no change, Some(value) = change to value +/// - `Option>`: None = no change, Some(None) = set to NULL, Some(Some(value)) = set to value /// -/// # 사용 예시 +/// # Usage Example /// ```ignore /// repository_update_user(conn, user_id, UserUpdateParams { /// totp_secret: Some(Some(secret)), @@ -32,7 +32,7 @@ pub struct UserUpdateParams { pub totp_backup_codes: Option>>, } -/// 범용 사용자 정보 업데이트 +/// General-purpose user information update pub async fn repository_update_user( conn: &C, user_id: Uuid, diff --git a/crates/axumkit-server/src/repository/user/user_bans/create.rs b/crates/axumkit-server/src/repository/user/user_bans/create.rs new file mode 100644 index 0000000..682d985 --- /dev/null +++ b/crates/axumkit-server/src/repository/user/user_bans/create.rs @@ -0,0 +1,24 @@ +use axumkit_entity::user_bans::{ActiveModel, Model}; +use axumkit_errors::errors::Errors; +use chrono::{DateTime, Utc}; +use sea_orm::{ActiveModelTrait, ConnectionTrait, Set}; +use uuid::Uuid; + +pub async fn repository_create_user_ban( + conn: &C, + user_id: Uuid, + expires_at: Option>, +) -> Result +where + C: ConnectionTrait, +{ + let new_ban = ActiveModel { + id: Default::default(), + user_id: Set(user_id), + expires_at: Set(expires_at), + created_at: Default::default(), + }; + + let result = new_ban.insert(conn).await?; + Ok(result) +} diff --git a/crates/axumkit-server/src/repository/user/user_bans/delete.rs b/crates/axumkit-server/src/repository/user/user_bans/delete.rs new file mode 100644 index 0000000..2489bf1 --- /dev/null +++ b/crates/axumkit-server/src/repository/user/user_bans/delete.rs @@ -0,0 +1,34 @@ +use axumkit_entity::user_bans::{Column, Entity}; +use axumkit_errors::errors::Errors; +use chrono::Utc; +use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter}; +use uuid::Uuid; + +pub async fn repository_delete_user_ban(conn: &C, user_id: Uuid) -> Result +where + C: ConnectionTrait, +{ + let result = Entity::delete_many() + .filter(Column::UserId.eq(user_id)) + .exec(conn) + .await?; + + Ok(result.rows_affected) +} + +/// Deletes only expired bans for the user (prevents UNIQUE constraint violation on re-ban). +pub async fn repository_delete_expired_user_ban(conn: &C, user_id: Uuid) -> Result +where + C: ConnectionTrait, +{ + let now = Utc::now(); + + let result = Entity::delete_many() + .filter(Column::UserId.eq(user_id)) + .filter(Column::ExpiresAt.is_not_null()) + .filter(Column::ExpiresAt.lte(now)) + .exec(conn) + .await?; + + Ok(result.rows_affected) +} diff --git a/crates/axumkit-server/src/repository/user/user_bans/find.rs b/crates/axumkit-server/src/repository/user/user_bans/find.rs new file mode 100644 index 0000000..5b96e41 --- /dev/null +++ b/crates/axumkit-server/src/repository/user/user_bans/find.rs @@ -0,0 +1,28 @@ +use axumkit_entity::user_bans::{Column, Entity, Model}; +use axumkit_errors::errors::Errors; +use chrono::Utc; +use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, ExprTrait, QueryFilter}; +use uuid::Uuid; + +pub async fn repository_find_user_ban(conn: &C, user_id: Uuid) -> Result, Errors> +where + C: ConnectionTrait, +{ + let now = Utc::now(); + + let ban = Entity::find() + .filter(Column::UserId.eq(user_id)) + .filter(Column::ExpiresAt.is_null().or(Column::ExpiresAt.gt(now))) + .one(conn) + .await?; + + Ok(ban) +} + +pub async fn repository_is_user_banned(conn: &C, user_id: Uuid) -> Result +where + C: ConnectionTrait, +{ + let ban = repository_find_user_ban(conn, user_id).await?; + Ok(ban.is_some()) +} diff --git a/crates/axumkit-server/src/repository/user/user_bans/mod.rs b/crates/axumkit-server/src/repository/user/user_bans/mod.rs new file mode 100644 index 0000000..5711301 --- /dev/null +++ b/crates/axumkit-server/src/repository/user/user_bans/mod.rs @@ -0,0 +1,7 @@ +mod create; +mod delete; +mod find; + +pub use create::repository_create_user_ban; +pub use delete::{repository_delete_expired_user_ban, repository_delete_user_ban}; +pub use find::{repository_find_user_ban, repository_is_user_banned}; diff --git a/crates/axumkit-server/src/repository/user/user_roles/create.rs b/crates/axumkit-server/src/repository/user/user_roles/create.rs new file mode 100644 index 0000000..2e7f6fa --- /dev/null +++ b/crates/axumkit-server/src/repository/user/user_roles/create.rs @@ -0,0 +1,27 @@ +use axumkit_entity::common::Role; +use axumkit_entity::user_roles::{ActiveModel, Model}; +use axumkit_errors::errors::Errors; +use chrono::{DateTime, Utc}; +use sea_orm::{ActiveModelTrait, ConnectionTrait, Set}; +use uuid::Uuid; + +pub async fn repository_create_user_role( + conn: &C, + user_id: Uuid, + role: Role, + expires_at: Option>, +) -> Result +where + C: ConnectionTrait, +{ + let new_role = ActiveModel { + id: Default::default(), + user_id: Set(user_id), + role: Set(role), + granted_at: Default::default(), + expires_at: Set(expires_at), + }; + + let result = new_role.insert(conn).await?; + Ok(result) +} diff --git a/crates/axumkit-server/src/repository/user/user_roles/delete.rs b/crates/axumkit-server/src/repository/user/user_roles/delete.rs new file mode 100644 index 0000000..2e4bc95 --- /dev/null +++ b/crates/axumkit-server/src/repository/user/user_roles/delete.rs @@ -0,0 +1,44 @@ +use axumkit_entity::common::Role; +use axumkit_entity::user_roles::{Column, Entity}; +use axumkit_errors::errors::Errors; +use chrono::Utc; +use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter}; +use uuid::Uuid; + +pub async fn repository_delete_user_role( + conn: &C, + user_id: Uuid, + role: Role, +) -> Result +where + C: ConnectionTrait, +{ + let result = Entity::delete_many() + .filter(Column::UserId.eq(user_id)) + .filter(Column::Role.eq(role)) + .exec(conn) + .await?; + + Ok(result.rows_affected) +} + +pub async fn repository_delete_expired_user_role( + conn: &C, + user_id: Uuid, + role: Role, +) -> Result +where + C: ConnectionTrait, +{ + let now = Utc::now(); + + let result = Entity::delete_many() + .filter(Column::UserId.eq(user_id)) + .filter(Column::Role.eq(role)) + .filter(Column::ExpiresAt.is_not_null()) + .filter(Column::ExpiresAt.lte(now)) + .exec(conn) + .await?; + + Ok(result.rows_affected) +} diff --git a/crates/axumkit-server/src/repository/user/user_roles/find.rs b/crates/axumkit-server/src/repository/user/user_roles/find.rs new file mode 100644 index 0000000..a1f9160 --- /dev/null +++ b/crates/axumkit-server/src/repository/user/user_roles/find.rs @@ -0,0 +1,26 @@ +use axumkit_entity::common::Role; +use axumkit_entity::user_roles::{Column, Entity}; +use axumkit_errors::errors::Errors; +use chrono::Utc; +use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, ExprTrait, QueryFilter}; +use uuid::Uuid; + +pub async fn repository_find_user_roles(conn: &C, user_id: Uuid) -> Result, Errors> +where + C: ConnectionTrait, +{ + let now = Utc::now(); + + let mut roles = Entity::find() + .filter(Column::UserId.eq(user_id)) + .filter(Column::ExpiresAt.is_null().or(Column::ExpiresAt.gt(now))) + .all(conn) + .await? + .into_iter() + .map(|entry| entry.role) + .collect::>(); + + roles.sort_by_key(|role| std::cmp::Reverse(role.display_priority())); + + Ok(roles) +} diff --git a/crates/axumkit-server/src/repository/user/user_roles/mod.rs b/crates/axumkit-server/src/repository/user/user_roles/mod.rs new file mode 100644 index 0000000..8c5272d --- /dev/null +++ b/crates/axumkit-server/src/repository/user/user_roles/mod.rs @@ -0,0 +1,7 @@ +mod create; +mod delete; +mod find; + +pub use create::repository_create_user_role; +pub use delete::{repository_delete_expired_user_role, repository_delete_user_role}; +pub use find::repository_find_user_roles; diff --git a/crates/axumkit-server/src/service/auth/change_email.rs b/crates/axumkit-server/src/service/auth/change_email.rs index 0ffb3eb..2100b21 100644 --- a/crates/axumkit-server/src/service/auth/change_email.rs +++ b/crates/axumkit-server/src/service/auth/change_email.rs @@ -3,7 +3,7 @@ use crate::repository::user::{repository_find_user_by_email, repository_get_user use crate::state::WorkerClient; use crate::utils::crypto::password::verify_password; use crate::utils::crypto::token::generate_secure_token; -use crate::utils::redis_cache::set_json_with_ttl; +use crate::utils::redis_cache::issue_token_and_store_json_with_ttl; use axumkit_config::ServerConfig; use axumkit_dto::auth::request::ChangeEmailRequest; use axumkit_errors::errors::{Errors, ServiceResult}; @@ -19,7 +19,6 @@ pub struct EmailChangeData { pub new_email: String, } -/// 이메일 변경을 요청합니다. 새 이메일로 인증 메일이 발송됩니다. pub async fn service_change_email( conn: &C, redis_conn: &ConnectionManager, @@ -32,21 +31,17 @@ where { let config = ServerConfig::get(); - // 1. 사용자 조회 let user = repository_get_user_by_id(conn, user_id).await?; - // 2. 비밀번호 검증 (OAuth 전용 사용자는 비밀번호 변경 불가) let password_hash = user.password.ok_or(Errors::UserPasswordNotSet)?; verify_password(&payload.password, &password_hash)?; - // 3. 새 이메일이 현재 이메일과 동일한지 확인 if user.email == payload.new_email { return Err(Errors::BadRequestError( "New email must be different from current email.".to_string(), )); } - // 4. 새 이메일이 이미 사용 중인지 확인 if repository_find_user_by_email(conn, payload.new_email.clone()) .await? .is_some() @@ -54,20 +49,21 @@ where return Err(Errors::UserEmailAlreadyExists); } - // 5. 토큰 생성 - let token = generate_secure_token(); - let token_key = format!("email_change:{}", token); - let change_data = EmailChangeData { user_id: user.id.to_string(), new_email: payload.new_email.clone(), }; - // 6. Redis에 토큰 저장 (분 단위 → 초 단위 변환) let ttl_seconds = (config.auth_email_change_token_expire_time * 60) as u64; - set_json_with_ttl(redis_conn, &token_key, &change_data, ttl_seconds).await?; + let token = issue_token_and_store_json_with_ttl( + redis_conn, + generate_secure_token, + axumkit_constants::email_change_key, + &change_data, + ttl_seconds, + ) + .await?; - // 7. Worker 서비스에 이메일 발송 요청 (새 이메일로) worker_client::send_email_change_verification( worker, &payload.new_email, @@ -77,10 +73,7 @@ where ) .await?; - info!( - "Email change verification sent to {} for user {}", - payload.new_email, user_id - ); + info!(user_id = %user_id, "Email change verification sent"); Ok(()) } diff --git a/crates/axumkit-server/src/service/auth/change_password.rs b/crates/axumkit-server/src/service/auth/change_password.rs index 54333b1..ac6c4e5 100644 --- a/crates/axumkit-server/src/service/auth/change_password.rs +++ b/crates/axumkit-server/src/service/auth/change_password.rs @@ -6,50 +6,37 @@ use crate::utils::crypto::password::{hash_password, verify_password}; use axumkit_dto::auth::request::ChangePasswordRequest; use axumkit_errors::errors::{Errors, ServiceResult}; use redis::aio::ConnectionManager; -use sea_orm::ConnectionTrait; +use sea_orm::{DatabaseConnection, TransactionTrait}; use tracing::info; use uuid::Uuid; -/// 비밀번호를 변경합니다. /// /// # Arguments -/// * `conn` - 데이터베이스 연결 -/// * `redis_conn` - Redis 연결 -/// * `user_id` - 사용자 ID -/// * `session_id` - 현재 세션 ID (유지할 세션) -/// * `payload` - 비밀번호 변경 요청 -pub async fn service_change_password( - conn: &C, +pub async fn service_change_password( + conn: &DatabaseConnection, redis_conn: &ConnectionManager, user_id: Uuid, session_id: &str, payload: ChangePasswordRequest, -) -> ServiceResult<()> -where - C: ConnectionTrait, -{ - // 1. 사용자 조회 - let user = repository_get_user_by_id(conn, user_id).await?; +) -> ServiceResult<()> { + let txn = conn.begin().await?; + + let user = repository_get_user_by_id(&txn, user_id).await?; - // 2. 비밀번호가 설정되어 있는지 확인 (OAuth 전용 사용자 제외) let password_hash = user.password.ok_or(Errors::UserPasswordNotSet)?; - // 3. 현재 비밀번호 검증 verify_password(&payload.current_password, &password_hash)?; - // 4. 새 비밀번호가 현재 비밀번호와 동일한지 확인 if payload.current_password == payload.new_password { return Err(Errors::BadRequestError( "New password must be different from current password.".to_string(), )); } - // 5. 새 비밀번호 해싱 let new_password_hash = hash_password(&payload.new_password)?; - // 6. 비밀번호 업데이트 repository_update_user( - conn, + &txn, user_id, UserUpdateParams { password: Some(Some(new_password_hash)), @@ -58,14 +45,12 @@ where ) .await?; - // 7. 현재 세션을 제외한 모든 세션 무효화 + txn.commit().await?; + let deleted_count = SessionService::delete_other_sessions(redis_conn, &user_id.to_string(), session_id).await?; - info!( - "Password changed for user {}, {} other sessions invalidated", - user_id, deleted_count - ); + info!(user_id = %user_id, invalidated_sessions = deleted_count, "Password changed"); Ok(()) } diff --git a/crates/axumkit-server/src/service/auth/confirm_email_change.rs b/crates/axumkit-server/src/service/auth/confirm_email_change.rs index 9162d48..94ca960 100644 --- a/crates/axumkit-server/src/service/auth/confirm_email_change.rs +++ b/crates/axumkit-server/src/service/auth/confirm_email_change.rs @@ -2,51 +2,41 @@ use crate::repository::user::{ UserUpdateParams, repository_find_user_by_email, repository_update_user, }; use crate::service::auth::change_email::EmailChangeData; +use crate::utils::redis_cache::get_json_and_delete; use axumkit_errors::errors::{Errors, ServiceResult}; -use redis::AsyncCommands; use redis::aio::ConnectionManager; -use sea_orm::ConnectionTrait; +use sea_orm::{DatabaseConnection, TransactionTrait}; use tracing::info; use uuid::Uuid; -/// 이메일 변경을 확인합니다. -pub async fn service_confirm_email_change( - conn: &C, +pub async fn service_confirm_email_change( + conn: &DatabaseConnection, redis_conn: &ConnectionManager, token: &str, -) -> ServiceResult<()> -where - C: ConnectionTrait, -{ - // 1. Redis에서 토큰 검증 (get_del로 일회용) - let token_key = format!("email_change:{}", token); - let mut redis_mut = redis_conn.clone(); - - let token_json: Option = redis_mut - .get_del(&token_key) - .await - .map_err(|e| Errors::SysInternalError(format!("Redis error: {}", e)))?; - - let token_data = token_json.ok_or(Errors::TokenInvalidEmailChange)?; - - let change_data: EmailChangeData = - serde_json::from_str(&token_data).map_err(|_| Errors::TokenInvalidEmailChange)?; +) -> ServiceResult<()> { + let token_key = axumkit_constants::email_change_key(token); + let change_data: EmailChangeData = get_json_and_delete( + redis_conn, + &token_key, + || Errors::TokenInvalidEmailChange, + |_| Errors::TokenInvalidEmailChange, + ) + .await?; - // 2. user_id 파싱 let user_id = Uuid::parse_str(&change_data.user_id).map_err(|_| Errors::TokenInvalidEmailChange)?; - // 3. 이메일 중복 체크 (토큰 발급 후 다른 사용자가 해당 이메일을 사용했을 수 있음) + let txn = conn.begin().await?; + if let Some(existing) = - repository_find_user_by_email(conn, change_data.new_email.clone()).await? + repository_find_user_by_email(&txn, change_data.new_email.clone()).await? && existing.id != user_id { return Err(Errors::UserEmailAlreadyExists); } - // 4. 이메일 업데이트 (verified_at도 현재 시간으로 설정 - 이메일 인증 완료로 간주) repository_update_user( - conn, + &txn, user_id, UserUpdateParams { email: Some(change_data.new_email.clone()), @@ -56,10 +46,9 @@ where ) .await?; - info!( - "Email changed successfully for user {} to {}", - user_id, change_data.new_email - ); + txn.commit().await?; + + info!(user_id = %user_id, "Email changed"); Ok(()) } diff --git a/crates/axumkit-server/src/service/auth/forgot_password.rs b/crates/axumkit-server/src/service/auth/forgot_password.rs index c922f2c..28d7611 100644 --- a/crates/axumkit-server/src/service/auth/forgot_password.rs +++ b/crates/axumkit-server/src/service/auth/forgot_password.rs @@ -2,7 +2,7 @@ use crate::bridge::worker_client; use crate::repository::user::repository_find_user_by_email; use crate::state::WorkerClient; use crate::utils::crypto::token::generate_secure_token; -use crate::utils::redis_cache::set_json_with_ttl; +use crate::utils::redis_cache::issue_token_and_store_json_with_ttl; use axumkit_config::ServerConfig; use axumkit_errors::errors::ServiceResult; use redis::aio::ConnectionManager; @@ -10,15 +10,12 @@ use sea_orm::ConnectionTrait; use serde::{Deserialize, Serialize}; use tracing::info; -/// Redis에 저장되는 비밀번호 재설정 토큰 데이터 #[derive(Debug, Serialize, Deserialize)] pub struct PasswordResetData { pub user_id: String, } -/// 비밀번호 재설정 이메일을 발송합니다. /// -/// 보안: 이메일 존재 여부와 관계없이 항상 성공을 반환합니다. pub async fn service_forgot_password( conn: &C, redis_conn: &ConnectionManager, @@ -30,40 +27,35 @@ where { let config = ServerConfig::get(); - // 1. 이메일로 사용자 조회 let user = repository_find_user_by_email(conn, email.to_string()).await?; - // 2. 사용자가 없으면 조용히 반환 (이메일 존재 여부 노출 방지) let user = match user { Some(u) => u, None => { - info!("Password reset requested for non-existent email: {}", email); + info!("Password reset requested for non-existent email"); return Ok(()); } }; - // 3. 비밀번호가 설정되지 않은 사용자는 조용히 반환 if user.password.is_none() { - info!( - "Password reset requested for user without password: {}", - email - ); + info!("Password reset requested for user without password"); return Ok(()); } - // 4. 토큰 생성 - let token = generate_secure_token(); - let token_key = format!("password_reset:{}", token); - let reset_data = PasswordResetData { user_id: user.id.to_string(), }; - // 5. Redis에 토큰 저장 (분 단위 → 초 단위 변환) let ttl_seconds = (config.auth_password_reset_token_expire_time * 60) as u64; - set_json_with_ttl(redis_conn, &token_key, &reset_data, ttl_seconds).await?; + let token = issue_token_and_store_json_with_ttl( + redis_conn, + generate_secure_token, + axumkit_constants::password_reset_key, + &reset_data, + ttl_seconds, + ) + .await?; - // 6. Worker 서비스에 이메일 발송 요청 worker_client::send_password_reset_email( worker, &user.email, @@ -73,7 +65,7 @@ where ) .await?; - info!("Password reset email sent to: {}", email); + info!("Password reset email sent"); Ok(()) } diff --git a/crates/axumkit-server/src/service/auth/login.rs b/crates/axumkit-server/src/service/auth/login.rs index ae89077..b0ba0d3 100644 --- a/crates/axumkit-server/src/service/auth/login.rs +++ b/crates/axumkit-server/src/service/auth/login.rs @@ -3,19 +3,17 @@ use crate::service::auth::session::SessionService; use crate::service::auth::totp::TotpTempToken; use axumkit_dto::auth::request::LoginRequest; use axumkit_errors::errors::{Errors, ServiceResult}; +use tracing::info; use crate::utils::crypto::password::verify_password; use redis::aio::ConnectionManager; use sea_orm::DatabaseConnection; -/// 로그인 결과: 세션 생성 또는 TOTP 필요 pub enum LoginResult { - /// TOTP 없음: 세션 ID 반환 SessionCreated { session_id: String, remember_me: bool, }, - /// TOTP 필요: 임시 토큰 반환 TotpRequired(String), } @@ -26,29 +24,27 @@ pub async fn service_login( user_agent: Option, ip_address: Option, ) -> ServiceResult { - // 사용자 검증 let user = repository_find_user_by_email(conn, payload.email.clone()) .await? - .ok_or(Errors::UserNotFound)?; + .ok_or(Errors::InvalidCredentials)?; - // 비밀번호 검증 - let password_hash = user.password.ok_or(Errors::UserPasswordNotSet)?; - verify_password(&payload.password, &password_hash)?; + let password_hash = user.password.ok_or(Errors::InvalidCredentials)?; + verify_password(&payload.password, &password_hash).map_err(|_| Errors::InvalidCredentials)?; - // TOTP 활성화 확인 if user.totp_enabled_at.is_some() { - // TOTP 필요: 임시 토큰 생성 let temp_token = TotpTempToken::create(redis, user.id, user_agent, ip_address, payload.remember_me) .await?; + info!(user_id = %user.id, "Login requires TOTP"); return Ok(LoginResult::TotpRequired(temp_token.token)); } - // TOTP 없음: 바로 세션 생성 let session = SessionService::create_session(redis, user.id.to_string(), user_agent, ip_address).await?; + info!(user_id = %user.id, "Login successful"); + Ok(LoginResult::SessionCreated { session_id: session.session_id, remember_me: payload.remember_me, diff --git a/crates/axumkit-server/src/service/auth/logout.rs b/crates/axumkit-server/src/service/auth/logout.rs index bc71385..2ac778d 100644 --- a/crates/axumkit-server/src/service/auth/logout.rs +++ b/crates/axumkit-server/src/service/auth/logout.rs @@ -1,10 +1,12 @@ use crate::service::auth::session::SessionService; use axumkit_errors::errors::ServiceResult; use redis::aio::ConnectionManager; +use tracing::info; pub async fn service_logout(redis: &ConnectionManager, session_id: &str) -> ServiceResult<()> { - // 세션 삭제 (delete_session 내부에서 유효성 확인) SessionService::delete_session(redis, session_id).await?; + info!(session_id = %session_id, "Logout"); + Ok(()) } diff --git a/crates/axumkit-server/src/service/auth/lua/reserve_pending_signup.lua b/crates/axumkit-server/src/service/auth/lua/reserve_pending_signup.lua new file mode 100644 index 0000000..1c78585 --- /dev/null +++ b/crates/axumkit-server/src/service/auth/lua/reserve_pending_signup.lua @@ -0,0 +1,29 @@ +-- Atomically reserve a pending email signup. +-- +-- KEYS[1] = email index key (email_signup:email:{email}) +-- KEYS[2] = handle index key (email_signup:handle:{handle}) +-- KEYS[3] = token payload key (email_verification:{token}) +-- +-- ARGV[1] = token value (stored in index keys) +-- ARGV[2] = JSON payload (stored in token key) +-- ARGV[3] = TTL in seconds +-- +-- Returns: +-- 1 = success (all three keys set) +-- -1 = email index already exists +-- -2 = handle index already exists + +if redis.call("EXISTS", KEYS[1]) == 1 then + return -1 +end + +if redis.call("EXISTS", KEYS[2]) == 1 then + return -2 +end + +local ttl = tonumber(ARGV[3]) +redis.call("SET", KEYS[1], ARGV[1], "EX", ttl) +redis.call("SET", KEYS[2], ARGV[1], "EX", ttl) +redis.call("SET", KEYS[3], ARGV[2], "EX", ttl) + +return 1 diff --git a/crates/axumkit-server/src/service/auth/mod.rs b/crates/axumkit-server/src/service/auth/mod.rs index 322a48a..f9ac0c4 100644 --- a/crates/axumkit-server/src/service/auth/mod.rs +++ b/crates/axumkit-server/src/service/auth/mod.rs @@ -8,6 +8,7 @@ pub mod resend_verification_email; pub mod reset_password; pub mod session; pub mod session_types; +pub mod signup; pub mod totp; pub mod verify_email; diff --git a/crates/axumkit-server/src/service/auth/resend_verification_email.rs b/crates/axumkit-server/src/service/auth/resend_verification_email.rs index d8d3b1e..a2857e0 100644 --- a/crates/axumkit-server/src/service/auth/resend_verification_email.rs +++ b/crates/axumkit-server/src/service/auth/resend_verification_email.rs @@ -1,71 +1,45 @@ use crate::bridge::worker_client; -use crate::repository::user::repository_get_user_by_id; -use crate::service::auth::verify_email::EmailVerificationData; +use crate::service::auth::verify_email::{ + find_pending_email_signup_by_email, get_pending_signup_remaining_minutes, +}; use crate::state::WorkerClient; -use crate::utils::crypto::token::generate_secure_token; -use crate::utils::redis_cache::set_json_with_ttl; -use axumkit_config::ServerConfig; -use axumkit_errors::errors::{Errors, ServiceResult}; +use axumkit_errors::errors::ServiceResult; use redis::aio::ConnectionManager; -use sea_orm::ConnectionTrait; -use uuid::Uuid; +use tracing::info; -/// 이메일 인증 메일을 재발송합니다. +/// Resend a verification email for a pending email/password signup. /// -/// # Arguments -/// * `conn` - 데이터베이스 연결 -/// * `redis_conn` - Redis 연결 -/// * `worker` - Worker Redis 연결 -/// * `user_id` - 사용자 ID -/// -/// # Returns -/// * `()` - 성공 시 -pub async fn service_resend_verification_email( - conn: &C, +/// Re-sends the **existing** token with its actual remaining TTL so the email +/// template shows the real validity window. Returns `Ok(())` silently if no +/// pending signup exists (prevents email enumeration). +pub async fn service_resend_verification_email( redis_conn: &ConnectionManager, worker: &WorkerClient, - user_id: Uuid, -) -> ServiceResult<()> -where - C: ConnectionTrait, -{ - let config = ServerConfig::get(); + email: &str, +) -> ServiceResult<()> { + let Some((existing_token, signup_data)) = + find_pending_email_signup_by_email(redis_conn, email).await? + else { + return Ok(()); + }; - // 1. 사용자 조회 - let user = repository_get_user_by_id(conn, user_id).await?; + let remaining_minutes = + get_pending_signup_remaining_minutes(redis_conn, &existing_token).await?; - // 2. 이미 인증된 사용자인지 확인 - if user.verified_at.is_some() { - return Err(Errors::EmailAlreadyVerified); + if remaining_minutes == 0 { + return Ok(()); } - // 3. OAuth 전용 사용자인지 확인 (password가 없으면 OAuth 사용자) - if user.password.is_none() { - return Err(Errors::UserPasswordNotSet); - } - - // 4. 새 토큰 생성 (암호학적으로 안전한 랜덤 토큰) - let token = generate_secure_token(); - let token_key = format!("email_verification:{}", token); - - let verification_data = EmailVerificationData { - user_id: user.id.to_string(), - email: user.email.clone(), - }; - - // 5. Redis에 토큰 저장 (분 단위 → 초 단위 변환) - let ttl_seconds = (config.auth_email_verification_token_expire_time * 60) as u64; - set_json_with_ttl(redis_conn, &token_key, &verification_data, ttl_seconds).await?; - - // 6. Worker 서비스에 이메일 발송 요청 worker_client::send_verification_email( worker, - &user.email, - &user.handle, - &token, - config.auth_email_verification_token_expire_time as u64, + &signup_data.email, + &signup_data.handle, + &existing_token, + remaining_minutes, ) .await?; + info!(email = %signup_data.email, handle = %signup_data.handle, "Pending signup verification email resent"); + Ok(()) } diff --git a/crates/axumkit-server/src/service/auth/reset_password.rs b/crates/axumkit-server/src/service/auth/reset_password.rs index 3d83310..0e37f42 100644 --- a/crates/axumkit-server/src/service/auth/reset_password.rs +++ b/crates/axumkit-server/src/service/auth/reset_password.rs @@ -2,20 +2,15 @@ use crate::repository::user::{UserUpdateParams, repository_update_user}; use crate::service::auth::forgot_password::PasswordResetData; use crate::service::auth::session::SessionService; use crate::utils::crypto::password::hash_password; +use crate::utils::redis_cache::get_json_and_delete; use axumkit_errors::errors::{Errors, ServiceResult}; -use redis::AsyncCommands; use redis::aio::ConnectionManager; use sea_orm::ConnectionTrait; use tracing::info; use uuid::Uuid; -/// 비밀번호를 재설정합니다. /// /// # Arguments -/// * `conn` - 데이터베이스 연결 -/// * `redis_conn` - Redis 연결 -/// * `token` - 비밀번호 재설정 토큰 -/// * `new_password` - 새 비밀번호 pub async fn service_reset_password( conn: &C, redis_conn: &ConnectionManager, @@ -25,27 +20,19 @@ pub async fn service_reset_password( where C: ConnectionTrait, { - // 1. Redis에서 토큰 검증 (get_del로 일회용) - let token_key = format!("password_reset:{}", token); - let mut redis_mut = redis_conn.clone(); - - let token_json: Option = redis_mut - .get_del(&token_key) - .await - .map_err(|e| Errors::SysInternalError(format!("Redis error: {}", e)))?; - - let token_data = token_json.ok_or(Errors::TokenInvalidReset)?; - - let reset_data: PasswordResetData = - serde_json::from_str(&token_data).map_err(|_| Errors::TokenInvalidReset)?; + let token_key = axumkit_constants::password_reset_key(token); + let reset_data: PasswordResetData = get_json_and_delete( + redis_conn, + &token_key, + || Errors::TokenInvalidReset, + |_| Errors::TokenInvalidReset, + ) + .await?; - // 2. user_id 파싱 let user_id = Uuid::parse_str(&reset_data.user_id).map_err(|_| Errors::TokenInvalidReset)?; - // 3. 새 비밀번호 해싱 let password_hash = hash_password(new_password)?; - // 4. 비밀번호 업데이트 repository_update_user( conn, user_id, @@ -56,14 +43,10 @@ where ) .await?; - // 5. 해당 사용자의 모든 세션 무효화 let deleted_count = SessionService::delete_all_user_sessions(redis_conn, &user_id.to_string()).await?; - info!( - "Password reset completed for user {}, {} sessions invalidated", - user_id, deleted_count - ); + info!(user_id = %user_id, invalidated_sessions = deleted_count, "Password reset completed"); Ok(()) } diff --git a/crates/axumkit-server/src/service/auth/session.rs b/crates/axumkit-server/src/service/auth/session.rs index 27a0703..1e2f04e 100644 --- a/crates/axumkit-server/src/service/auth/session.rs +++ b/crates/axumkit-server/src/service/auth/session.rs @@ -116,7 +116,6 @@ impl SessionService { Errors::SysInternalError(format!("Redis session retrieval failed: {}", e)) })?; - // Redis TTL이 만료를 처리하므로 키가 존재하면 유효한 세션 match session_data { Some(data) => { let session: Session = serde_json::from_str(&data).map_err(|e| { @@ -164,7 +163,6 @@ impl SessionService { Ok(()) } - /// 세션 TTL 연장 (최대 수명 체크 포함) pub async fn refresh_session( redis: &RedisClient, session: &Session, @@ -172,12 +170,10 @@ impl SessionService { let config = ServerConfig::get(); let now = Utc::now(); - // 최대 수명 초과 시 연장 불가 if now >= session.max_expires_at { return Ok(None); } - // 새 만료 시간 = min(now + sliding_ttl, max_expires_at) let sliding_expiry = now + chrono::Duration::hours(config.auth_session_sliding_ttl_hours); let new_expires_at = sliding_expiry.min(session.max_expires_at); @@ -211,7 +207,6 @@ impl SessionService { Ok(Some(refreshed_session)) } - /// 조건부 세션 연장 (임계값 체크 + 최대 수명 체크) pub async fn maybe_refresh_session( redis: &RedisClient, session: &Session, @@ -230,7 +225,6 @@ impl SessionService { } } - /// 특정 사용자의 모든 세션 삭제 (비밀번호 재설정 시 사용) pub async fn delete_all_user_sessions( redis: &RedisClient, user_id: &str, @@ -255,7 +249,6 @@ impl SessionService { Ok(count) } - /// 현재 세션을 제외한 모든 세션 삭제 (비밀번호 변경 시 사용) pub async fn delete_other_sessions( redis: &RedisClient, user_id: &str, diff --git a/crates/axumkit-server/src/service/auth/session_types.rs b/crates/axumkit-server/src/service/auth/session_types.rs index 2baeb02..27f619d 100644 --- a/crates/axumkit-server/src/service/auth/session_types.rs +++ b/crates/axumkit-server/src/service/auth/session_types.rs @@ -46,12 +46,10 @@ impl Session { self } - /// 세션을 연장할 수 있는지 확인 (최대 수명 체크) pub fn can_refresh(&self) -> bool { Utc::now() < self.max_expires_at } - /// 세션 연장이 필요한지 확인 (TTL 임계값 체크) pub fn needs_refresh(&self, threshold_percent: u8, sliding_ttl_hours: i64) -> bool { let now = Utc::now(); let remaining = (self.expires_at - now).num_seconds(); diff --git a/crates/axumkit-server/src/service/auth/signup.rs b/crates/axumkit-server/src/service/auth/signup.rs new file mode 100644 index 0000000..39b4c3f --- /dev/null +++ b/crates/axumkit-server/src/service/auth/signup.rs @@ -0,0 +1,80 @@ +use crate::bridge::worker_client; +use crate::repository::user::{repository_find_user_by_email, repository_find_user_by_handle}; +use crate::service::auth::verify_email::{ + PendingEmailSignupData, find_pending_email_signup_by_email, + find_pending_email_signup_by_handle, issue_pending_email_signup_token, +}; +use crate::state::WorkerClient; +use crate::utils::crypto::password::hash_password; +use axumkit_config::ServerConfig; +use axumkit_dto::user::{CreateUserRequest, CreateUserResponse}; +use axumkit_errors::errors::{Errors, ServiceResult}; +use redis::aio::ConnectionManager; +use sea_orm::DatabaseConnection; + +/// Accept a signup request and defer user creation until email verification. +pub async fn service_signup( + db: &DatabaseConnection, + redis_conn: &ConnectionManager, + worker: &WorkerClient, + payload: CreateUserRequest, +) -> ServiceResult { + let config = ServerConfig::get(); + + let existing_user_by_email = repository_find_user_by_email(db, payload.email.clone()).await?; + if existing_user_by_email.is_some() { + return Err(Errors::UserEmailAlreadyExists); + } + + // If a pending signup already exists for this email, return success without + // overwriting. This prevents an attacker from replacing the legitimate + // user's pending payload (password / handle) before verification. + if find_pending_email_signup_by_email(redis_conn, &payload.email) + .await? + .is_some() + { + return Ok(CreateUserResponse { + message: "Verification email sent. Complete signup from the link in your inbox." + .to_string(), + }); + } + + let existing_user_by_handle = + repository_find_user_by_handle(db, payload.handle.clone()).await?; + if existing_user_by_handle.is_some() { + return Err(Errors::UserHandleAlreadyExists); + } + + if find_pending_email_signup_by_handle(redis_conn, &payload.handle) + .await? + .is_some() + { + return Err(Errors::UserHandleAlreadyExists); + } + + let password_hash = hash_password(&payload.password)?; + let verification_data = PendingEmailSignupData { + email: payload.email.clone(), + handle: payload.handle.clone(), + display_name: payload.display_name, + password_hash, + }; + + let ttl_seconds = (config.auth_email_verification_token_expire_time * 60) as u64; + let token = + issue_pending_email_signup_token(redis_conn, &verification_data, ttl_seconds).await?; + + worker_client::send_verification_email( + worker, + &payload.email, + &payload.handle, + &token, + config.auth_email_verification_token_expire_time as u64, + ) + .await?; + + Ok(CreateUserResponse { + message: "Verification email sent. Complete signup from the link in your inbox." + .to_string(), + }) +} diff --git a/crates/axumkit-server/src/service/auth/totp/backup_codes.rs b/crates/axumkit-server/src/service/auth/totp/backup_codes.rs index cd6f32e..ec14de5 100644 --- a/crates/axumkit-server/src/service/auth/totp/backup_codes.rs +++ b/crates/axumkit-server/src/service/auth/totp/backup_codes.rs @@ -5,42 +5,33 @@ use crate::repository::user::{ use crate::utils::crypto::backup_code::hash_backup_codes; use axumkit_dto::auth::response::TotpBackupCodesResponse; use axumkit_errors::errors::{Errors, ServiceResult}; -use sea_orm::ConnectionTrait; +use sea_orm::{DatabaseConnection, TransactionTrait}; use uuid::Uuid; -/// 백업 코드 재생성: 현재 TOTP 코드 검증 후 새 백업 코드 생성 -pub async fn service_regenerate_backup_codes( - conn: &C, +pub async fn service_regenerate_backup_codes( + conn: &DatabaseConnection, user_id: Uuid, - email: &str, code: &str, -) -> ServiceResult -where - C: ConnectionTrait, -{ - // 사용자 조회 - let user = repository_get_user_by_id(conn, user_id).await?; - - // TOTP가 활성화되어 있어야 함 +) -> ServiceResult { + let txn = conn.begin().await?; + + let user = repository_get_user_by_id(&txn, user_id).await?; + if user.totp_enabled_at.is_none() { return Err(Errors::TotpNotEnabled); } let secret_base32 = user.totp_secret.clone().ok_or(Errors::TotpNotEnabled)?; - // TOTP 코드 검증 (백업 코드 재생성은 반드시 TOTP 코드로만) - if !verify_totp_code(&secret_base32, email, code)? { + if !verify_totp_code(&secret_base32, &user.email, code)? { return Err(Errors::TotpInvalidCode); } - // 새 백업 코드 생성 (평문) let backup_codes = generate_backup_codes(); - // 해시하여 DB에 저장 let hashed_codes = hash_backup_codes(&backup_codes); - // DB 업데이트 repository_update_user( - conn, + &txn, user_id, UserUpdateParams { totp_backup_codes: Some(Some(hashed_codes)), @@ -49,6 +40,7 @@ where ) .await?; - // 평문 백업 코드 반환 (사용자가 저장해야 함) + txn.commit().await?; + Ok(TotpBackupCodesResponse { backup_codes }) } diff --git a/crates/axumkit-server/src/service/auth/totp/common.rs b/crates/axumkit-server/src/service/auth/totp/common.rs index 564ad55..33bf09e 100644 --- a/crates/axumkit-server/src/service/auth/totp/common.rs +++ b/crates/axumkit-server/src/service/auth/totp/common.rs @@ -1,5 +1,5 @@ use axumkit_errors::errors::{Errors, ServiceResult}; -use rand::Rng; +use rand::RngExt; use totp_rs::{Algorithm, Secret, TOTP}; pub const ISSUER: &str = "Sevenwiki"; @@ -7,7 +7,6 @@ pub const BACKUP_CODE_COUNT: usize = 10; pub const BACKUP_CODE_LENGTH: usize = 8; const BACKUP_CODE_CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; -/// TOTP 코드 검증 pub fn verify_totp_code(secret_base32: &str, email: &str, code: &str) -> ServiceResult { let secret = Secret::Encoded(secret_base32.to_string()) .to_bytes() @@ -27,7 +26,6 @@ pub fn verify_totp_code(secret_base32: &str, email: &str, code: &str) -> Service Ok(totp.check_current(code).unwrap_or(false)) } -/// 백업 코드 생성 (10개, 8자리 영숫자) pub fn generate_backup_codes() -> Vec { let mut rng = rand::rng(); (0..BACKUP_CODE_COUNT) diff --git a/crates/axumkit-server/src/service/auth/totp/disable.rs b/crates/axumkit-server/src/service/auth/totp/disable.rs index 7105604..bd39b05 100644 --- a/crates/axumkit-server/src/service/auth/totp/disable.rs +++ b/crates/axumkit-server/src/service/auth/totp/disable.rs @@ -4,23 +4,19 @@ use crate::repository::user::{ }; use crate::utils::crypto::backup_code::verify_backup_code; use axumkit_errors::errors::{Errors, ServiceResult}; -use sea_orm::ConnectionTrait; +use sea_orm::{DatabaseConnection, TransactionTrait}; +use tracing::info; use uuid::Uuid; -/// TOTP 비활성화: 현재 코드 검증 후 모든 TOTP 필드 초기화 -pub async fn service_totp_disable( - conn: &C, +pub async fn service_totp_disable( + conn: &DatabaseConnection, user_id: Uuid, - email: &str, code: &str, -) -> ServiceResult<()> -where - C: ConnectionTrait, -{ - // 사용자 조회 - let user = repository_get_user_by_id(conn, user_id).await?; - - // TOTP가 활성화되어 있어야 함 +) -> ServiceResult<()> { + let txn = conn.begin().await?; + + let user = repository_get_user_by_id(&txn, user_id).await?; + if user.totp_enabled_at.is_none() { return Err(Errors::TotpNotEnabled); } @@ -28,13 +24,11 @@ where let secret_base32 = user.totp_secret.clone().ok_or(Errors::TotpNotEnabled)?; let backup_codes = user.totp_backup_codes.clone().unwrap_or_default(); - // 코드 검증 (TOTP 6자리 또는 백업 코드 8자리) if code.len() == 6 { - if !verify_totp_code(&secret_base32, email, code)? { + if !verify_totp_code(&secret_base32, &user.email, code)? { return Err(Errors::TotpInvalidCode); } } else if code.len() == 8 { - // 해시 비교로 백업 코드 검증 if verify_backup_code(code, &backup_codes).is_none() { return Err(Errors::TotpInvalidCode); } @@ -42,9 +36,8 @@ where return Err(Errors::TotpInvalidCode); } - // TOTP 비활성화 (모든 필드 초기화) repository_update_user( - conn, + &txn, user_id, UserUpdateParams { totp_secret: Some(None), @@ -55,5 +48,9 @@ where ) .await?; + txn.commit().await?; + + info!(user_id = %user_id, "TOTP disabled"); + Ok(()) } diff --git a/crates/axumkit-server/src/service/auth/totp/enable.rs b/crates/axumkit-server/src/service/auth/totp/enable.rs index c1ae103..047e11a 100644 --- a/crates/axumkit-server/src/service/auth/totp/enable.rs +++ b/crates/axumkit-server/src/service/auth/totp/enable.rs @@ -6,43 +6,34 @@ use crate::utils::crypto::backup_code::hash_backup_codes; use axumkit_dto::auth::response::TotpEnableResponse; use axumkit_errors::errors::{Errors, ServiceResult}; use chrono::Utc; -use sea_orm::ConnectionTrait; +use sea_orm::{DatabaseConnection, TransactionTrait}; +use tracing::info; use uuid::Uuid; -/// TOTP 활성화: 첫 코드 검증 후 활성화 + 백업 코드 생성 -pub async fn service_totp_enable( - conn: &C, +pub async fn service_totp_enable( + conn: &DatabaseConnection, user_id: Uuid, - email: &str, code: &str, -) -> ServiceResult -where - C: ConnectionTrait, -{ - // 사용자 조회 - let user = repository_get_user_by_id(conn, user_id).await?; - - // 이미 TOTP 활성화된 경우 +) -> ServiceResult { + let txn = conn.begin().await?; + + let user = repository_get_user_by_id(&txn, user_id).await?; + if user.totp_enabled_at.is_some() { return Err(Errors::TotpAlreadyEnabled); } - // Secret이 없는 경우 (setup 안 함) let secret_base32 = user.totp_secret.clone().ok_or(Errors::TotpNotEnabled)?; - // TOTP 검증 - if !verify_totp_code(&secret_base32, email, code)? { + if !verify_totp_code(&secret_base32, &user.email, code)? { return Err(Errors::TotpInvalidCode); } - // 백업 코드 생성 (평문) let backup_codes = generate_backup_codes(); - // 해시하여 DB에 저장 (평문은 사용자에게만 반환) let hashed_codes = hash_backup_codes(&backup_codes); - // DB 업데이트: totp_enabled_at 설정 + 해시된 백업 코드 저장 repository_update_user( - conn, + &txn, user_id, UserUpdateParams { totp_enabled_at: Some(Some(Utc::now())), @@ -52,6 +43,9 @@ where ) .await?; - // 평문 백업 코드 반환 (사용자가 저장해야 함) + txn.commit().await?; + + info!(user_id = %user_id, "TOTP enabled"); + Ok(TotpEnableResponse { backup_codes }) } diff --git a/crates/axumkit-server/src/service/auth/totp/setup.rs b/crates/axumkit-server/src/service/auth/totp/setup.rs index 7d98a66..95f84c7 100644 --- a/crates/axumkit-server/src/service/auth/totp/setup.rs +++ b/crates/axumkit-server/src/service/auth/totp/setup.rs @@ -4,29 +4,24 @@ use crate::repository::user::{ }; use axumkit_dto::auth::response::TotpSetupResponse; use axumkit_errors::errors::{Errors, ServiceResult}; -use rand::Rng; -use sea_orm::ConnectionTrait; +use rand::RngExt; +use sea_orm::{DatabaseConnection, TransactionTrait}; use totp_rs::{Algorithm, Secret, TOTP}; +use tracing::info; use uuid::Uuid; -/// TOTP 설정 시작: secret 생성, DB 저장 (아직 활성화 안 함), QR 반환 -pub async fn service_totp_setup( - conn: &C, +pub async fn service_totp_setup( + conn: &DatabaseConnection, user_id: Uuid, - email: &str, -) -> ServiceResult -where - C: ConnectionTrait, -{ - // 사용자 조회 - let user = repository_get_user_by_id(conn, user_id).await?; +) -> ServiceResult { + let txn = conn.begin().await?; + + let user = repository_get_user_by_id(&txn, user_id).await?; - // 이미 TOTP 활성화된 경우 if user.totp_enabled_at.is_some() { return Err(Errors::TotpAlreadyEnabled); } - // Secret 생성 (20 bytes = 160 bits, RFC 4226 권장) let (secret_bytes, secret_base32) = { let mut rng = rand::rng(); let bytes: [u8; 20] = rng.random(); @@ -34,7 +29,6 @@ where (bytes, secret.to_encoded().to_string()) }; - // TOTP 객체 생성 let totp = TOTP::new( Algorithm::SHA1, 6, // digits @@ -42,19 +36,17 @@ where 30, // step secret_bytes.to_vec(), Some(ISSUER.to_string()), - email.to_string(), + user.email, ) .map_err(|_| Errors::TotpSecretGenerationFailed)?; - // QR 코드 생성 (PNG base64) let qr_code_uri = totp.get_url(); let qr_code_png_base64 = totp .get_qr_base64() .map_err(|_| Errors::TotpQrGenerationFailed)?; - // DB에 secret 저장 (totp_enabled_at은 아직 NULL) repository_update_user( - conn, + &txn, user_id, UserUpdateParams { totp_secret: Some(Some(secret_base32)), @@ -63,6 +55,10 @@ where ) .await?; + txn.commit().await?; + + info!(user_id = %user_id, "TOTP setup initiated"); + Ok(TotpSetupResponse { qr_code_base64: qr_code_png_base64, qr_code_uri, diff --git a/crates/axumkit-server/src/service/auth/totp/status.rs b/crates/axumkit-server/src/service/auth/totp/status.rs index c22e72b..6e568e0 100644 --- a/crates/axumkit-server/src/service/auth/totp/status.rs +++ b/crates/axumkit-server/src/service/auth/totp/status.rs @@ -4,12 +4,10 @@ use axumkit_errors::errors::ServiceResult; use sea_orm::ConnectionTrait; use uuid::Uuid; -/// TOTP 상태 조회 pub async fn service_totp_status(conn: &C, user_id: Uuid) -> ServiceResult where C: ConnectionTrait, { - // 사용자 조회 let user = repository_get_user_by_id(conn, user_id).await?; let enabled = user.totp_enabled_at.is_some(); diff --git a/crates/axumkit-server/src/service/auth/totp/temp_token.rs b/crates/axumkit-server/src/service/auth/totp/temp_token.rs index 7e92d26..1a6ee50 100644 --- a/crates/axumkit-server/src/service/auth/totp/temp_token.rs +++ b/crates/axumkit-server/src/service/auth/totp/temp_token.rs @@ -1,15 +1,13 @@ -use crate::utils::redis_cache::set_json_with_ttl; +use crate::utils::redis_cache::{get_optional_json_and_delete, set_json_with_ttl}; use axumkit_errors::errors::Errors; use chrono::{DateTime, Utc}; -use rand::RngCore; -use redis::AsyncCommands; +use rand::Rng; use redis::aio::ConnectionManager as RedisClient; use serde::{Deserialize, Serialize}; use uuid::Uuid; -const TEMP_TOKEN_TTL_SECONDS: u64 = 120; // 2분 +const TEMP_TOKEN_TTL_SECONDS: u64 = 120; // 2 minutes -/// TOTP 검증용 임시 토큰 (Redis 저장용) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TotpTempToken { pub token: String, @@ -27,7 +25,6 @@ impl TotpTempToken { ip_address: Option, remember_me: bool, ) -> Self { - // 암호학적으로 안전한 랜덤 토큰 생성 (32 bytes = 256 bits) let mut bytes = [0u8; 32]; rand::rng().fill_bytes(&mut bytes); let token = hex::encode(bytes); @@ -46,7 +43,6 @@ impl TotpTempToken { format!("totp_temp:{}", self.token) } - /// 임시 토큰 생성 및 Redis 저장 pub async fn create( redis: &RedisClient, user_id: Uuid, @@ -67,27 +63,12 @@ impl TotpTempToken { Ok(temp_token) } - /// 임시 토큰 조회 및 삭제 (일회용) pub async fn get_and_delete(redis: &RedisClient, token: &str) -> Result, Errors> { - let mut conn = redis.clone(); let key = format!("totp_temp:{}", token); - // GETDEL: 조회 + 삭제 원자적 수행 - let data: Option = conn.get_del(&key).await.map_err(|e| { - Errors::SysInternalError(format!("Redis TOTP temp token retrieval failed: {}", e)) - })?; - - match data { - Some(json) => { - let temp_token: Self = serde_json::from_str(&json).map_err(|e| { - Errors::SysInternalError(format!( - "TOTP temp token deserialization failed: {}", - e - )) - })?; - Ok(Some(temp_token)) - } - None => Ok(None), - } + get_optional_json_and_delete(redis, &key, |e| { + Errors::SysInternalError(format!("TOTP temp token deserialization failed: {}", e)) + }) + .await } } diff --git a/crates/axumkit-server/src/service/auth/totp/verify.rs b/crates/axumkit-server/src/service/auth/totp/verify.rs index bf7a2cf..8f316c0 100644 --- a/crates/axumkit-server/src/service/auth/totp/verify.rs +++ b/crates/axumkit-server/src/service/auth/totp/verify.rs @@ -7,33 +7,28 @@ use crate::service::auth::totp::TotpTempToken; use crate::utils::crypto::backup_code::verify_backup_code; use axumkit_errors::errors::{Errors, ServiceResult}; use redis::aio::ConnectionManager as RedisClient; -use sea_orm::ConnectionTrait; +use sea_orm::{DatabaseConnection, TransactionTrait}; +use tracing::info; -/// TOTP 검증 결과 pub struct TotpVerifyResult { pub session_id: String, pub remember_me: bool, } -/// TOTP 검증 (로그인 2단계): temp_token + code → session 생성 -pub async fn service_totp_verify( - conn: &C, +pub async fn service_totp_verify( + conn: &DatabaseConnection, redis: &RedisClient, temp_token: &str, code: &str, -) -> ServiceResult -where - C: ConnectionTrait, -{ - // 임시 토큰 조회 및 삭제 (일회용) +) -> ServiceResult { let token_data = TotpTempToken::get_and_delete(redis, temp_token) .await? .ok_or(Errors::TotpTempTokenInvalid)?; - // 사용자 조회 - let user = repository_get_user_by_id(conn, token_data.user_id).await?; + let txn = conn.begin().await?; + + let user = repository_get_user_by_id(&txn, token_data.user_id).await?; - // TOTP가 활성화되어 있어야 함 if user.totp_enabled_at.is_none() { return Err(Errors::TotpNotEnabled); } @@ -41,26 +36,21 @@ where let secret_base32 = user.totp_secret.clone().ok_or(Errors::TotpNotEnabled)?; let backup_codes = user.totp_backup_codes.clone().unwrap_or_default(); - // 코드 길이로 TOTP vs 백업 코드 구분 if code.len() == 6 { - // TOTP 코드 검증 if !verify_totp_code(&secret_base32, &user.email, code)? { return Err(Errors::TotpInvalidCode); } } else if code.len() == 8 { - // 백업 코드 검증 if backup_codes.is_empty() { return Err(Errors::TotpBackupCodeExhausted); } - // 해시 비교로 백업 코드 검증 if let Some(idx) = verify_backup_code(code, &backup_codes) { - // 사용된 백업 코드 제거 let mut new_codes = backup_codes.clone(); new_codes.remove(idx); repository_update_user( - conn, + &txn, token_data.user_id, UserUpdateParams { totp_backup_codes: Some(Some(new_codes)), @@ -75,7 +65,8 @@ where return Err(Errors::TotpInvalidCode); } - // 세션 생성 + txn.commit().await?; + let session = SessionService::create_session( redis, token_data.user_id.to_string(), @@ -84,6 +75,8 @@ where ) .await?; + info!(user_id = %token_data.user_id, "TOTP verified"); + Ok(TotpVerifyResult { session_id: session.session_id, remember_me: token_data.remember_me, diff --git a/crates/axumkit-server/src/service/auth/verify_email.rs b/crates/axumkit-server/src/service/auth/verify_email.rs index 9f8cce1..f089271 100644 --- a/crates/axumkit-server/src/service/auth/verify_email.rs +++ b/crates/axumkit-server/src/service/auth/verify_email.rs @@ -1,78 +1,204 @@ use crate::repository::user::{ - UserUpdateParams, repository_get_user_by_id, repository_update_user, + repository_create_user_with_password_hash, repository_find_user_by_email, + repository_find_user_by_handle, }; +use crate::utils::crypto::token::generate_secure_token; +use crate::utils::redis_cache::{delete_key, get_json, get_ttl_seconds}; use axumkit_errors::errors::{Errors, ServiceResult}; -use chrono::Utc; -use redis::AsyncCommands; use redis::aio::ConnectionManager; -use sea_orm::ConnectionTrait; +use sea_orm::{DatabaseConnection, TransactionTrait}; use serde::{Deserialize, Serialize}; +use std::sync::LazyLock; +use tracing::info; use uuid::Uuid; -#[derive(Debug, Serialize, Deserialize)] -pub struct EmailVerificationData { - pub user_id: String, +static RESERVE_PENDING_SIGNUP_SCRIPT: LazyLock = + LazyLock::new(|| redis::Script::new(include_str!("lua/reserve_pending_signup.lua"))); + +#[derive(Debug, Clone, Serialize, Deserialize)] +/// Pending email/password signup payload stored in Redis until verification. +pub struct PendingEmailSignupData { pub email: String, + pub handle: String, + pub display_name: String, + pub password_hash: String, } -/// 이메일 인증을 처리합니다. -/// -/// # Arguments -/// * `conn` - 데이터베이스 연결 -/// * `redis_conn` - Redis 연결 -/// * `token` - 이메일 인증 토큰 -/// -/// # Returns -/// * `()` - 성공 시 -pub async fn service_verify_email( - conn: &C, +/// Issue a new pending email signup token, reserving email index, handle index, +/// and token payload **atomically** via a Lua script. +/// Returns `Err` if either index key already exists. +pub async fn issue_pending_email_signup_token( redis_conn: &ConnectionManager, - token: &str, -) -> ServiceResult<()> -where - C: ConnectionTrait, -{ - // 1. Redis에서 토큰 검증 (get_del로 일회용) - let token_key = format!("email_verification:{}", token); - let mut redis_mut = redis_conn.clone(); - - let token_json: Option = redis_mut - .get_del(&token_key) + signup_data: &PendingEmailSignupData, + ttl_seconds: u64, +) -> ServiceResult { + let token = generate_secure_token(); + + let email_key = axumkit_constants::email_signup_email_key(&signup_data.email); + let handle_key = axumkit_constants::email_signup_handle_key(&signup_data.handle); + let token_key = axumkit_constants::email_verification_key(&token); + + let token_json = serde_json::to_string(&token).map_err(|e| { + Errors::SysInternalError(format!("JSON serialization failed for token index: {}", e)) + })?; + + let payload_json = serde_json::to_string(signup_data).map_err(|e| { + Errors::SysInternalError(format!( + "JSON serialization failed for signup payload: {}", + e + )) + })?; + + let mut conn = redis_conn.clone(); + let result: i64 = RESERVE_PENDING_SIGNUP_SCRIPT + .key(&email_key) + .key(&handle_key) + .key(&token_key) + .arg(&token_json) + .arg(&payload_json) + .arg(ttl_seconds) + .invoke_async(&mut conn) .await - .map_err(|e| Errors::SysInternalError(format!("Redis error: {}", e)))?; + .map_err(|e| { + Errors::SysInternalError(format!("Redis reserve_pending_signup script failed: {}", e)) + })?; - let token_data = token_json.ok_or(Errors::TokenInvalidVerification)?; + match result { + 1 => Ok(token), + -1 => Err(Errors::UserEmailAlreadyExists), + -2 => Err(Errors::UserHandleAlreadyExists), + other => Err(Errors::SysInternalError(format!( + "Unexpected reserve_pending_signup result: {}", + other + ))), + } +} - let verification_data: EmailVerificationData = - serde_json::from_str(&token_data).map_err(|_| Errors::TokenInvalidVerification)?; +pub async fn find_pending_email_signup_by_email( + redis_conn: &ConnectionManager, + email: &str, +) -> ServiceResult> { + find_pending_email_signup_by_index( + redis_conn, + &axumkit_constants::email_signup_email_key(email), + ) + .await +} - // 2. user_id 파싱 - let user_id = Uuid::parse_str(&verification_data.user_id) - .map_err(|_| Errors::TokenInvalidVerification)?; +pub async fn find_pending_email_signup_by_handle( + redis_conn: &ConnectionManager, + handle: &str, +) -> ServiceResult> { + find_pending_email_signup_by_index( + redis_conn, + &axumkit_constants::email_signup_handle_key(handle), + ) + .await +} - // 3. 사용자 조회 - let user = repository_get_user_by_id(conn, user_id).await?; +pub async fn delete_pending_email_signup_indices( + redis_conn: &ConnectionManager, + signup_data: &PendingEmailSignupData, +) -> ServiceResult<()> { + delete_key( + redis_conn, + &axumkit_constants::email_signup_email_key(&signup_data.email), + ) + .await?; + delete_key( + redis_conn, + &axumkit_constants::email_signup_handle_key(&signup_data.handle), + ) + .await?; - // 4. 이미 인증된 사용자인지 확인 - if user.verified_at.is_some() { - return Err(Errors::EmailAlreadyVerified); + Ok(()) +} + +/// Get the remaining TTL (in minutes, rounded up) of a pending signup token. +pub async fn get_pending_signup_remaining_minutes( + redis_conn: &ConnectionManager, + token: &str, +) -> ServiceResult { + let key = axumkit_constants::email_verification_key(token); + match get_ttl_seconds(redis_conn, &key).await? { + Some(secs) => Ok(secs.div_ceil(60)), + None => Ok(0), + } +} + +async fn find_pending_email_signup_by_index( + redis_conn: &ConnectionManager, + index_key: &str, +) -> ServiceResult> { + let Some(token) = get_json::(redis_conn, index_key).await? else { + return Ok(None); + }; + + let verification_key = axumkit_constants::email_verification_key(&token); + let Some(signup_data) = + get_json::(redis_conn, &verification_key).await? + else { + return Ok(None); + }; + + Ok(Some((token, signup_data))) +} + +/// Verify a pending signup email token and create the user account. +pub async fn service_verify_email( + db: &DatabaseConnection, + redis_conn: &ConnectionManager, + token: &str, +) -> ServiceResult { + let token_key = axumkit_constants::email_verification_key(token); + + let signup_data: PendingEmailSignupData = get_json(redis_conn, &token_key) + .await? + .ok_or(Errors::TokenInvalidVerification)?; + + let user_id = complete_pending_email_signup(db, signup_data.clone()).await?; + + // DB commit succeeded — now clean up Redis (best-effort). + delete_key(redis_conn, &token_key).await.ok(); + delete_pending_email_signup_indices(redis_conn, &signup_data) + .await + .ok(); + + Ok(user_id) +} + +async fn complete_pending_email_signup( + db: &DatabaseConnection, + signup_data: PendingEmailSignupData, +) -> ServiceResult { + let txn = db.begin().await?; + + if repository_find_user_by_email(&txn, signup_data.email.clone()) + .await? + .is_some() + { + return Err(Errors::UserEmailAlreadyExists); } - // 5. 이메일 주소 일치 확인 - if user.email != verification_data.email { - return Err(Errors::TokenEmailMismatch); + if repository_find_user_by_handle(&txn, signup_data.handle.clone()) + .await? + .is_some() + { + return Err(Errors::UserHandleAlreadyExists); } - // 6. verified_at 업데이트 - repository_update_user( - conn, - user_id, - UserUpdateParams { - verified_at: Some(Some(Utc::now())), - ..Default::default() - }, + let user = repository_create_user_with_password_hash( + &txn, + signup_data.email, + signup_data.handle, + signup_data.display_name, + signup_data.password_hash, ) .await?; - Ok(()) + txn.commit().await?; + + info!(user_id = %user.id, handle = %user.handle, "Pending signup completed"); + + Ok(user.id) } diff --git a/crates/axumkit-server/src/service/mod.rs b/crates/axumkit-server/src/service/mod.rs index 04ae6ef..6e71624 100644 --- a/crates/axumkit-server/src/service/mod.rs +++ b/crates/axumkit-server/src/service/mod.rs @@ -1,7 +1,7 @@ pub mod action_logs; pub mod auth; pub mod eventstream; +pub mod moderation; pub mod oauth; -pub mod posts; pub mod search; pub mod user; diff --git a/crates/axumkit-server/src/service/moderation/list_moderation_logs.rs b/crates/axumkit-server/src/service/moderation/list_moderation_logs.rs new file mode 100644 index 0000000..98d613a --- /dev/null +++ b/crates/axumkit-server/src/service/moderation/list_moderation_logs.rs @@ -0,0 +1,63 @@ +use crate::repository::moderation::{ + ModerationLogFilter, repository_exists_newer_moderation_log, + repository_exists_older_moderation_log, repository_find_moderation_logs, +}; +use axumkit_dto::moderation::{ + ListModerationLogsRequest, ListModerationLogsResponse, ModerationLogListItem, +}; +use axumkit_dto::pagination::CursorDirection; +use axumkit_errors::errors::ServiceResult; +use sea_orm::DatabaseConnection; + +pub async fn service_list_moderation_logs( + conn: &DatabaseConnection, + payload: ListModerationLogsRequest, +) -> ServiceResult { + let limit = payload.limit; + let is_newer = payload.cursor_direction == Some(CursorDirection::Newer); + + let filter = ModerationLogFilter { + actor_id: payload.actor_id, + resource_type: payload.resource_type, + resource_id: payload.resource_id, + actions: payload.actions, + }; + + let mut logs = repository_find_moderation_logs( + conn, + &filter, + payload.cursor_id, + payload.cursor_direction, + limit, + ) + .await?; + + let (has_newer, has_older) = if logs.is_empty() { + (false, false) + } else { + let first_id = logs.first().unwrap().id; + let last_id = logs.last().unwrap().id; + if is_newer { + let has_newer = repository_exists_newer_moderation_log(conn, &filter, last_id).await?; + let has_older = repository_exists_older_moderation_log(conn, &filter, first_id).await?; + (has_newer, has_older) + } else { + let has_newer = repository_exists_newer_moderation_log(conn, &filter, first_id).await?; + let has_older = repository_exists_older_moderation_log(conn, &filter, last_id).await?; + (has_newer, has_older) + } + }; + + if is_newer { + logs.reverse(); + } + + let data: Vec = + logs.into_iter().map(ModerationLogListItem::from).collect(); + + Ok(ListModerationLogsResponse { + data, + has_newer, + has_older, + }) +} diff --git a/crates/axumkit-server/src/service/moderation/mod.rs b/crates/axumkit-server/src/service/moderation/mod.rs new file mode 100644 index 0000000..8da2cb7 --- /dev/null +++ b/crates/axumkit-server/src/service/moderation/mod.rs @@ -0,0 +1,3 @@ +mod list_moderation_logs; + +pub use list_moderation_logs::service_list_moderation_logs; diff --git a/crates/axumkit-server/src/service/oauth/complete_signup.rs b/crates/axumkit-server/src/service/oauth/complete_signup.rs index 85345cc..428be36 100644 --- a/crates/axumkit-server/src/service/oauth/complete_signup.rs +++ b/crates/axumkit-server/src/service/oauth/complete_signup.rs @@ -6,6 +6,9 @@ use crate::repository::oauth::find_user_by_oauth::repository_find_user_by_oauth; use crate::repository::user::find_by_email::repository_find_user_by_email; use crate::repository::user::find_by_handle::repository_find_user_by_handle; use crate::service::auth::session::SessionService; +use crate::service::auth::verify_email::{ + find_pending_email_signup_by_email, find_pending_email_signup_by_handle, +}; use crate::service::oauth::download_profile_image::download_and_upload_profile_image; use crate::service::oauth::types::PendingSignupData; use crate::state::WorkerClient; @@ -33,6 +36,7 @@ pub async fn service_complete_signup( worker: &WorkerClient, pending_token: &str, handle: &str, + display_name: &str, anonymous_user_id: &str, user_agent: Option, ip_address: Option, @@ -89,6 +93,14 @@ where return Err(Errors::OauthEmailAlreadyExists); } + // Check if a pending email/password signup holds this email + if find_pending_email_signup_by_email(redis_conn, &email) + .await? + .is_some() + { + return Err(Errors::OauthEmailAlreadyExists); + } + if repository_find_user_by_handle(conn, handle.to_string()) .await? .is_some() @@ -96,6 +108,14 @@ where return Err(Errors::UserHandleAlreadyExists); } + // Check if a pending email/password signup holds this handle + if find_pending_email_signup_by_handle(redis_conn, handle) + .await? + .is_some() + { + return Err(Errors::UserHandleAlreadyExists); + } + // 4. External I/O stays outside transaction. let profile_image_key = match pending_data.profile_image { Some(ref url) => download_and_upload_profile_image(http_client, r2_assets, url).await, @@ -130,7 +150,7 @@ where let new_user = repository_create_oauth_user( &txn, &pending_data.email, - &pending_data.display_name, + display_name, handle, profile_image_key.clone(), ) diff --git a/crates/axumkit-server/src/service/oauth/download_profile_image.rs b/crates/axumkit-server/src/service/oauth/download_profile_image.rs index 7858c5a..48a3721 100644 --- a/crates/axumkit-server/src/service/oauth/download_profile_image.rs +++ b/crates/axumkit-server/src/service/oauth/download_profile_image.rs @@ -6,15 +6,15 @@ use axumkit_constants::{PROFILE_IMAGE_MAX_SIZE, user_image_key}; use reqwest::Client as HttpClient; use tracing::{error, warn}; -/// OAuth 프로필 이미지를 다운로드하여 R2에 업로드하고 storage key를 반환합니다. +/// Downloads an OAuth profile image, uploads it to R2, and returns the storage key. /// -/// 실패 시 None을 반환합니다 (프로필 이미지 없이 가입 진행). +/// Returns None on failure (signup proceeds without profile image). pub async fn download_and_upload_profile_image( http_client: &HttpClient, r2_assets: &R2Client, image_url: &str, ) -> Option { - // 1. 이미지 다운로드 + // 1. Download image let response = match http_client.get(image_url).send().await { Ok(resp) => resp, Err(e) => { @@ -36,7 +36,7 @@ pub async fn download_and_upload_profile_image( } }; - // 2. 이미지 검증 + // 2. Validate image let image_info = match validate_image(&image_bytes, PROFILE_IMAGE_MAX_SIZE) { Ok(info) => info, Err(e) => { @@ -45,7 +45,7 @@ pub async fn download_and_upload_profile_image( } }; - // 3. 이미지 처리 (WebP 변환, 리사이즈) + // 3. Process image (WebP conversion, resize) let processed = match process_image_for_upload(&image_bytes, &image_info.mime_type) { Ok(p) => p, Err(e) => { @@ -54,7 +54,7 @@ pub async fn download_and_upload_profile_image( } }; - // 4. R2 업로드 + // 4. Upload to R2 let hash = generate_image_hash(&processed.data); let storage_key = user_image_key(&hash, &processed.extension); diff --git a/crates/axumkit-server/src/service/oauth/find_or_create_oauth_user.rs b/crates/axumkit-server/src/service/oauth/find_or_create_oauth_user.rs index fcd643b..3f4b650 100644 --- a/crates/axumkit-server/src/service/oauth/find_or_create_oauth_user.rs +++ b/crates/axumkit-server/src/service/oauth/find_or_create_oauth_user.rs @@ -3,27 +3,32 @@ use crate::repository::oauth::create_oauth_user::repository_create_oauth_user; use crate::repository::oauth::find_user_by_oauth::repository_find_user_by_oauth; use crate::repository::user::find_by_email::repository_find_user_by_email; use crate::repository::user::find_by_handle::repository_find_user_by_handle; +use crate::service::auth::verify_email::{ + find_pending_email_signup_by_email, find_pending_email_signup_by_handle, +}; use crate::service::oauth::types::OAuthUserResult; use axumkit_entity::common::OAuthProvider; use axumkit_errors::errors::{Errors, ServiceResult}; +use redis::aio::ConnectionManager; use sea_orm::ConnectionTrait; use tracing::info; -/// OAuth 제공자로부터 받은 정보로 사용자를 찾거나 생성합니다. +/// Finds or creates a user using information from an OAuth provider. /// /// # Arguments -/// * `conn` - 데이터베이스 연결 (트랜잭션 내부에서 호출되어야 함) -/// * `provider` - OAuth 제공자 (Google, GitHub 등) -/// * `provider_user_id` - OAuth 제공자에서의 사용자 ID -/// * `email` - 사용자 이메일 -/// * `display_name` - 사용자 표시 이름 -/// * `handle` - 사용자 핸들 (신규 사용자 생성 시 필수) -/// * `profile_image` - 프로필 이미지 URL (선택사항) +/// * `conn` - Database connection (must be called within a transaction) +/// * `provider` - OAuth provider (Google, GitHub, etc.) +/// * `provider_user_id` - User ID from the OAuth provider +/// * `email` - User email +/// * `display_name` - User display name +/// * `handle` - User handle (required for new user creation) +/// * `profile_image` - Profile image URL (optional) /// /// # Returns -/// * `OAuthUserResult` - 사용자 모델과 신규 사용자 여부 +/// * `OAuthUserResult` - User model and whether the user is new pub async fn service_find_or_create_oauth_user( conn: &C, + redis_conn: &ConnectionManager, provider: OAuthProvider, provider_user_id: &str, email: &str, @@ -34,7 +39,7 @@ pub async fn service_find_or_create_oauth_user( where C: ConnectionTrait, { - // 1. 기존 OAuth 연결이 있는지 확인 + // 1. Check if an existing OAuth connection exists if let Some(user) = repository_find_user_by_oauth(conn, provider.clone(), provider_user_id).await? { @@ -44,7 +49,7 @@ where }); } - // 2. 같은 이메일의 기존 계정이 있는지 확인 (보안: 자동 연결 방지) + // 2. Check if an existing account with the same email exists (security: prevent automatic linking) if repository_find_user_by_email(conn, email.to_string()) .await? .is_some() @@ -52,10 +57,18 @@ where return Err(Errors::OauthEmailAlreadyExists); } - // 3. 신규 사용자 - handle 필수 + // 2b. Check if a pending email/password signup holds this email + if find_pending_email_signup_by_email(redis_conn, email) + .await? + .is_some() + { + return Err(Errors::OauthEmailAlreadyExists); + } + + // 3. New user - handle required let handle = handle.ok_or(Errors::OauthHandleRequired)?; - // 3. handle 중복 확인 + // 3. Check handle uniqueness if repository_find_user_by_handle(conn, handle.to_string()) .await? .is_some() @@ -63,11 +76,19 @@ where return Err(Errors::UserHandleAlreadyExists); } - // 4. 새 사용자 생성 + // 3b. Check if a pending email/password signup holds this handle + if find_pending_email_signup_by_handle(redis_conn, handle) + .await? + .is_some() + { + return Err(Errors::UserHandleAlreadyExists); + } + + // 4. Create new user let new_user = repository_create_oauth_user(conn, email, display_name, handle, profile_image).await?; - // 5. OAuth 연결 생성 + // 5. Create OAuth connection repository_create_oauth_connection(conn, &new_user.id, provider.clone(), provider_user_id) .await?; diff --git a/crates/axumkit-server/src/service/oauth/generate_oauth_url.rs b/crates/axumkit-server/src/service/oauth/generate_oauth_url.rs index 55ab629..cd6a422 100644 --- a/crates/axumkit-server/src/service/oauth/generate_oauth_url.rs +++ b/crates/axumkit-server/src/service/oauth/generate_oauth_url.rs @@ -9,20 +9,20 @@ use axumkit_errors::errors::ServiceResult; use redis::aio::ConnectionManager; use uuid::Uuid; -/// OAuth 인증 URL을 생성하고 state를 Redis에 저장합니다. +/// Generates an OAuth authorization URL and stores the state in Redis. pub async fn service_generate_oauth_url( redis_conn: &ConnectionManager, anonymous_user_id: &str, flow: OAuthAuthorizeFlow, provider: OAuthProvider, ) -> ServiceResult { - // 1. State 생성 + // 1. Generate state let state = Uuid::now_v7().to_string(); - // 2. OAuth 인증 URL 생성 (PKCE 포함) + // 2. Generate OAuth authorization URL (with PKCE) let (auth_url, _state, pkce_verifier) = generate_auth_url::

(state.clone())?; - // 3. State와 PKCE verifier를 Redis에 저장 + // 3. Store state and PKCE verifier in Redis let state_data = OAuthStateData { pkce_verifier, flow, diff --git a/crates/axumkit-server/src/service/oauth/github/client.rs b/crates/axumkit-server/src/service/oauth/github/client.rs index 6adb25a..6912ebb 100644 --- a/crates/axumkit-server/src/service/oauth/github/client.rs +++ b/crates/axumkit-server/src/service/oauth/github/client.rs @@ -5,7 +5,7 @@ use reqwest::Client as HttpClient; const GITHUB_USER_INFO_URL: &str = "https://api.github.com/user"; const GITHUB_USER_EMAILS_URL: &str = "https://api.github.com/user/emails"; -/// Access token으로 GitHub 사용자 정보를 가져옵니다. +/// Fetches GitHub user info using an access token. pub async fn fetch_github_user_info( http_client: &HttpClient, access_token: &str, @@ -33,7 +33,7 @@ pub async fn fetch_github_user_info( Ok(user_info) } -/// Access token으로 GitHub 사용자의 이메일 목록을 가져옵니다. +/// Fetches the GitHub user's email list using an access token. pub async fn fetch_github_user_emails( http_client: &HttpClient, access_token: &str, diff --git a/crates/axumkit-server/src/service/oauth/github/generate_url.rs b/crates/axumkit-server/src/service/oauth/github/generate_url.rs index 61e3dd9..abcece1 100644 --- a/crates/axumkit-server/src/service/oauth/github/generate_url.rs +++ b/crates/axumkit-server/src/service/oauth/github/generate_url.rs @@ -6,7 +6,7 @@ use axumkit_entity::common::OAuthProvider; use axumkit_errors::errors::ServiceResult; use redis::aio::ConnectionManager; -/// GitHub OAuth 인증 URL을 생성하고 state를 Redis에 저장합니다. +/// Generates a GitHub OAuth authorization URL and stores the state in Redis. pub async fn service_generate_github_oauth_url( redis_conn: &ConnectionManager, anonymous_user_id: &str, diff --git a/crates/axumkit-server/src/service/oauth/github/link.rs b/crates/axumkit-server/src/service/oauth/github/link.rs index 8f165fd..498d708 100644 --- a/crates/axumkit-server/src/service/oauth/github/link.rs +++ b/crates/axumkit-server/src/service/oauth/github/link.rs @@ -13,7 +13,7 @@ use redis::aio::ConnectionManager; use sea_orm::{DatabaseConnection, TransactionTrait}; use uuid::Uuid; -/// GitHub OAuth를 기존 계정에 연결합니다. +/// Links GitHub OAuth to an existing account. pub async fn service_link_github_oauth( conn: &DatabaseConnection, redis_conn: &ConnectionManager, @@ -23,7 +23,7 @@ pub async fn service_link_github_oauth( state: &str, anonymous_user_id: &str, ) -> ServiceResult<()> { - // 1. Redis에서 state 검증 및 PKCE verifier 조회 (get_del로 1회용) + // 1. Validate state and retrieve PKCE verifier from Redis (single-use via get_del) let state_key = oauth_state_key(state); let state_data: OAuthStateData = get_json_and_delete( redis_conn, @@ -33,7 +33,7 @@ pub async fn service_link_github_oauth( ) .await?; - // 2. Authorization code를 access token으로 교환 + // 2. Exchange authorization code for access token if state_data.provider != OAuthProvider::Github || state_data.flow != OAuthAuthorizeFlow::Link || state_data.anonymous_user_id != anonymous_user_id @@ -44,12 +44,12 @@ pub async fn service_link_github_oauth( let access_token = exchange_code::(http_client, code, &state_data.pkce_verifier).await?; - // 3. Access token으로 사용자 정보 가져오기 + // 3. Fetch user info with access token let user_info = fetch_github_user_info(http_client, &access_token).await?; let txn = conn.begin().await?; - // 4. 이미 다른 계정에 연결되어 있는지 확인 + // 4. Check if already linked to another account if repository_find_user_by_oauth(&txn, OAuthProvider::Github, &user_info.id.to_string()) .await? .is_some() @@ -57,7 +57,7 @@ pub async fn service_link_github_oauth( return Err(Errors::OauthAccountAlreadyLinked); } - // 5. 현재 유저에게 이미 GitHub이 연결되어 있는지 확인 + // 5. Check if GitHub is already linked to the current user if repository_find_oauth_connection(&txn, user_id, OAuthProvider::Github) .await? .is_some() @@ -65,7 +65,7 @@ pub async fn service_link_github_oauth( return Err(Errors::OauthAccountAlreadyLinked); } - // 6. OAuth 연결 생성 + // 6. Create OAuth connection repository_create_oauth_connection( &txn, &user_id, diff --git a/crates/axumkit-server/src/service/oauth/github/sign_in.rs b/crates/axumkit-server/src/service/oauth/github/sign_in.rs index ba0ea45..8901486 100644 --- a/crates/axumkit-server/src/service/oauth/github/sign_in.rs +++ b/crates/axumkit-server/src/service/oauth/github/sign_in.rs @@ -2,6 +2,7 @@ use super::{GithubProvider, fetch_github_user_emails, fetch_github_user_info}; use crate::repository::oauth::find_user_by_oauth::repository_find_user_by_oauth; use crate::repository::user::find_by_email::repository_find_user_by_email; use crate::service::auth::session::SessionService; +use crate::service::auth::verify_email::find_pending_email_signup_by_email; use crate::service::oauth::provider::client::exchange_code; use crate::service::oauth::types::{OAuthStateData, PendingSignupData}; use crate::utils::redis_cache::{get_json_and_delete, issue_token_and_store_json_with_ttl}; @@ -14,10 +15,10 @@ use axumkit_errors::errors::{Errors, ServiceResult}; use redis::aio::ConnectionManager; use sea_orm::ConnectionTrait; -/// GitHub OAuth 로그인을 처리합니다. +/// Handles GitHub OAuth sign-in. /// -/// - 기존 사용자: 세션 생성 후 Success 반환 -/// - 신규 사용자: PendingSignup 반환 (complete-signup으로 가입 완료 필요) +/// - Existing user: creates a session and returns Success +/// - New user: returns PendingSignup (requires complete-signup to finish registration) pub async fn service_github_sign_in( conn: &C, redis_conn: &ConnectionManager, @@ -31,7 +32,7 @@ pub async fn service_github_sign_in( where C: ConnectionTrait, { - // 1. Redis에서 state 검증 및 PKCE verifier 조회 (get_del로 1회용) + // 1. Validate state and retrieve PKCE verifier from Redis (single-use via get_del) let state_key = oauth_state_key(state); let state_data: OAuthStateData = get_json_and_delete( redis_conn, @@ -41,7 +42,7 @@ where ) .await?; - // 2. Authorization code를 access token으로 교환 + // 2. Exchange authorization code for access token if state_data.provider != OAuthProvider::Github || state_data.flow != OAuthAuthorizeFlow::Login || state_data.anonymous_user_id != anonymous_user_id @@ -52,10 +53,9 @@ where let access_token = exchange_code::(http_client, code, &state_data.pkce_verifier).await?; - // 3. Access token으로 사용자 정보 가져오기 + // 3. Fetch user info with access token let user_info = fetch_github_user_info(http_client, &access_token).await?; - let display_name = user_info.name.unwrap_or_else(|| user_info.login.clone()); let email = if let Some(email) = user_info.email { email } else { @@ -69,12 +69,12 @@ where ))? }; - // 4. 기존 OAuth 연결 확인 + // 4. Check for existing OAuth connection if let Some(existing_user) = repository_find_user_by_oauth(conn, OAuthProvider::Github, &user_info.id.to_string()) .await? { - // 기존 사용자 - 세션 생성 후 Success 반환 + // Existing user - create session and return Success let session = SessionService::create_session( redis_conn, existing_user.id.to_string(), @@ -86,7 +86,7 @@ where return Ok(SignInResult::Success(session.session_id)); } - // 5. 신규 사용자 - 이메일 중복 확인 + // 5. New user - check for email duplication if repository_find_user_by_email(conn, email.clone()) .await? .is_some() @@ -94,14 +94,21 @@ where return Err(Errors::OauthEmailAlreadyExists); } - // 6. 신규 사용자 - pending signup 데이터를 Redis에 저장 + // 5b. Check if a pending email/password signup holds this email + if find_pending_email_signup_by_email(redis_conn, &email) + .await? + .is_some() + { + return Err(Errors::OauthEmailAlreadyExists); + } + + // 6. New user - store pending signup data in Redis let config = ServerConfig::get(); let pending_data = PendingSignupData { provider: OAuthProvider::Github, provider_user_id: user_info.id.to_string(), anonymous_user_id: anonymous_user_id.to_string(), email: email.clone(), - display_name: display_name.clone(), profile_image: Some(user_info.avatar_url), }; @@ -118,6 +125,5 @@ where Ok(SignInResult::PendingSignup { pending_token, email, - display_name, }) } diff --git a/crates/axumkit-server/src/service/oauth/google/client.rs b/crates/axumkit-server/src/service/oauth/google/client.rs index f94e397..fe77d30 100644 --- a/crates/axumkit-server/src/service/oauth/google/client.rs +++ b/crates/axumkit-server/src/service/oauth/google/client.rs @@ -4,7 +4,7 @@ use reqwest::Client as HttpClient; const GOOGLE_USER_INFO_URL: &str = "https://www.googleapis.com/oauth2/v2/userinfo"; -/// Access token으로 Google 사용자 정보를 가져옵니다. +/// Fetches Google user info using an access token. pub async fn fetch_google_user_info( http_client: &HttpClient, access_token: &str, diff --git a/crates/axumkit-server/src/service/oauth/google/generate_url.rs b/crates/axumkit-server/src/service/oauth/google/generate_url.rs index 19082ad..62e2adc 100644 --- a/crates/axumkit-server/src/service/oauth/google/generate_url.rs +++ b/crates/axumkit-server/src/service/oauth/google/generate_url.rs @@ -6,7 +6,7 @@ use axumkit_entity::common::OAuthProvider; use axumkit_errors::errors::ServiceResult; use redis::aio::ConnectionManager; -/// Google OAuth 인증 URL을 생성하고 state를 Redis에 저장합니다. +/// Generates a Google OAuth authorization URL and stores the state in Redis. pub async fn service_generate_google_oauth_url( redis_conn: &ConnectionManager, anonymous_user_id: &str, diff --git a/crates/axumkit-server/src/service/oauth/google/link.rs b/crates/axumkit-server/src/service/oauth/google/link.rs index b1dfd82..600ee10 100644 --- a/crates/axumkit-server/src/service/oauth/google/link.rs +++ b/crates/axumkit-server/src/service/oauth/google/link.rs @@ -13,7 +13,7 @@ use redis::aio::ConnectionManager; use sea_orm::{DatabaseConnection, TransactionTrait}; use uuid::Uuid; -/// Google OAuth를 기존 계정에 연결합니다. +/// Links Google OAuth to an existing account. pub async fn service_link_google_oauth( conn: &DatabaseConnection, redis_conn: &ConnectionManager, @@ -23,7 +23,7 @@ pub async fn service_link_google_oauth( state: &str, anonymous_user_id: &str, ) -> ServiceResult<()> { - // 1. Redis에서 state 검증 및 PKCE verifier 조회 (get_del로 1회용) + // 1. Validate state and retrieve PKCE verifier from Redis (single-use via get_del) let state_key = oauth_state_key(state); let state_data: OAuthStateData = get_json_and_delete( redis_conn, @@ -33,7 +33,7 @@ pub async fn service_link_google_oauth( ) .await?; - // 2. Authorization code를 access token으로 교환 + // 2. Exchange authorization code for access token if state_data.provider != OAuthProvider::Google || state_data.flow != OAuthAuthorizeFlow::Link || state_data.anonymous_user_id != anonymous_user_id @@ -44,17 +44,17 @@ pub async fn service_link_google_oauth( let access_token = exchange_code::(http_client, code, &state_data.pkce_verifier).await?; - // 3. Access token으로 사용자 정보 가져오기 + // 3. Fetch user info with access token let user_info = fetch_google_user_info(http_client, &access_token).await?; - // 3-1. 이메일 검증 여부 확인 + // 3-1. Check email verification status if !user_info.verified_email { return Err(Errors::OauthEmailNotVerified); } let txn = conn.begin().await?; - // 4. 이미 다른 계정에 연결되어 있는지 확인 + // 4. Check if already linked to another account if repository_find_user_by_oauth(&txn, OAuthProvider::Google, &user_info.id) .await? .is_some() @@ -62,7 +62,7 @@ pub async fn service_link_google_oauth( return Err(Errors::OauthAccountAlreadyLinked); } - // 5. 현재 유저에게 이미 Google이 연결되어 있는지 확인 + // 5. Check if Google is already linked to the current user if repository_find_oauth_connection(&txn, user_id, OAuthProvider::Google) .await? .is_some() @@ -70,7 +70,7 @@ pub async fn service_link_google_oauth( return Err(Errors::OauthAccountAlreadyLinked); } - // 6. OAuth 연결 생성 + // 6. Create OAuth connection repository_create_oauth_connection(&txn, &user_id, OAuthProvider::Google, &user_info.id) .await?; diff --git a/crates/axumkit-server/src/service/oauth/google/mod.rs b/crates/axumkit-server/src/service/oauth/google/mod.rs index 92ea5ab..2edd649 100644 --- a/crates/axumkit-server/src/service/oauth/google/mod.rs +++ b/crates/axumkit-server/src/service/oauth/google/mod.rs @@ -2,10 +2,12 @@ pub mod client; pub mod config; pub mod generate_url; pub mod link; +pub mod one_tap_sign_in; pub mod sign_in; pub use client::fetch_google_user_info; pub use config::GoogleProvider; pub use generate_url::service_generate_google_oauth_url; pub use link::service_link_google_oauth; +pub use one_tap_sign_in::service_google_one_tap_sign_in; pub use sign_in::service_google_sign_in; diff --git a/crates/axumkit-server/src/service/oauth/google/one_tap_sign_in.rs b/crates/axumkit-server/src/service/oauth/google/one_tap_sign_in.rs new file mode 100644 index 0000000..91e1d8c --- /dev/null +++ b/crates/axumkit-server/src/service/oauth/google/one_tap_sign_in.rs @@ -0,0 +1,244 @@ +use crate::repository::oauth::find_user_by_oauth::repository_find_user_by_oauth; +use crate::repository::user::find_by_email::repository_find_user_by_email; +use crate::service::auth::session::SessionService; +use crate::service::auth::verify_email::find_pending_email_signup_by_email; +use crate::service::oauth::types::PendingSignupData; +use crate::utils::redis_cache::issue_token_and_store_json_with_ttl; +use axumkit_config::ServerConfig; +use axumkit_constants::oauth_pending_key; +use axumkit_dto::oauth::internal::SignInResult; +use axumkit_entity::common::OAuthProvider; +use axumkit_errors::errors::{Errors, ServiceResult}; +use jsonwebtoken::jwk::JwkSet; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header}; +use redis::aio::ConnectionManager; +use reqwest::header::{CACHE_CONTROL, HeaderValue}; +use sea_orm::ConnectionTrait; +use serde::Deserialize; +use std::sync::LazyLock; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; +use tracing::debug; + +const GOOGLE_JWKS_URL: &str = "https://www.googleapis.com/oauth2/v3/certs"; +const DEFAULT_JWKS_CACHE_TTL_SECONDS: u64 = 300; + +static GOOGLE_JWKS_CACHE: LazyLock>> = + LazyLock::new(|| RwLock::new(None)); + +#[derive(Debug, Clone)] +struct CachedGoogleJwks { + jwks: JwkSet, + expires_at: Instant, +} + +#[derive(Debug, Deserialize)] +struct GoogleIdTokenClaims { + sub: String, + email: String, + email_verified: bool, + picture: Option, +} + +/// Handle Google One Tap server-side sign-in. +pub async fn service_google_one_tap_sign_in( + conn: &C, + redis_conn: &ConnectionManager, + http_client: &reqwest::Client, + credential: &str, + anonymous_user_id: &str, + user_agent: Option, + ip_address: Option, +) -> ServiceResult +where + C: ConnectionTrait, +{ + let header = decode_header(credential).map_err(|e| { + debug!(error = %e, "Failed to decode Google ID token header"); + Errors::GoogleInvalidIdToken + })?; + let kid = header.kid.ok_or_else(|| { + debug!("Google ID token header missing 'kid' field"); + Errors::GoogleInvalidIdToken + })?; + + let (jwks, from_cache) = get_google_jwks(http_client, false).await?; + let decoding_key = if let Some(jwk) = jwks.find(&kid) { + DecodingKey::from_jwk(jwk).map_err(|e| { + debug!(error = %e, "Failed to build decoding key from JWK"); + Errors::GoogleInvalidIdToken + })? + } else if from_cache { + let (refreshed_jwks, _) = get_google_jwks(http_client, true).await?; + let jwk = refreshed_jwks.find(&kid).ok_or_else(|| { + debug!(kid = %kid, "kid not found in refreshed JWKS"); + Errors::GoogleInvalidIdToken + })?; + DecodingKey::from_jwk(jwk).map_err(|e| { + debug!(error = %e, "Failed to build decoding key from refreshed JWK"); + Errors::GoogleInvalidIdToken + })? + } else { + debug!(kid = %kid, "kid not found in freshly fetched JWKS"); + return Err(Errors::GoogleInvalidIdToken); + }; + + let config = ServerConfig::get(); + let mut validation = Validation::new(Algorithm::RS256); + validation.set_required_spec_claims(&["exp", "iss", "aud", "sub"]); + validation.validate_nbf = true; + validation.set_issuer(&["accounts.google.com", "https://accounts.google.com"]); + validation.set_audience(&[config.google_client_id.as_str()]); + + let token_data = decode::(credential, &decoding_key, &validation) + .map_err(|e| { + debug!(error = %e, "Google ID token validation failed"); + Errors::GoogleInvalidIdToken + })?; + + if !token_data.claims.email_verified { + return Err(Errors::OauthEmailNotVerified); + } + + // 1. Check whether this Google identity is already linked. + if let Some(existing_user) = + repository_find_user_by_oauth(conn, OAuthProvider::Google, &token_data.claims.sub).await? + { + let session = SessionService::create_session( + redis_conn, + existing_user.id.to_string(), + user_agent, + ip_address, + ) + .await?; + + return Ok(SignInResult::Success(session.session_id)); + } + + // 2. New user path: reject if the email already belongs to another account. + if repository_find_user_by_email(conn, token_data.claims.email.clone()) + .await? + .is_some() + { + return Err(Errors::OauthEmailAlreadyExists); + } + + // 2b. Check if a pending email/password signup holds this email + if find_pending_email_signup_by_email(redis_conn, &token_data.claims.email) + .await? + .is_some() + { + return Err(Errors::OauthEmailAlreadyExists); + } + + // 3. New user path: store pending-signup data in Redis. + let config = ServerConfig::get(); + let pending_data = PendingSignupData { + provider: OAuthProvider::Google, + provider_user_id: token_data.claims.sub, + anonymous_user_id: anonymous_user_id.to_string(), + email: token_data.claims.email.clone(), + profile_image: token_data.claims.picture, + }; + + let ttl_seconds = (config.oauth_pending_signup_ttl_minutes * 60) as u64; + let pending_token = issue_token_and_store_json_with_ttl( + redis_conn, + || uuid::Uuid::new_v4().to_string(), + oauth_pending_key, + &pending_data, + ttl_seconds, + ) + .await?; + + Ok(SignInResult::PendingSignup { + pending_token, + email: token_data.claims.email, + }) +} + +async fn get_google_jwks( + http_client: &reqwest::Client, + force_refresh: bool, +) -> ServiceResult<(JwkSet, bool)> { + let now = Instant::now(); + if !force_refresh { + let cache = GOOGLE_JWKS_CACHE.read().await; + if let Some(cached) = cache.as_ref() { + if now < cached.expires_at { + return Ok((cached.jwks.clone(), true)); + } + } + } + + // Acquire write lock and double-check to prevent cache stampede + let mut cache = GOOGLE_JWKS_CACHE.write().await; + if !force_refresh { + if let Some(cached) = cache.as_ref() { + if Instant::now() < cached.expires_at { + return Ok((cached.jwks.clone(), true)); + } + } + } + + let (jwks, cache_ttl_seconds) = fetch_google_jwks(http_client).await?; + *cache = Some(CachedGoogleJwks { + jwks: jwks.clone(), + expires_at: Instant::now() + Duration::from_secs(cache_ttl_seconds), + }); + + Ok((jwks, false)) +} + +async fn fetch_google_jwks(http_client: &reqwest::Client) -> ServiceResult<(JwkSet, u64)> { + let response = http_client + .get(GOOGLE_JWKS_URL) + .send() + .await + .map_err(|_| Errors::GoogleJwksFetchFailed)?; + + if !response.status().is_success() { + return Err(Errors::GoogleJwksFetchFailed); + } + + let cache_ttl_seconds = response + .headers() + .get(CACHE_CONTROL) + .and_then(|value: &HeaderValue| value.to_str().ok()) + .and_then(parse_cache_control_max_age) + .unwrap_or(DEFAULT_JWKS_CACHE_TTL_SECONDS); + + let jwks = response + .json::() + .await + .map_err(|_| Errors::GoogleJwksParseFailed)?; + + Ok((jwks, cache_ttl_seconds)) +} + +fn parse_cache_control_max_age(cache_control: &str) -> Option { + cache_control.split(',').find_map(|directive| { + directive + .trim() + .strip_prefix("max-age=") + .and_then(|value| value.parse::().ok()) + }) +} + +#[cfg(test)] +mod tests { + use super::parse_cache_control_max_age; + + #[test] + fn parses_max_age_from_cache_control() { + assert_eq!( + parse_cache_control_max_age("public, max-age=24131, must-revalidate"), + Some(24131) + ); + } + + #[test] + fn returns_none_when_max_age_missing() { + assert_eq!(parse_cache_control_max_age("public, must-revalidate"), None); + } +} diff --git a/crates/axumkit-server/src/service/oauth/google/sign_in.rs b/crates/axumkit-server/src/service/oauth/google/sign_in.rs index 0333f99..c94a750 100644 --- a/crates/axumkit-server/src/service/oauth/google/sign_in.rs +++ b/crates/axumkit-server/src/service/oauth/google/sign_in.rs @@ -2,6 +2,7 @@ use super::{GoogleProvider, fetch_google_user_info}; use crate::repository::oauth::find_user_by_oauth::repository_find_user_by_oauth; use crate::repository::user::find_by_email::repository_find_user_by_email; use crate::service::auth::session::SessionService; +use crate::service::auth::verify_email::find_pending_email_signup_by_email; use crate::service::oauth::provider::client::exchange_code; use crate::service::oauth::types::{OAuthStateData, PendingSignupData}; use crate::utils::redis_cache::{get_json_and_delete, issue_token_and_store_json_with_ttl}; @@ -14,10 +15,10 @@ use axumkit_errors::errors::{Errors, ServiceResult}; use redis::aio::ConnectionManager; use sea_orm::ConnectionTrait; -/// Google OAuth 로그인을 처리합니다. +/// Handles Google OAuth sign-in. /// -/// - 기존 사용자: 세션 생성 후 Success 반환 -/// - 신규 사용자: PendingSignup 반환 (complete-signup으로 가입 완료 필요) +/// - Existing user: creates a session and returns Success +/// - New user: returns PendingSignup (requires complete-signup to finish registration) pub async fn service_google_sign_in( conn: &C, redis_conn: &ConnectionManager, @@ -31,7 +32,7 @@ pub async fn service_google_sign_in( where C: ConnectionTrait, { - // 1. Redis에서 state 검증 및 PKCE verifier 조회 (get_del로 1회용) + // 1. Validate state and retrieve PKCE verifier from Redis (single-use via get_del) let state_key = oauth_state_key(state); let state_data: OAuthStateData = get_json_and_delete( redis_conn, @@ -41,7 +42,7 @@ where ) .await?; - // 2. Authorization code를 access token으로 교환 + // 2. Exchange authorization code for access token if state_data.provider != OAuthProvider::Google || state_data.flow != OAuthAuthorizeFlow::Login || state_data.anonymous_user_id != anonymous_user_id @@ -52,19 +53,19 @@ where let access_token = exchange_code::(http_client, code, &state_data.pkce_verifier).await?; - // 3. Access token으로 사용자 정보 가져오기 + // 3. Fetch user info with access token let user_info = fetch_google_user_info(http_client, &access_token).await?; - // 3-1. 이메일 검증 여부 확인 + // 3-1. Check email verification status if !user_info.verified_email { return Err(Errors::OauthEmailNotVerified); } - // 4. 기존 OAuth 연결 확인 + // 4. Check for existing OAuth connection if let Some(existing_user) = repository_find_user_by_oauth(conn, OAuthProvider::Google, &user_info.id).await? { - // 기존 사용자 - 세션 생성 후 Success 반환 + // Existing user - create session and return Success let session = SessionService::create_session( redis_conn, existing_user.id.to_string(), @@ -76,7 +77,7 @@ where return Ok(SignInResult::Success(session.session_id)); } - // 5. 신규 사용자 - 이메일 중복 확인 + // 5. New user - check for email duplication if repository_find_user_by_email(conn, user_info.email.clone()) .await? .is_some() @@ -84,14 +85,21 @@ where return Err(Errors::OauthEmailAlreadyExists); } - // 6. 신규 사용자 - pending signup 데이터를 Redis에 저장 + // 5b. Check if a pending email/password signup holds this email + if find_pending_email_signup_by_email(redis_conn, &user_info.email) + .await? + .is_some() + { + return Err(Errors::OauthEmailAlreadyExists); + } + + // 6. New user - store pending signup data in Redis let config = ServerConfig::get(); let pending_data = PendingSignupData { provider: OAuthProvider::Google, provider_user_id: user_info.id, anonymous_user_id: anonymous_user_id.to_string(), email: user_info.email.clone(), - display_name: user_info.name.clone(), profile_image: Some(user_info.picture), }; @@ -108,6 +116,5 @@ where Ok(SignInResult::PendingSignup { pending_token, email: user_info.email, - display_name: user_info.name, }) } diff --git a/crates/axumkit-server/src/service/oauth/list_connections.rs b/crates/axumkit-server/src/service/oauth/list_connections.rs index b9cd4b8..8a9a6b9 100644 --- a/crates/axumkit-server/src/service/oauth/list_connections.rs +++ b/crates/axumkit-server/src/service/oauth/list_connections.rs @@ -4,7 +4,7 @@ use axumkit_errors::errors::ServiceResult; use sea_orm::ConnectionTrait; use uuid::Uuid; -/// 사용자의 모든 OAuth 연결 목록을 조회합니다. +/// Queries all OAuth connection entries for a user. pub async fn service_list_oauth_connections( conn: &C, user_id: Uuid, diff --git a/crates/axumkit-server/src/service/oauth/provider/client.rs b/crates/axumkit-server/src/service/oauth/provider/client.rs index 2f784eb..da412fb 100644 --- a/crates/axumkit-server/src/service/oauth/provider/client.rs +++ b/crates/axumkit-server/src/service/oauth/provider/client.rs @@ -6,7 +6,7 @@ use oauth2::{ }; use reqwest::Client as HttpClient; -/// OAuth 인증 URL을 생성합니다. +/// Generates an OAuth authorization URL. /// Returns: (auth_url, state, pkce_verifier) pub fn generate_auth_url( state: String, @@ -41,7 +41,7 @@ pub fn generate_auth_url( )) } -/// Authorization code를 access token으로 교환합니다. +/// Exchanges an authorization code for an access token. pub async fn exchange_code( http_client: &HttpClient, code: &str, diff --git a/crates/axumkit-server/src/service/oauth/provider/config.rs b/crates/axumkit-server/src/service/oauth/provider/config.rs index 187eddf..0a0f2a7 100644 --- a/crates/axumkit-server/src/service/oauth/provider/config.rs +++ b/crates/axumkit-server/src/service/oauth/provider/config.rs @@ -1,13 +1,13 @@ use axumkit_entity::common::OAuthProvider; -/// OAuth provider 설정을 정의하는 trait. -/// 각 provider(Google, GitHub 등)가 구현하여 generic 함수에서 사용합니다. +/// Trait defining OAuth provider configuration. +/// Implemented by each provider (Google, GitHub, etc.) for use in generic functions. pub trait OAuthProviderConfig { const AUTH_URL: &'static str; const TOKEN_URL: &'static str; const SCOPES: &'static [&'static str]; const PROVIDER: OAuthProvider; - /// ServerConfig에서 (client_id, client_secret, redirect_uri)를 반환합니다. + /// Returns (client_id, client_secret, redirect_uri) from ServerConfig. fn credentials() -> (&'static str, &'static str, &'static str); } diff --git a/crates/axumkit-server/src/service/oauth/types/github.rs b/crates/axumkit-server/src/service/oauth/types/github.rs index e5b3645..867a233 100644 --- a/crates/axumkit-server/src/service/oauth/types/github.rs +++ b/crates/axumkit-server/src/service/oauth/types/github.rs @@ -1,6 +1,6 @@ use serde::Deserialize; -/// GitHub OAuth API에서 받는 유저 정보 +/// User info received from the GitHub OAuth API #[derive(Debug, Deserialize)] pub struct GithubUserInfo { /// GitHub user ID @@ -15,7 +15,7 @@ pub struct GithubUserInfo { pub avatar_url: String, } -/// GitHub user email 정보 (별도 API 호출용) +/// GitHub user email info (for separate API call) #[derive(Debug, Deserialize)] pub struct GithubEmail { pub email: String, diff --git a/crates/axumkit-server/src/service/oauth/types/google.rs b/crates/axumkit-server/src/service/oauth/types/google.rs index 07d8b3d..48e6e7e 100644 --- a/crates/axumkit-server/src/service/oauth/types/google.rs +++ b/crates/axumkit-server/src/service/oauth/types/google.rs @@ -1,6 +1,6 @@ use serde::Deserialize; -/// Google OAuth API에서 받는 유저 정보 +/// User info received from the Google OAuth API #[derive(Debug, Deserialize)] pub struct GoogleUserInfo { /// Google user ID (v2 API uses 'id') diff --git a/crates/axumkit-server/src/service/oauth/types/oauth_user_result.rs b/crates/axumkit-server/src/service/oauth/types/oauth_user_result.rs index b4b88e1..4dcf0e0 100644 --- a/crates/axumkit-server/src/service/oauth/types/oauth_user_result.rs +++ b/crates/axumkit-server/src/service/oauth/types/oauth_user_result.rs @@ -1,10 +1,10 @@ use axumkit_entity::users::Model as UserModel; -/// OAuth 로그인 결과 (내부 서비스 로직용) +/// OAuth sign-in result (for internal service logic) #[derive(Debug)] pub struct OAuthUserResult { /// User model pub user: UserModel, - /// 신규 생성된 유저인지 여부 + /// Whether the user was newly created pub is_new_user: bool, } diff --git a/crates/axumkit-server/src/service/oauth/types/pending_signup_data.rs b/crates/axumkit-server/src/service/oauth/types/pending_signup_data.rs index 5bffac3..c5b95fe 100644 --- a/crates/axumkit-server/src/service/oauth/types/pending_signup_data.rs +++ b/crates/axumkit-server/src/service/oauth/types/pending_signup_data.rs @@ -1,13 +1,12 @@ use axumkit_entity::common::OAuthProvider; use serde::{Deserialize, Serialize}; -/// OAuth 로그인 시 신규 사용자가 handle 없이 요청한 경우 Redis에 임시 저장되는 데이터 +/// Data temporarily stored in Redis when a new user requests OAuth sign-in without a handle #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PendingSignupData { pub provider: OAuthProvider, pub provider_user_id: String, pub anonymous_user_id: String, pub email: String, - pub display_name: String, pub profile_image: Option, } diff --git a/crates/axumkit-server/src/service/posts/create_post.rs b/crates/axumkit-server/src/service/posts/create_post.rs deleted file mode 100644 index 8da21c8..0000000 --- a/crates/axumkit-server/src/service/posts/create_post.rs +++ /dev/null @@ -1,44 +0,0 @@ -use axumkit_constants::POST_CONTENT_PREFIX; -use axumkit_dto::posts::CreatePostResponse; -use axumkit_errors::errors::Errors; -use sea_orm::DatabaseConnection; -use uuid::Uuid; - -use crate::bridge::worker_client::index_post; -use crate::connection::seaweedfs_conn::SeaweedFsClient; -use crate::repository::posts::repository_create_post; -use crate::state::WorkerClient; - -pub async fn service_create_post( - conn: &DatabaseConnection, - seaweedfs_client: &SeaweedFsClient, - worker: &WorkerClient, - author_id: Uuid, - title: String, - content: String, -) -> Result { - // 1. Generate storage key - let post_id = Uuid::now_v7(); - let storage_key = format!("{}/{}", POST_CONTENT_PREFIX, post_id); - - // 2. Upload content to SeaweedFS - seaweedfs_client - .upload_content(&storage_key, &content) - .await - .map_err(|e| { - tracing::error!("Failed to upload post content: {}", e); - Errors::FileUploadError(e.to_string()) - })?; - - // 3. Create post in database with storage_key - let post = repository_create_post(conn, author_id, title, storage_key).await?; - - // 4. Publish index job via bridge - if let Err(e) = index_post(worker, post.id).await { - tracing::warn!("Failed to queue post index job: {:?}", e); - } - - Ok(CreatePostResponse { - id: post.id.to_string(), - }) -} diff --git a/crates/axumkit-server/src/service/posts/delete_post.rs b/crates/axumkit-server/src/service/posts/delete_post.rs deleted file mode 100644 index a095f47..0000000 --- a/crates/axumkit-server/src/service/posts/delete_post.rs +++ /dev/null @@ -1,39 +0,0 @@ -use axumkit_dto::posts::DeletePostResponse; -use axumkit_errors::errors::Errors; -use sea_orm::DatabaseConnection; -use uuid::Uuid; - -use crate::bridge::worker_client::{delete_content, delete_post_from_index}; -use crate::repository::posts::{repository_delete_post, repository_get_post_by_id}; -use crate::state::WorkerClient; - -pub async fn service_delete_post( - conn: &DatabaseConnection, - worker: &WorkerClient, - id: Uuid, - author_id: Uuid, -) -> Result { - let post = repository_get_post_by_id(conn, id).await?; - - // 작성자만 삭제 가능 - if post.author_id != author_id { - return Err(Errors::UserUnauthorized); - } - - let storage_key = post.storage_key.clone(); - - // DB에서 삭제 - repository_delete_post(conn, id).await?; - - // Index에서 삭제 - if let Err(e) = delete_post_from_index(worker, id).await { - tracing::warn!("Failed to queue post delete from index: {:?}", e); - } - - // Storage에서 삭제 (worker queue) - if let Err(e) = delete_content(worker, vec![storage_key]).await { - tracing::warn!("Failed to queue content delete: {:?}", e); - } - - Ok(DeletePostResponse { success: true }) -} diff --git a/crates/axumkit-server/src/service/posts/get_post.rs b/crates/axumkit-server/src/service/posts/get_post.rs deleted file mode 100644 index 61a188e..0000000 --- a/crates/axumkit-server/src/service/posts/get_post.rs +++ /dev/null @@ -1,33 +0,0 @@ -use axumkit_dto::posts::PostResponse; -use axumkit_errors::errors::Errors; -use sea_orm::DatabaseConnection; -use uuid::Uuid; - -use crate::connection::seaweedfs_conn::SeaweedFsClient; -use crate::repository::posts::repository_get_post_by_id; - -pub async fn service_get_post( - conn: &DatabaseConnection, - seaweedfs_client: &SeaweedFsClient, - id: Uuid, -) -> Result { - let post = repository_get_post_by_id(conn, id).await?; - - // content를 SeaweedFS에서 읽어옴 - let content = seaweedfs_client - .download_content(&post.storage_key) - .await - .map_err(|e| { - tracing::error!("Failed to download post content: {}", e); - Errors::FileReadError(e.to_string()) - })?; - - Ok(PostResponse { - id: post.id.to_string(), - author_id: post.author_id.to_string(), - title: post.title, - content, - created_at: post.created_at, - updated_at: post.updated_at, - }) -} diff --git a/crates/axumkit-server/src/service/posts/list_posts.rs b/crates/axumkit-server/src/service/posts/list_posts.rs deleted file mode 100644 index 29a60c1..0000000 --- a/crates/axumkit-server/src/service/posts/list_posts.rs +++ /dev/null @@ -1,24 +0,0 @@ -use axumkit_dto::posts::{ListPostsResponse, PostListItem}; -use axumkit_errors::errors::Errors; -use sea_orm::DatabaseConnection; - -use crate::repository::posts::repository_list_posts; - -pub async fn service_list_posts( - conn: &DatabaseConnection, - limit: u64, - offset: u64, -) -> Result { - let posts = repository_list_posts(conn, limit, offset).await?; - let posts = posts - .into_iter() - .map(|post| PostListItem { - id: post.id.to_string(), - author_id: post.author_id.to_string(), - title: post.title, - created_at: post.created_at, - updated_at: post.updated_at, - }) - .collect(); - Ok(ListPostsResponse { posts }) -} diff --git a/crates/axumkit-server/src/service/posts/mod.rs b/crates/axumkit-server/src/service/posts/mod.rs deleted file mode 100644 index e0fa30a..0000000 --- a/crates/axumkit-server/src/service/posts/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -pub mod create_post; -pub mod delete_post; -pub mod get_post; -pub mod list_posts; -pub mod update_post; - -pub use create_post::service_create_post; -pub use delete_post::service_delete_post; -pub use get_post::service_get_post; -pub use list_posts::service_list_posts; -pub use update_post::service_update_post; diff --git a/crates/axumkit-server/src/service/posts/update_post.rs b/crates/axumkit-server/src/service/posts/update_post.rs deleted file mode 100644 index 870df68..0000000 --- a/crates/axumkit-server/src/service/posts/update_post.rs +++ /dev/null @@ -1,71 +0,0 @@ -use axumkit_dto::posts::PostResponse; -use axumkit_errors::errors::Errors; -use sea_orm::DatabaseConnection; -use uuid::Uuid; - -use crate::bridge::worker_client::index_post; -use crate::connection::seaweedfs_conn::SeaweedFsClient; -use crate::repository::posts::{ - PostUpdateParams, repository_get_post_by_id, repository_update_post, -}; -use crate::state::WorkerClient; - -pub async fn service_update_post( - conn: &DatabaseConnection, - seaweedfs_client: &SeaweedFsClient, - worker: &WorkerClient, - id: Uuid, - author_id: Uuid, - title: Option, - content: Option, -) -> Result { - let post = repository_get_post_by_id(conn, id).await?; - - // 작성자만 수정 가능 - if post.author_id != author_id { - return Err(Errors::UserUnauthorized); - } - - // content가 변경되면 SeaweedFS에 업로드 - let new_storage_key = if let Some(ref new_content) = content { - seaweedfs_client - .upload_content(&post.storage_key, new_content) - .await - .map_err(|e| { - tracing::error!("Failed to upload post content: {}", e); - Errors::FileUploadError(e.to_string()) - })?; - None // 기존 storage_key 유지 - } else { - None - }; - - let params = PostUpdateParams { - title, - storage_key: new_storage_key, - }; - let updated_post = repository_update_post(conn, post, params).await?; - - // Publish index job via bridge - if let Err(e) = index_post(worker, updated_post.id).await { - tracing::warn!("Failed to queue post index job: {:?}", e); - } - - // content를 SeaweedFS에서 읽어옴 - let content = seaweedfs_client - .download_content(&updated_post.storage_key) - .await - .map_err(|e| { - tracing::error!("Failed to download post content: {}", e); - Errors::FileReadError(e.to_string()) - })?; - - Ok(PostResponse { - id: updated_post.id.to_string(), - author_id: updated_post.author_id.to_string(), - title: updated_post.title, - content, - created_at: updated_post.created_at, - updated_at: updated_post.updated_at, - }) -} diff --git a/crates/axumkit-server/src/service/search/mod.rs b/crates/axumkit-server/src/service/search/mod.rs index f88b7f0..b8a331e 100644 --- a/crates/axumkit-server/src/service/search/mod.rs +++ b/crates/axumkit-server/src/service/search/mod.rs @@ -1,5 +1,3 @@ -pub mod search_posts; pub mod search_users; -pub use search_posts::*; pub use search_users::*; diff --git a/crates/axumkit-server/src/service/search/search_posts.rs b/crates/axumkit-server/src/service/search/search_posts.rs deleted file mode 100644 index 2f7bf72..0000000 --- a/crates/axumkit-server/src/service/search/search_posts.rs +++ /dev/null @@ -1,90 +0,0 @@ -use crate::connection::MeilisearchClient; -use axumkit_dto::search::{PostSearchHit, SearchPostsRequest, SearchPostsResponse}; -use axumkit_errors::errors::{Errors, ServiceResult}; -use serde::Deserialize; -use tracing::{info, warn}; -use uuid::Uuid; - -// Private: MeiliSearch index schema -#[derive(Debug, Deserialize)] -struct IndexedPost { - id: String, - author_id: String, - title: String, - content: String, -} - -pub async fn service_search_posts( - client: &MeilisearchClient, - request: &SearchPostsRequest, -) -> ServiceResult { - info!( - "Searching posts: query='{}', page={}, page_size={}", - request.query, request.page, request.page_size - ); - - // Build and execute search query using page/hitsPerPage mode for exact total_hits - let index = client.get_client().index("posts"); - let mut search_query = index.search(); - - search_query.with_query(&request.query); - search_query.with_page(request.page as usize); - search_query.with_hits_per_page(request.page_size as usize); - - // Execute search - let results = search_query.execute::().await.map_err(|e| { - tracing::error!("MeiliSearch query failed: {}", e); - Errors::MeiliSearchQueryFailed - })?; - - // Get pagination info from response (available in page/hitsPerPage mode) - let total_hits = results.total_hits.unwrap_or(0) as u64; - let total_pages = results.total_pages.unwrap_or(0) as u32; - - // Transform results to DTOs - let hits: Vec = results - .hits - .into_iter() - .filter_map(|hit| { - let post = hit.result; - let id = match Uuid::parse_str(&post.id) { - Ok(id) => id, - Err(e) => { - warn!("Invalid UUID in search index: '{}', error: {}", post.id, e); - return None; - } - }; - let author_id = match Uuid::parse_str(&post.author_id) { - Ok(id) => id, - Err(e) => { - warn!( - "Invalid author_id UUID in search index: '{}', error: {}", - post.author_id, e - ); - return None; - } - }; - Some(PostSearchHit { - id, - author_id, - title: post.title, - content_snippet: truncate_content(&post.content, 200), - }) - }) - .collect(); - - Ok(SearchPostsResponse { - hits, - page: request.page, - page_size: request.page_size, - total_hits, - total_pages, - }) -} - -fn truncate_content(content: &str, max_chars: usize) -> String { - match content.char_indices().nth(max_chars) { - Some((idx, _)) => content[..idx].to_string(), - None => content.to_string(), - } -} diff --git a/crates/axumkit-server/src/service/user/create_user.rs b/crates/axumkit-server/src/service/user/create_user.rs deleted file mode 100644 index 0b3157c..0000000 --- a/crates/axumkit-server/src/service/user/create_user.rs +++ /dev/null @@ -1,79 +0,0 @@ -use crate::bridge::worker_client; -use crate::repository::user::{ - repository_create_user, repository_find_user_by_email, repository_find_user_by_handle, -}; -use crate::service::auth::verify_email::EmailVerificationData; -use crate::state::WorkerClient; -use crate::utils::crypto::token::generate_secure_token; -use crate::utils::redis_cache::set_json_with_ttl; -use axumkit_config::ServerConfig; -use axumkit_dto::user::{CreateUserRequest, CreateUserResponse}; -use axumkit_errors::errors::{Errors, ServiceResult}; -use redis::aio::ConnectionManager; -use sea_orm::{DatabaseConnection, TransactionTrait}; - -pub async fn service_create_user( - conn: &DatabaseConnection, - redis_conn: &ConnectionManager, - worker: &WorkerClient, - payload: CreateUserRequest, -) -> ServiceResult { - let config = ServerConfig::get(); - let txn = conn.begin().await?; - - // Check if user already exists by email - let existing_user_by_email = repository_find_user_by_email(&txn, payload.email.clone()).await?; - if existing_user_by_email.is_some() { - return Err(Errors::UserEmailAlreadyExists); - } - - // Check if user already exists by handle - let existing_user_by_handle = - repository_find_user_by_handle(&txn, payload.handle.clone()).await?; - if existing_user_by_handle.is_some() { - return Err(Errors::UserHandleAlreadyExists); - } - - // Create user in database - let user = repository_create_user( - &txn, - payload.email.clone(), - payload.handle.clone(), - payload.display_name, - payload.password, - ) - .await?; - - txn.commit().await?; - - // 이메일 인증 토큰 생성 및 발송 (암호학적으로 안전한 랜덤 토큰) - let token = generate_secure_token(); - let token_key = format!("email_verification:{}", token); - - let verification_data = EmailVerificationData { - user_id: user.id.to_string(), - email: user.email.clone(), - }; - - // Redis에 토큰 저장 (분 단위 → 초 단위 변환) - let ttl_seconds = (config.auth_email_verification_token_expire_time * 60) as u64; - set_json_with_ttl(redis_conn, &token_key, &verification_data, ttl_seconds).await?; - - // Worker 서비스에 이메일 발송 요청 - worker_client::send_verification_email( - worker, - &user.email, - &user.handle, - &token, - config.auth_email_verification_token_expire_time as u64, // minutes - ) - .await?; - - // MeiliSearch에 유저 인덱싱 (멘션 검색용) - worker_client::index_user(worker, user.id).await.ok(); - - Ok(CreateUserResponse { - message: "User created successfully. Please check your email to verify your account." - .to_string(), - }) -} diff --git a/crates/axumkit-server/src/service/user/management/ban_user.rs b/crates/axumkit-server/src/service/user/management/ban_user.rs new file mode 100644 index 0000000..74e36ec --- /dev/null +++ b/crates/axumkit-server/src/service/user/management/ban_user.rs @@ -0,0 +1,68 @@ +use crate::permission::PermissionService; +use crate::repository::moderation::repository_create_moderation_log; +use crate::repository::user::user_bans::{ + repository_create_user_ban, repository_delete_expired_user_ban, repository_find_user_ban, +}; +use crate::service::auth::session_types::SessionContext; +use axumkit_constants::ModerationAction; +use axumkit_dto::user::response::BanUserResponse; +use axumkit_entity::common::ModerationResourceType; +use axumkit_errors::errors::{Errors, ServiceResult}; +use chrono::{DateTime, Utc}; +use sea_orm::{DatabaseConnection, TransactionTrait}; +use serde_json::json; +use tracing::info; +use uuid::Uuid; + +/// Bans a user. +/// +/// # Permissions +/// - Only Admin can ban users +/// - Cannot ban another Admin +/// +/// # Errors +/// - Returns `Errors::UserAlreadyBanned` if the user is already banned +pub async fn service_ban_user( + db: &DatabaseConnection, + target_user_id: Uuid, + expires_at: Option>, + reason: String, + session: &SessionContext, +) -> ServiceResult { + PermissionService::require_admin_for_target(db, Some(session), target_user_id).await?; + + let txn = db.begin().await?; + + if repository_find_user_ban(&txn, target_user_id) + .await? + .is_some() + { + return Err(Errors::UserAlreadyBanned); + } + + repository_delete_expired_user_ban(&txn, target_user_id).await?; + + let ban = repository_create_user_ban(&txn, target_user_id, expires_at).await?; + + repository_create_moderation_log( + &txn, + ModerationAction::UserBan, + Some(session.user_id), + ModerationResourceType::User, + Some(target_user_id), + reason, + Some(json!({ + "expires_at": expires_at + })), + ) + .await?; + + txn.commit().await?; + + info!(target_user_id = %target_user_id, actor_id = %session.user_id, "User banned"); + + Ok(BanUserResponse { + user_id: target_user_id, + expires_at: ban.expires_at, + }) +} diff --git a/crates/axumkit-server/src/service/user/management/grant_role.rs b/crates/axumkit-server/src/service/user/management/grant_role.rs new file mode 100644 index 0000000..cfb0eb7 --- /dev/null +++ b/crates/axumkit-server/src/service/user/management/grant_role.rs @@ -0,0 +1,69 @@ +use crate::permission::PermissionService; +use crate::repository::moderation::repository_create_moderation_log; +use crate::repository::user::user_roles::{ + repository_create_user_role, repository_delete_expired_user_role, repository_find_user_roles, +}; +use crate::service::auth::session_types::SessionContext; +use axumkit_constants::ModerationAction; +use axumkit_dto::user::response::GrantRoleResponse; +use axumkit_entity::common::{ModerationResourceType, Role}; +use axumkit_errors::errors::{Errors, ServiceResult}; +use chrono::{DateTime, Utc}; +use sea_orm::{DatabaseConnection, TransactionTrait}; +use serde_json::json; +use tracing::info; +use uuid::Uuid; + +/// Grants a role to a user. +/// +/// # Permissions +/// - Only Admin can grant roles +/// - Cannot grant roles to another Admin +/// +/// # Errors +/// - Returns `Errors::UserAlreadyHasRole` if the user already has the role +pub async fn service_grant_role( + db: &DatabaseConnection, + target_user_id: Uuid, + role: Role, + expires_at: Option>, + reason: String, + session: &SessionContext, +) -> ServiceResult { + PermissionService::require_admin_for_target(db, Some(session), target_user_id).await?; + + let txn = db.begin().await?; + + let existing_roles = repository_find_user_roles(&txn, target_user_id).await?; + if existing_roles.contains(&role) { + return Err(Errors::UserAlreadyHasRole); + } + + repository_delete_expired_user_role(&txn, target_user_id, role).await?; + + let user_role = repository_create_user_role(&txn, target_user_id, role, expires_at).await?; + + repository_create_moderation_log( + &txn, + ModerationAction::UserGrantRole, + Some(session.user_id), + ModerationResourceType::User, + Some(target_user_id), + reason, + Some(json!({ + "role": role.as_str(), + "expires_at": expires_at + })), + ) + .await?; + + txn.commit().await?; + + info!(target_user_id = %target_user_id, role = %role, actor_id = %session.user_id, "Role granted"); + + Ok(GrantRoleResponse { + user_id: target_user_id, + role: user_role.role, + expires_at: user_role.expires_at, + }) +} diff --git a/crates/axumkit-server/src/service/user/management/mod.rs b/crates/axumkit-server/src/service/user/management/mod.rs new file mode 100644 index 0000000..508e16c --- /dev/null +++ b/crates/axumkit-server/src/service/user/management/mod.rs @@ -0,0 +1,4 @@ +pub mod ban_user; +pub mod grant_role; +pub mod revoke_role; +pub mod unban_user; diff --git a/crates/axumkit-server/src/service/user/management/revoke_role.rs b/crates/axumkit-server/src/service/user/management/revoke_role.rs new file mode 100644 index 0000000..e3ce7c3 --- /dev/null +++ b/crates/axumkit-server/src/service/user/management/revoke_role.rs @@ -0,0 +1,63 @@ +use crate::permission::PermissionService; +use crate::repository::moderation::repository_create_moderation_log; +use crate::repository::user::user_roles::{ + repository_delete_user_role, repository_find_user_roles, +}; +use crate::service::auth::session_types::SessionContext; +use axumkit_constants::ModerationAction; +use axumkit_dto::user::response::RevokeRoleResponse; +use axumkit_entity::common::{ModerationResourceType, Role}; +use axumkit_errors::errors::{Errors, ServiceResult}; +use sea_orm::{DatabaseConnection, TransactionTrait}; +use serde_json::json; +use tracing::info; +use uuid::Uuid; + +/// Revokes a role from a user. +/// +/// # Permissions +/// - Only Admin can revoke roles +/// - Cannot revoke roles from another Admin +/// +/// # Errors +/// - Returns `Errors::UserDoesNotHaveRole` if the user does not have the role +pub async fn service_revoke_role( + db: &DatabaseConnection, + target_user_id: Uuid, + role: Role, + reason: String, + session: &SessionContext, +) -> ServiceResult { + PermissionService::require_admin_for_target(db, Some(session), target_user_id).await?; + + let txn = db.begin().await?; + + let active_roles = repository_find_user_roles(&txn, target_user_id).await?; + if !active_roles.contains(&role) { + return Err(Errors::UserDoesNotHaveRole); + } + + repository_delete_user_role(&txn, target_user_id, role).await?; + + repository_create_moderation_log( + &txn, + ModerationAction::UserRevokeRole, + Some(session.user_id), + ModerationResourceType::User, + Some(target_user_id), + reason, + Some(json!({ + "role": role.as_str() + })), + ) + .await?; + + txn.commit().await?; + + info!(target_user_id = %target_user_id, role = %role, actor_id = %session.user_id, "Role revoked"); + + Ok(RevokeRoleResponse { + user_id: target_user_id, + role, + }) +} diff --git a/crates/axumkit-server/src/service/user/management/unban_user.rs b/crates/axumkit-server/src/service/user/management/unban_user.rs new file mode 100644 index 0000000..1ae9834 --- /dev/null +++ b/crates/axumkit-server/src/service/user/management/unban_user.rs @@ -0,0 +1,58 @@ +use crate::permission::PermissionService; +use crate::repository::moderation::repository_create_moderation_log; +use crate::repository::user::user_bans::{repository_delete_user_ban, repository_find_user_ban}; +use crate::service::auth::session_types::SessionContext; +use axumkit_constants::ModerationAction; +use axumkit_dto::user::response::UnbanUserResponse; +use axumkit_entity::common::ModerationResourceType; +use axumkit_errors::errors::{Errors, ServiceResult}; +use sea_orm::{DatabaseConnection, TransactionTrait}; +use tracing::info; +use uuid::Uuid; + +/// Unbans a user. +/// +/// # Permissions +/// - Only Admin can unban users +/// - Cannot unban another Admin +/// +/// # Errors +/// - Returns `Errors::UserNotBanned` if the user is not currently banned +pub async fn service_unban_user( + db: &DatabaseConnection, + target_user_id: Uuid, + reason: String, + session: &SessionContext, +) -> ServiceResult { + PermissionService::require_admin_for_target(db, Some(session), target_user_id).await?; + + let txn = db.begin().await?; + + if repository_find_user_ban(&txn, target_user_id) + .await? + .is_none() + { + return Err(Errors::UserNotBanned); + } + + repository_delete_user_ban(&txn, target_user_id).await?; + + repository_create_moderation_log( + &txn, + ModerationAction::UserUnban, + Some(session.user_id), + ModerationResourceType::User, + Some(target_user_id), + reason, + None, + ) + .await?; + + txn.commit().await?; + + info!(target_user_id = %target_user_id, actor_id = %session.user_id, "User unbanned"); + + Ok(UnbanUserResponse { + user_id: target_user_id, + }) +} diff --git a/crates/axumkit-server/src/service/user/mod.rs b/crates/axumkit-server/src/service/user/mod.rs index 2f8baf1..97a9f0b 100644 --- a/crates/axumkit-server/src/service/user/mod.rs +++ b/crates/axumkit-server/src/service/user/mod.rs @@ -1,10 +1,10 @@ pub mod check_handle_available; -pub mod create_user; pub mod delete_banner_image; pub mod delete_profile_image; pub mod get_my_profile; pub mod get_user_profile_by_handle; pub mod get_user_profile_by_id; +pub mod management; pub mod update_my_profile; pub mod upload_banner_image; pub mod upload_profile_image; diff --git a/crates/axumkit-server/src/state.rs b/crates/axumkit-server/src/state.rs index 7a13a34..c49a9bc 100644 --- a/crates/axumkit-server/src/state.rs +++ b/crates/axumkit-server/src/state.rs @@ -2,7 +2,7 @@ use redis::aio::ConnectionManager as RedisClient; use std::sync::Arc; use tokio::sync::broadcast; -use crate::connection::{MeilisearchClient, R2Client, SeaweedFsClient}; +use crate::connection::{MeilisearchClient, R2Client}; use axumkit_dto::action_logs::ActionLogResponse; use reqwest::Client as HttpClient; use sea_orm::DatabaseConnection as PostgresqlClient; @@ -18,10 +18,9 @@ pub type EventStreamSender = broadcast::Sender; #[derive(Clone)] pub struct AppState { - pub write_db: PostgresqlClient, - pub read_db: PostgresqlClient, + /// Database connection (via PgDog connection pooler) + pub db: PostgresqlClient, pub r2_client: R2Client, - pub seaweedfs_client: SeaweedFsClient, /// Redis for sessions, tokens, rate-limiting (persistent, AOF) pub redis_session: RedisClient, /// Redis for document cache (volatile, LRU eviction) diff --git a/crates/axumkit-server/src/utils/crypto/backup_code.rs b/crates/axumkit-server/src/utils/crypto/backup_code.rs index bd7f40f..6143e86 100644 --- a/crates/axumkit-server/src/utils/crypto/backup_code.rs +++ b/crates/axumkit-server/src/utils/crypto/backup_code.rs @@ -1,7 +1,7 @@ use axumkit_config::ServerConfig; -/// 백업 코드를 Blake3 keyed hash로 해시 -/// TOTP_SECRET을 키로 사용하여 암호학적으로 안전한 해시 생성 +/// Hash backup codes using Blake3 keyed hash +/// Uses TOTP_SECRET as the key to generate a cryptographically secure hash pub fn hash_backup_code(code: &str) -> String { let config = ServerConfig::get(); let key = blake3::derive_key("axumkit totp backup code v1", config.totp_secret.as_bytes()); @@ -10,13 +10,13 @@ pub fn hash_backup_code(code: &str) -> String { hasher.finalize().to_hex().to_string() } -/// 백업 코드 목록을 해시하여 반환 +/// Hash and return a list of backup codes pub fn hash_backup_codes(codes: &[String]) -> Vec { codes.iter().map(|c| hash_backup_code(c)).collect() } -/// 입력된 코드가 저장된 해시 목록 중 하나와 일치하는지 확인 -/// 일치하면 해당 인덱스 반환, 없으면 None +/// Check if the input code matches any of the stored hashes +/// Returns the matching index if found, None otherwise pub fn verify_backup_code(code: &str, stored_hashes: &[String]) -> Option { let input_hash = hash_backup_code(code); stored_hashes.iter().position(|h| h == &input_hash) diff --git a/crates/axumkit-server/src/utils/crypto/password.rs b/crates/axumkit-server/src/utils/crypto/password.rs index 8d2bb19..ecc333f 100644 --- a/crates/axumkit-server/src/utils/crypto/password.rs +++ b/crates/axumkit-server/src/utils/crypto/password.rs @@ -8,10 +8,10 @@ pub fn hash_password(password: &str) -> Result { // Use Argon2id with a minimum configuration of 19 MiB of memory, // an iteration count of 2, and 1 degree of parallelism. let params = Params::new( - 19 * 1024, // 19 MiB 메모리 (KB 단위) + 19 * 1024, // 19 MiB memory (in KB) 2, // iterations 1, // parallelism - None, // output length 기본값 (32 bytes) + None, // output length default (32 bytes) ) .map_err(|e| Errors::HashingError(e.to_string()))?; diff --git a/crates/axumkit-server/src/utils/crypto/token.rs b/crates/axumkit-server/src/utils/crypto/token.rs index 04f6b15..da043d2 100644 --- a/crates/axumkit-server/src/utils/crypto/token.rs +++ b/crates/axumkit-server/src/utils/crypto/token.rs @@ -1,14 +1,14 @@ use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use rand::Rng; +use rand::RngExt; -/// 암호학적으로 안전한 토큰 생성 (32바이트 = 256비트) -/// URL-safe Base64 인코딩으로 반환 (43자) +/// Generate a cryptographically secure token (32 bytes = 256 bits) +/// Returned as URL-safe Base64 encoding (43 characters) pub fn generate_secure_token() -> String { let token_bytes: [u8; 32] = rand::rng().random(); URL_SAFE_NO_PAD.encode(token_bytes) } -/// 지정된 길이의 암호학적으로 안전한 토큰 생성 +/// Generate a cryptographically secure token of specified length pub fn generate_secure_token_with_length(byte_length: usize) -> String { let token_bytes: Vec = (0..byte_length).map(|_| rand::rng().random()).collect(); URL_SAFE_NO_PAD.encode(&token_bytes) @@ -24,7 +24,7 @@ mod tests { // 32 bytes -> 43 chars in base64 (no padding) assert_eq!(token.len(), 43); - // 두 번 생성하면 다른 값 + // Two generated tokens should be different let token2 = generate_secure_token(); assert_ne!(token, token2); } diff --git a/crates/axumkit-server/src/utils/logger.rs b/crates/axumkit-server/src/utils/logger.rs index a1b5c1c..5cee0d0 100644 --- a/crates/axumkit-server/src/utils/logger.rs +++ b/crates/axumkit-server/src/utils/logger.rs @@ -6,8 +6,8 @@ use tracing_subscriber::{EnvFilter, Layer, fmt}; static TRACING_GUARD: LazyLock> = LazyLock::new(|| { - // EnvFilter: RUST_LOG 환경변수로 제어 가능 - // 기본값: debug 빌드는 debug, release 빌드는 info + // EnvFilter: controllable via RUST_LOG environment variable + // Default: debug for debug builds, info for release builds let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { #[cfg(debug_assertions)] let default = "debug"; @@ -20,7 +20,7 @@ static TRACING_GUARD: LazyLock String { let config = ServerConfig::get(); - format!("{}/{}", config.r2_public_domain, key) + format!("{}/{}", config.r2_assets_public_domain, key) } diff --git a/crates/axumkit-server/src/utils/redis_cache.rs b/crates/axumkit-server/src/utils/redis_cache.rs index 211605b..2cb635b 100644 --- a/crates/axumkit-server/src/utils/redis_cache.rs +++ b/crates/axumkit-server/src/utils/redis_cache.rs @@ -142,6 +142,54 @@ where } } +/// SET a JSON value only if the key does not exist (NX). Returns true if set. +pub async fn set_json_nx_with_ttl( + redis_client: &RedisClient, + key: &str, + value: &T, + ttl_seconds: u64, +) -> Result { + let json = serde_json::to_string(value).map_err(|e| { + Errors::SysInternalError(format!( + "JSON serialization failed for Redis key '{}': {}", + key, e + )) + })?; + + let mut conn = redis_client.clone(); + let result: Option = redis::cmd("SET") + .arg(key) + .arg(json) + .arg("NX") + .arg("EX") + .arg(ttl_seconds) + .query_async(&mut conn) + .await + .map_err(|e| { + Errors::SysInternalError(format!("Redis SET NX failed for key '{}': {}", key, e)) + })?; + + Ok(matches!(result, Some(v) if v == "OK")) +} + +/// Get the remaining TTL of a key in seconds. Returns None if key doesn't exist. +pub async fn get_ttl_seconds(redis_client: &RedisClient, key: &str) -> Result, Errors> { + let mut conn = redis_client.clone(); + let ttl: i64 = redis::cmd("TTL") + .arg(key) + .query_async(&mut conn) + .await + .map_err(|e| { + Errors::SysInternalError(format!("Redis TTL failed for key '{}': {}", key, e)) + })?; + + if ttl < 0 { + Ok(None) + } else { + Ok(Some(ttl as u64)) + } +} + /// Delete a key from Redis pub async fn delete_key(redis_client: &RedisClient, key: &str) -> Result<(), Errors> { let mut conn = redis_client.clone(); diff --git a/crates/axumkit-server/src/utils/session_helper.rs b/crates/axumkit-server/src/utils/session_helper.rs index 835f6f9..c3d6eae 100644 --- a/crates/axumkit-server/src/utils/session_helper.rs +++ b/crates/axumkit-server/src/utils/session_helper.rs @@ -3,16 +3,16 @@ use axumkit_errors::errors::Errors; use sea_orm::prelude::IpNetwork; use uuid::Uuid; -/// SessionContext에서 user_id와 IP를 추출 +/// Extract user_id and IP from SessionContext /// /// # Returns -/// - 로그인 사용자: `(Some(user_id), Some(ip_network))` -/// - 익명 사용자: `(None, Some(ip_network))` +/// - Logged-in user: `(Some(user_id), Some(ip_network))` +/// - Anonymous user: `(None, Some(ip_network))` /// -/// IP는 항상 기록됨 (다중 계정 탐지, 차단 회피 추적용) +/// IP is always recorded (for multi-account detection, ban evasion tracking) /// /// # Errors -/// - `Errors::InvalidIpAddress` - IP 주소 파싱 실패 시 +/// - `Errors::InvalidIpAddress` - When IP address parsing fails pub fn extract_user_or_ip( session: Option<&SessionContext>, ip_address: &str, diff --git a/crates/axumkit-worker/src/config/worker_config.rs b/crates/axumkit-worker/src/config/worker_config.rs index 504fb27..94299b5 100644 --- a/crates/axumkit-worker/src/config/worker_config.rs +++ b/crates/axumkit-worker/src/config/worker_config.rs @@ -23,6 +23,9 @@ pub struct WorkerConfig { // Redis Cache (View counts, etc.) pub redis_cache_host: String, pub redis_cache_port: String, + // Redis Session (persistent store for cron locks) + pub redis_session_host: String, + pub redis_session_port: String, // Frontend & Project pub frontend_host: String, @@ -43,16 +46,14 @@ pub struct WorkerConfig { // Cron pub cron_timezone: String, - // SeaweedFS (revision content storage) - pub seaweedfs_endpoint: String, - - // R2 (Sitemap storage) + // Cloudflare R2 (shared credentials) pub r2_endpoint: String, pub r2_region: String, pub r2_access_key_id: String, pub r2_secret_access_key: String, - pub r2_bucket_name: String, - pub r2_public_domain: String, + // R2 Assets (public bucket - images, sitemap) + pub r2_assets_bucket_name: String, + pub r2_assets_public_domain: String, } static CONFIG: LazyLock = LazyLock::new(|| { @@ -84,12 +85,11 @@ static CONFIG: LazyLock = LazyLock::new(|| { let db_write_name = require!("POSTGRES_WRITE_NAME"); let db_write_user = require!("POSTGRES_WRITE_USER"); let db_write_password = require!("POSTGRES_WRITE_PASSWORD"); - let seaweedfs_endpoint = require!("SEAWEEDFS_ENDPOINT"); let r2_endpoint = require!("R2_ENDPOINT"); let r2_access_key_id = require!("R2_ACCESS_KEY_ID"); let r2_secret_access_key = require!("R2_SECRET_ACCESS_KEY"); - let r2_bucket_name = require!("R2_BUCKET_NAME"); - let r2_public_domain = require!("R2_PUBLIC_DOMAIN"); + let r2_assets_bucket_name = require!("R2_ASSETS_BUCKET_NAME"); + let r2_assets_public_domain = require!("R2_ASSETS_PUBLIC_DOMAIN"); // Panic with all errors at once if !errors.is_empty() { @@ -128,6 +128,9 @@ static CONFIG: LazyLock = LazyLock::new(|| { // Redis Cache redis_cache_host: env::var("REDIS_CACHE_HOST").unwrap_or_else(|_| "127.0.0.1".into()), redis_cache_port: env::var("REDIS_CACHE_PORT").unwrap_or_else(|_| "6380".into()), + // Redis Session + redis_session_host: env::var("REDIS_SESSION_HOST").unwrap_or_else(|_| "127.0.0.1".into()), + redis_session_port: env::var("REDIS_SESSION_PORT").unwrap_or_else(|_| "6379".into()), // Frontend & Project frontend_host, @@ -154,16 +157,14 @@ static CONFIG: LazyLock = LazyLock::new(|| { // Cron cron_timezone: env::var("CRON_TIMEZONE").unwrap_or_else(|_| "UTC".into()), - // SeaweedFS - seaweedfs_endpoint, - - // R2 + // Cloudflare R2 (shared credentials) r2_endpoint, r2_region: env::var("R2_REGION").unwrap_or_else(|_| "auto".into()), r2_access_key_id, r2_secret_access_key, - r2_bucket_name, - r2_public_domain, + // R2 Assets (public bucket) + r2_assets_bucket_name, + r2_assets_public_domain, } }); @@ -179,6 +180,13 @@ impl WorkerConfig { ) } + pub fn redis_session_url(&self) -> String { + format!( + "redis://{}:{}", + self.redis_session_host, self.redis_session_port + ) + } + pub fn database_url(&self) -> String { format!( "postgres://{}:{}@{}:{}/{}", diff --git a/crates/axumkit-worker/src/connection/mod.rs b/crates/axumkit-worker/src/connection/mod.rs index b4b3fa7..b02fa64 100644 --- a/crates/axumkit-worker/src/connection/mod.rs +++ b/crates/axumkit-worker/src/connection/mod.rs @@ -1,7 +1,5 @@ mod database_conn; mod r2_conn; -mod seaweedfs_conn; pub use database_conn::establish_connection; pub use r2_conn::{R2Client, establish_r2_connection}; -pub use seaweedfs_conn::{SeaweedFsClient, StorageObjectInfo, establish_seaweedfs_connection}; diff --git a/crates/axumkit-worker/src/connection/r2_conn.rs b/crates/axumkit-worker/src/connection/r2_conn.rs index e5d0fd0..6d99c03 100644 --- a/crates/axumkit-worker/src/connection/r2_conn.rs +++ b/crates/axumkit-worker/src/connection/r2_conn.rs @@ -84,10 +84,10 @@ impl R2Client { { Ok(_) => Ok(true), Err(err) => { - // SdkError를 사용하여 더 정확한 에러 처리 + // Use SdkError for more precise error handling match &err { SdkError::ServiceError(service_err) => { - // 404 Not Found 에러인지 확인 + // Check if it is a 404 Not Found error if service_err.err().is_not_found() { Ok(false) } else { @@ -100,7 +100,7 @@ impl R2Client { } } - // 추가: 스트림으로 업로드 (큰 파일용) + // Additional: stream upload (for large files) pub async fn upload_file( &self, key: &str, @@ -161,8 +161,8 @@ pub async fn establish_r2_connection(config: &WorkerConfig) -> anyhow::Result, - bucket: String, -} - -impl SeaweedFsClient { - pub fn new(client: Client, bucket: String) -> Self { - Self { - client: Arc::new(client), - bucket, - } - } - - /// 콘텐츠 다운로드 (zstd 압축 해제) - pub async fn download_content( - &self, - key: &str, - ) -> Result> { - let resp = self - .client - .get_object() - .bucket(&self.bucket) - .key(key) - .send() - .await?; - - let data = resp.body.collect().await?; - let bytes = data.into_bytes(); - - let decompressed = zstd::decode_all(bytes.as_ref())?; - let content = String::from_utf8(decompressed)?; - - Ok(content) - } - - /// 오브젝트 삭제 - pub async fn delete(&self, key: &str) -> Result<(), Box> { - self.client - .delete_object() - .bucket(&self.bucket) - .key(key) - .send() - .await?; - Ok(()) - } - - /// 오브젝트 목록 조회 (페이지네이션) - /// Returns (keys, continuation_token) - pub async fn list_objects( - &self, - continuation_token: Option<&str>, - max_keys: i32, - ) -> Result<(Vec, Option), Box> - { - let mut request = self - .client - .list_objects_v2() - .bucket(&self.bucket) - .max_keys(max_keys); - - if let Some(token) = continuation_token { - request = request.continuation_token(token); - } - - let resp = request.send().await?; - - let objects: Vec = resp - .contents() - .iter() - .filter_map(|obj| { - let key = obj.key()?.to_string(); - let last_modified = obj.last_modified().map(|t| { - chrono::DateTime::from_timestamp(t.secs(), t.subsec_nanos()).unwrap_or_default() - }); - Some(StorageObjectInfo { key, last_modified }) - }) - .collect(); - - let next_token = resp.next_continuation_token().map(|s| s.to_string()); - - Ok((objects, next_token)) - } -} - -/// Storage object info for cleanup -#[derive(Debug, Clone)] -pub struct StorageObjectInfo { - pub key: String, - pub last_modified: Option>, -} - -const BUCKET_NAME: &str = "axumkit-content"; - -pub async fn establish_seaweedfs_connection( - config: &WorkerConfig, -) -> anyhow::Result { - info!("Connecting to SeaweedFS at: {}", config.seaweedfs_endpoint); - - let aws_config = aws_config::defaults(BehaviorVersion::latest()) - .region(Region::new("us-east-1")) - .endpoint_url(&config.seaweedfs_endpoint) - .credentials_provider(aws_sdk_s3::config::Credentials::new( - "", - "", - None, - None, - "anonymous", - )) - .load() - .await; - - let s3_config = aws_sdk_s3::config::Builder::from(&aws_config) - .force_path_style(true) - .build(); - - let client = Client::from_conf(s3_config); - let seaweedfs_client = SeaweedFsClient::new(client, BUCKET_NAME.to_string()); - - info!("Successfully connected to SeaweedFS"); - Ok(seaweedfs_client) -} diff --git a/crates/axumkit-worker/src/jobs/cron/cleanup_orphaned_blobs.rs b/crates/axumkit-worker/src/jobs/cron/cleanup_orphaned_blobs.rs deleted file mode 100644 index 4641d26..0000000 --- a/crates/axumkit-worker/src/jobs/cron/cleanup_orphaned_blobs.rs +++ /dev/null @@ -1,118 +0,0 @@ -use crate::connection::SeaweedFsClient; -use axumkit_entity::posts; -use chrono::{Duration, Utc}; -use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QuerySelect}; -use std::collections::HashSet; - -/// Batch size for listing objects from storage -const BATCH_SIZE: i32 = 1000; - -/// Minimum age in hours before a blob can be considered orphaned -/// (to avoid race conditions with in-flight transactions) -const MIN_AGE_HOURS: i64 = 1; - -/// Run orphaned blob cleanup -/// -/// Finds storage keys in SeaweedFS that don't exist in posts table -/// and deletes them. Only deletes blobs older than MIN_AGE_HOURS to avoid -/// race conditions with in-flight transactions. -pub async fn run_cleanup_orphaned_blobs(db: &DatabaseConnection, storage: &SeaweedFsClient) { - tracing::info!( - batch_size = BATCH_SIZE, - min_age_hours = MIN_AGE_HOURS, - "Starting orphaned blob cleanup" - ); - - let cutoff_time = Utc::now() - Duration::hours(MIN_AGE_HOURS); - let mut continuation_token: Option = None; - let mut total_checked = 0u64; - let mut total_deleted = 0u64; - let mut total_errors = 0u64; - - loop { - // 1. List objects from storage - let (objects, next_token) = match storage - .list_objects(continuation_token.as_deref(), BATCH_SIZE) - .await - { - Ok(result) => result, - Err(e) => { - tracing::error!(error = %e, "Failed to list objects from storage"); - break; - } - }; - - if objects.is_empty() { - break; - } - - // 2. Filter to only old enough objects - let old_objects: Vec<_> = objects - .into_iter() - .filter(|obj| obj.last_modified.map(|t| t < cutoff_time).unwrap_or(false)) - .collect(); - - if !old_objects.is_empty() { - let keys: Vec = old_objects.iter().map(|o| o.key.clone()).collect(); - total_checked += keys.len() as u64; - - // 3. Check which keys exist in DB - let existing_keys = match find_existing_storage_keys(db, &keys).await { - Ok(keys) => keys, - Err(e) => { - tracing::error!(error = %e, "Failed to query existing storage keys"); - break; - } - }; - - // 4. Delete orphaned keys (keys that don't exist in DB) - for obj in old_objects { - if !existing_keys.contains(&obj.key) { - match storage.delete(&obj.key).await { - Ok(_) => { - total_deleted += 1; - tracing::debug!(key = %obj.key, "Deleted orphaned blob"); - } - Err(e) => { - total_errors += 1; - tracing::warn!(key = %obj.key, error = %e, "Failed to delete orphaned blob"); - } - } - } - } - } - - // Continue to next page - continuation_token = next_token; - if continuation_token.is_none() { - break; - } - } - - tracing::info!( - total_checked = total_checked, - total_deleted = total_deleted, - total_errors = total_errors, - "Orphaned blob cleanup completed" - ); -} - -/// Find which storage keys exist in posts table -async fn find_existing_storage_keys( - db: &DatabaseConnection, - keys: &[String], -) -> Result, anyhow::Error> { - if keys.is_empty() { - return Ok(HashSet::new()); - } - - let results: Vec = posts::Entity::find() - .select_only() - .column(posts::Column::StorageKey) - .filter(posts::Column::StorageKey.is_in(keys.to_vec())) - .into_tuple() - .all(db) - .await?; - - Ok(results.into_iter().collect()) -} diff --git a/crates/axumkit-worker/src/jobs/cron/lua/extend_lock.lua b/crates/axumkit-worker/src/jobs/cron/lua/extend_lock.lua new file mode 100644 index 0000000..0e51e41 --- /dev/null +++ b/crates/axumkit-worker/src/jobs/cron/lua/extend_lock.lua @@ -0,0 +1,5 @@ +if redis.call("GET", KEYS[1]) == ARGV[1] then + return redis.call("EXPIRE", KEYS[1], ARGV[2]) +else + return 0 +end diff --git a/crates/axumkit-worker/src/jobs/cron/lua/release_lock.lua b/crates/axumkit-worker/src/jobs/cron/lua/release_lock.lua new file mode 100644 index 0000000..e9aa40a --- /dev/null +++ b/crates/axumkit-worker/src/jobs/cron/lua/release_lock.lua @@ -0,0 +1,5 @@ +if redis.call("GET", KEYS[1]) == ARGV[1] then + return redis.call("DEL", KEYS[1]) +else + return 0 +end diff --git a/crates/axumkit-worker/src/jobs/cron/mod.rs b/crates/axumkit-worker/src/jobs/cron/mod.rs index c351649..94bc1e1 100644 --- a/crates/axumkit-worker/src/jobs/cron/mod.rs +++ b/crates/axumkit-worker/src/jobs/cron/mod.rs @@ -1,14 +1,18 @@ mod cleanup; -mod cleanup_orphaned_blobs; pub mod sitemap; use crate::DbPool; +use crate::SessionClient; use crate::config::WorkerConfig; use crate::connection::R2Client; -use crate::connection::SeaweedFsClient; use chrono_tz::Tz; +use redis::Script; +use std::pin::pin; use std::sync::Arc; +use std::sync::LazyLock; +use std::time::Duration; use tokio_cron_scheduler::{Job, JobBuilder, JobScheduler, JobSchedulerError}; +use uuid::Uuid; /// Cleanup cron schedule: 4:00 AM every Saturday /// Format: "sec min hour day month weekday" @@ -17,14 +21,24 @@ const CLEANUP_SCHEDULE: &str = "0 0 4 * * 6"; /// Sitemap cron schedule: 3:00 AM every Sunday const SITEMAP_SCHEDULE: &str = "0 0 3 * * 0"; -/// Orphaned blob cleanup schedule: 5:00 AM every Friday -const ORPHANED_BLOB_CLEANUP_SCHEDULE: &str = "0 0 5 * * 5"; +/// Distributed lock TTL for cron jobs (seconds). +const CRON_LOCK_TTL_SECONDS: u64 = 60 * 30; // 30 minutes +/// Heartbeat interval for lock extension (seconds). +const CRON_LOCK_HEARTBEAT_SECONDS: u64 = 60 * 10; // 10 minutes -/// Create and start the cron scheduler +const CLEANUP_LOCK_KEY: &str = "cron:lock:cleanup"; +const SITEMAP_LOCK_KEY: &str = "cron:lock:sitemap"; + +static RELEASE_LOCK_SCRIPT: LazyLock