From 11f48fd3ddc565bbf79b6a5d4ed0a17fa26a177f Mon Sep 17 00:00:00 2001 From: yuyx <237899745@qq.com> Date: Fri, 19 Dec 2025 23:28:32 +0800 Subject: [PATCH] Implement compression quota refunds and admin manual subscription --- .cargo/config.toml | 6 + .env.example | 65 + .gitignore | 13 + Cargo.lock | 4997 +++++++++++++++++ Cargo.toml | 56 + README.md | 149 + docker/Dockerfile | 46 + docker/docker-compose.dev.yml | 25 + docker/nginx.conf | 53 + docs/api.md | 685 +++ docs/architecture.md | 334 ++ docs/billing.md | 191 + docs/confirm.md | 46 + docs/database.md | 583 ++ docs/deployment.md | 711 +++ docs/email.md | 471 ++ docs/frontend.md | 158 + docs/observability.md | 99 + docs/prd.md | 162 + docs/privacy.md | 114 + docs/security.md | 119 + docs/terms.md | 117 + docs/ui.md | 134 + frontend/.gitignore | 24 + frontend/.vscode/extensions.json | 3 + frontend/README.md | 5 + frontend/index.html | 13 + frontend/package-lock.json | 2756 +++++++++ frontend/package.json | 29 + frontend/postcss.config.js | 6 + frontend/public/vite.svg | 1 + frontend/src/App.vue | 3 + frontend/src/app/layouts/AdminLayout.vue | 69 + frontend/src/app/layouts/DashboardLayout.vue | 91 + frontend/src/app/layouts/PublicLayout.vue | 86 + frontend/src/app/router.ts | 93 + frontend/src/assets/vue.svg | 1 + frontend/src/components/HelloWorld.vue | 41 + frontend/src/main.ts | 20 + frontend/src/pages/DocsPage.vue | 30 + frontend/src/pages/ForgotPasswordPage.vue | 75 + frontend/src/pages/HomePage.vue | 422 ++ frontend/src/pages/LoginPage.vue | 93 + frontend/src/pages/NotFoundPage.vue | 17 + frontend/src/pages/PricingPage.vue | 73 + frontend/src/pages/PrivacyPage.vue | 18 + frontend/src/pages/RegisterPage.vue | 100 + frontend/src/pages/ResetPasswordPage.vue | 83 + frontend/src/pages/TermsPage.vue | 20 + frontend/src/pages/VerifyEmailPage.vue | 61 + frontend/src/pages/admin/AdminBillingPage.vue | 354 ++ frontend/src/pages/admin/AdminConfigPage.vue | 134 + frontend/src/pages/admin/AdminHomePage.vue | 70 + .../src/pages/admin/AdminIntegrationsPage.vue | 464 ++ frontend/src/pages/admin/AdminTasksPage.vue | 204 + frontend/src/pages/admin/AdminUsersPage.vue | 163 + .../pages/dashboard/DashboardApiKeysPage.vue | 236 + .../pages/dashboard/DashboardBillingPage.vue | 202 + .../pages/dashboard/DashboardHistoryPage.vue | 344 ++ .../src/pages/dashboard/DashboardHomePage.vue | 143 + .../pages/dashboard/DashboardSettingsPage.vue | 275 + frontend/src/services/api.ts | 524 ++ frontend/src/services/http.ts | 129 + frontend/src/stores/auth.ts | 66 + frontend/src/style.css | 45 + frontend/src/utils/format.ts | 23 + frontend/tailwind.config.js | 10 + frontend/tsconfig.app.json | 20 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 26 + frontend/vite.config.ts | 29 + migrations/001_init.sql | 423 ++ migrations/002_task_client_ip.sql | 9 + migrations/003_add_compression_rate.sql | 6 + migrations/004_add_bonus_units.sql | 11 + src/api/admin.rs | 1553 +++++ src/api/auth.rs | 596 ++ src/api/billing.rs | 738 +++ src/api/compress.rs | 1218 ++++ src/api/context.rs | 229 + src/api/downloads.rs | 343 ++ src/api/envelope.rs | 8 + src/api/health.rs | 39 + src/api/mod.rs | 66 + src/api/response.rs | 6 + src/api/tasks.rs | 987 ++++ src/api/user.rs | 912 +++ src/api/webhooks.rs | 520 ++ src/auth.rs | 60 + src/config.rs | 161 + src/error.rs | 136 + src/main.rs | 64 + src/services/billing.rs | 135 + src/services/bootstrap.rs | 204 + src/services/compress.rs | 533 ++ src/services/idempotency.rs | 341 ++ src/services/mail.rs | 344 ++ src/services/mod.rs | 7 + src/services/quota.rs | 73 + src/services/settings.rs | 227 + src/state.rs | 10 + src/worker/mod.rs | 738 +++ templates/email_verification.html | 44 + templates/email_verification.txt | 14 + templates/password_reset.html | 44 + templates/password_reset.txt | 14 + 106 files changed, 27848 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 docker/Dockerfile create mode 100644 docker/docker-compose.dev.yml create mode 100644 docker/nginx.conf create mode 100644 docs/api.md create mode 100644 docs/architecture.md create mode 100644 docs/billing.md create mode 100644 docs/confirm.md create mode 100644 docs/database.md create mode 100644 docs/deployment.md create mode 100644 docs/email.md create mode 100644 docs/frontend.md create mode 100644 docs/observability.md create mode 100644 docs/prd.md create mode 100644 docs/privacy.md create mode 100644 docs/security.md create mode 100644 docs/terms.md create mode 100644 docs/ui.md create mode 100644 frontend/.gitignore create mode 100644 frontend/.vscode/extensions.json create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/vite.svg create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/app/layouts/AdminLayout.vue create mode 100644 frontend/src/app/layouts/DashboardLayout.vue create mode 100644 frontend/src/app/layouts/PublicLayout.vue create mode 100644 frontend/src/app/router.ts create mode 100644 frontend/src/assets/vue.svg create mode 100644 frontend/src/components/HelloWorld.vue create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/pages/DocsPage.vue create mode 100644 frontend/src/pages/ForgotPasswordPage.vue create mode 100644 frontend/src/pages/HomePage.vue create mode 100644 frontend/src/pages/LoginPage.vue create mode 100644 frontend/src/pages/NotFoundPage.vue create mode 100644 frontend/src/pages/PricingPage.vue create mode 100644 frontend/src/pages/PrivacyPage.vue create mode 100644 frontend/src/pages/RegisterPage.vue create mode 100644 frontend/src/pages/ResetPasswordPage.vue create mode 100644 frontend/src/pages/TermsPage.vue create mode 100644 frontend/src/pages/VerifyEmailPage.vue create mode 100644 frontend/src/pages/admin/AdminBillingPage.vue create mode 100644 frontend/src/pages/admin/AdminConfigPage.vue create mode 100644 frontend/src/pages/admin/AdminHomePage.vue create mode 100644 frontend/src/pages/admin/AdminIntegrationsPage.vue create mode 100644 frontend/src/pages/admin/AdminTasksPage.vue create mode 100644 frontend/src/pages/admin/AdminUsersPage.vue create mode 100644 frontend/src/pages/dashboard/DashboardApiKeysPage.vue create mode 100644 frontend/src/pages/dashboard/DashboardBillingPage.vue create mode 100644 frontend/src/pages/dashboard/DashboardHistoryPage.vue create mode 100644 frontend/src/pages/dashboard/DashboardHomePage.vue create mode 100644 frontend/src/pages/dashboard/DashboardSettingsPage.vue create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/src/services/http.ts create mode 100644 frontend/src/stores/auth.ts create mode 100644 frontend/src/style.css create mode 100644 frontend/src/utils/format.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 migrations/001_init.sql create mode 100644 migrations/002_task_client_ip.sql create mode 100644 migrations/003_add_compression_rate.sql create mode 100644 migrations/004_add_bonus_units.sql create mode 100644 src/api/admin.rs create mode 100644 src/api/auth.rs create mode 100644 src/api/billing.rs create mode 100644 src/api/compress.rs create mode 100644 src/api/context.rs create mode 100644 src/api/downloads.rs create mode 100644 src/api/envelope.rs create mode 100644 src/api/health.rs create mode 100644 src/api/mod.rs create mode 100644 src/api/response.rs create mode 100644 src/api/tasks.rs create mode 100644 src/api/user.rs create mode 100644 src/api/webhooks.rs create mode 100644 src/auth.rs create mode 100644 src/config.rs create mode 100644 src/error.rs create mode 100644 src/main.rs create mode 100644 src/services/billing.rs create mode 100644 src/services/bootstrap.rs create mode 100644 src/services/compress.rs create mode 100644 src/services/idempotency.rs create mode 100644 src/services/mail.rs create mode 100644 src/services/mod.rs create mode 100644 src/services/quota.rs create mode 100644 src/services/settings.rs create mode 100644 src/state.rs create mode 100644 src/worker/mod.rs create mode 100644 templates/email_verification.html create mode 100644 templates/email_verification.txt create mode 100644 templates/password_reset.html create mode 100644 templates/password_reset.txt diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..6e0446f --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,6 @@ +[http] +timeout = 600 +low-speed-limit = 1 + +[net] +retry = 10 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2c26b06 --- /dev/null +++ b/.env.example @@ -0,0 +1,65 @@ +# 运行模式:api | worker +IMAGEFORGE_ROLE=api + +# 服务配置 +HOST=0.0.0.0 +PORT=8080 +PUBLIC_BASE_URL=http://localhost:8080 +RUST_LOG=info,tower_http=info,imageforge=debug + +# 数据库 +DATABASE_URL=postgres://imageforge:devpassword@localhost:5432/imageforge +DATABASE_MAX_CONNECTIONS=10 + +# Redis +REDIS_URL=redis://localhost:6379 + +# JWT(网站/管理后台) +JWT_SECRET=your-super-secret-key-change-in-production +JWT_EXPIRY_HOURS=168 + +# API Key(仅 Pro/Business 可创建) +API_KEY_PEPPER=please-change-this-in-production + +# 存储(生产建议 S3/MinIO + 预签名 URL) +STORAGE_TYPE=local # local | s3 +STORAGE_PATH=./uploads +SIGNED_URL_TTL_MINUTES=60 + +# S3 配置(如果使用 S3/MinIO) +# S3_ENDPOINT=http://localhost:9000 +# S3_BUCKET=your-bucket +# S3_REGION=us-east-1 +# S3_ACCESS_KEY=xxx +# S3_SECRET_KEY=xxx + +# 计费(已确认:Stripe) +BILLING_PROVIDER=stripe +STRIPE_SECRET_KEY=sk_test_xxx +STRIPE_WEBHOOK_SECRET=whsec_xxx + +# 邮件服务(注册验证 + 密码重置) +MAIL_ENABLED=false +MAIL_LOG_LINKS_WHEN_DISABLED=true +MAIL_PROVIDER=qq # qq | 163 | aliyun_enterprise | tencent_enterprise | gmail | outlook | custom +MAIL_FROM=noreply@example.com +MAIL_PASSWORD=your-smtp-authorization-code +MAIL_FROM_NAME=ImageForge +# MAIL_SMTP_HOST=smtp.example.com +# MAIL_SMTP_PORT=465 +# MAIL_SMTP_ENCRYPTION=ssl # ssl | starttls | none + +# 限制(默认值;最终以套餐/用户覆盖为准) +ALLOW_ANONYMOUS_UPLOAD=true +ANON_MAX_FILE_SIZE_MB=5 +ANON_MAX_FILES_PER_BATCH=5 +ANON_DAILY_UNITS=10 +MAX_IMAGE_PIXELS=40000000 +IDEMPOTENCY_TTL_HOURS=24 + +# 结果保留(匿名默认;登录用户按套餐 retention_days) +ANON_RETENTION_HOURS=24 + +# 管理员初始账户(首启可自动创建) +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=changeme123 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8896adc --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +/target +/.sqlx +/.env +/.env.* +!/\.env.example + +/uploads +/static + +/frontend/node_modules +/frontend/dist + +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..590d1ca --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4997 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "ar_archive_writer" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c269894b6fe5e9d7ada0cf69b5bf847ff35bc25fc271f08e1d080fce80339a" +dependencies = [ + "object", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "async-compression" +version = "0.4.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ec5f6c2f8bc326c994cb9e241cc257ddaba9afa8555a43cffbb5dd86efaa37" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "av-data" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fca67ba5d317924c02180c576157afd54babe48a76ebc66ce6d34bb8ba08308e" +dependencies = [ + "byte-slice-cast", + "bytes", + "num-derive", + "num-rational", + "num-traits", +] + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.17", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "axum" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbfe9f610fe4e99cf0cfcd03ccf8c63c28c616fe714d80475ef731f3b13dd21b" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitreader" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "886559b1e163d56c765bc3a985febb4eee8009f625244511d8ee3c432e08c066" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "bitstream-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" + +[[package]] +name = "bitstream-io" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +dependencies = [ + "core2", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "built" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" + +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "cc" +version = "1.2.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon 0.12.16", +] + +[[package]] +name = "cfg-expr" +version = "0.20.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21be0e1ce6cdb2ee7fff840f922fb04ead349e5cfb1e750b769132d44ce04720" +dependencies = [ + "smallvec", + "target-lexicon 0.13.3", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[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 = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "compression-codecs" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f7ac3e5b97fdce45e8922fb05cae2c37f7bbd63d30dd94821dacfd8f3f2bf2" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "dav1d" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80c3f80814db85397819d464bb553268992c393b4b3b5554b89c1655996d5926" +dependencies = [ + "av-data", + "bitflags", + "dav1d-sys", + "static_assertions", +] + +[[package]] +name = "dav1d-sys" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3c91aea6668645415331133ed6f8ddf0e7f40160cd97a12d59e68716a58704b" +dependencies = [ + "libc", + "system-deps 7.0.7", +] + +[[package]] +name = "deflate64" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "log", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fallible_collections" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88c69768c0a15262df21899142bc6df9b9b823546d4b4b9a7bc2d6c448ec6fd" +dependencies = [ + "hashbrown 0.13.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "filetime" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.60.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls 0.23.35", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.4", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.1", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "dav1d", + "exr", + "gif", + "image-webp", + "moxcms", + "mp4parse", + "num-traits", + "png", + "qoi", + "ravif 0.12.0", + "rayon", + "rgb", + "tiff", + "zune-core 0.5.0", + "zune-jpeg 0.5.7", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imageforge" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "argon2", + "axum", + "axum-extra", + "base64 0.22.1", + "chrono", + "dotenvy", + "hex", + "hmac", + "image", + "img-parts", + "jsonwebtoken", + "lettre", + "oxipng", + "rand 0.8.5", + "ravif 0.11.20", + "redis", + "reqwest", + "rgb", + "serde", + "serde_json", + "sha2", + "sqlx", + "thiserror 1.0.69", + "time", + "tokio", + "tokio-util", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", + "webp", + "zip", +] + +[[package]] +name = "img-parts" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19734e3c43b2a850f5889c077056e47c874095f2d87e853c7c41214ae67375f0" +dependencies = [ + "bytes", + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + +[[package]] +name = "indexmap" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "rayon", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "lettre" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "rustls 0.23.35", + "socket2 0.6.1", + "tokio", + "tokio-rustls", + "url", + "webpki-roots 1.0.4", +] + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "libdeflate-sys" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23bd6304ebf75390d8a99b88bdf2a266f62647838140cb64af8e6702f6e3fddc" +dependencies = [ + "cc", +] + +[[package]] +name = "libdeflater" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d4880e6d634d3d029d65fa016038e788cc728a17b782684726fb34ee140caf" +dependencies = [ + "libdeflate-sys", +] + +[[package]] +name = "libfuzzer-sys" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libredox" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50" +dependencies = [ + "bitflags", + "libc", + "redox_syscall 0.6.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libwebp-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54cd30df7c7165ce74a456e4ca9732c603e8dc5e60784558c1c6dc047f876733" +dependencies = [ + "cc", + "glob", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "mp4parse" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63a35203d3c6ce92d5251c77520acb2e57108c88728695aa883f70023624c570" +dependencies = [ + "bitreader", + "byteorder", + "fallible_collections", + "log", + "num-traits", + "static_assertions", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "nasm-rs" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4d98d0065f4b1daf164b3eafb11974c94662e5e2396cf03f32d0bb5c17da51" +dependencies = [ + "rayon", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "oxipng" +version = "9.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c613f0f566526a647c7473f6a8556dbce22c91b13485ee4b4ec7ab648e4973" +dependencies = [ + "bitvec", + "clap", + "crossbeam-channel", + "env_logger", + "filetime", + "glob", + "indexmap", + "libdeflater", + "log", + "rayon", + "rgb", + "rustc-hash", + "zopfli", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn 2.0.111", +] + +[[package]] +name = "psm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d11f2fedc3b7dafdc2851bc52f277377c5473d378859be234bc7ebb593144d01" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "pxfm" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.35", + "socket2 0.6.1", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls 0.23.35", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.1", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rav1e" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9" +dependencies = [ + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av1-grain", + "bitstream-io 2.6.0", + "built 0.7.7", + "cc", + "cfg-if", + "interpolate_name", + "itertools 0.12.1", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "nasm-rs", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "once_cell", + "paste", + "profiling", + "rand 0.8.5", + "rand_chacha 0.3.1", + "simd_helpers", + "system-deps 6.2.2", + "thiserror 1.0.69", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io 4.9.0", + "built 0.8.0", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.2", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.17", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.11.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e 0.7.1", + "rayon", + "rgb", +] + +[[package]] +name = "ravif" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e 0.8.1", + "rayon", + "rgb", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redis" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c580d9cbbe1d1b479e8d67cf9daf6a62c957e6846048408b80b43ac3f6af84cd" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "combine", + "futures", + "futures-util", + "itoa", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.4.10", + "tokio", + "tokio-retry", + "tokio-util", + "url", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec96166dafa0886eb81fe1c0a388bece180fbef2135f97c1e2cf8302e74b43b5" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.12.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.35", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.4", +] + +[[package]] +name = "rgb" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a0376c50d0358279d9d643e4bf7b7be212f1f4ff1da9070a7b54d22ef75c88" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.8", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.17", + "time", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlformat" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" +dependencies = [ + "nom 7.1.3", + "unicode_categories", +] + +[[package]] +name = "sqlx" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9a2ccff1a000a5a59cd33da541d9f2fdcd9e6e8229cc200565942bff36d0aaa" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ba59a9342a3d9bab6c56c118be528b27c9b60e490080e9711a04dccac83ef6" +dependencies = [ + "ahash", + "atoi", + "byteorder", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-channel", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashlink", + "hex", + "indexmap", + "log", + "memchr", + "once_cell", + "paste", + "percent-encoding", + "rustls 0.21.12", + "rustls-pemfile", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlformat", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.25.4", +] + +[[package]] +name = "sqlx-macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea40e2345eb2faa9e1e5e326db8c34711317d2b5e08d0d5741619048a803127" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 1.0.109", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5833ef53aaa16d860e92123292f1f6a3d53c34ba8b1969f152ef1a7bb803f3c8" +dependencies = [ + "dotenvy", + "either", + "heck 0.4.1", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 1.0.109", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418" +dependencies = [ + "atoi", + "base64 0.21.7", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 1.0.69", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e" +dependencies = [ + "atoi", + "base64 0.21.7", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 1.0.69", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "tracing", + "url", + "urlencoding", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr 0.15.8", + "heck 0.5.0", + "pkg-config", + "toml 0.8.23", + "version-compare", +] + +[[package]] +name = "system-deps" +version = "7.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f" +dependencies = [ + "cfg-expr 0.20.5", + "heck 0.5.0", + "pkg-config", + "toml 0.9.10+spec-1.1.0", + "version-compare", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +dependencies = [ + "rustix", + "windows-sys 0.60.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg 0.4.21", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.1", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "tokio-retry" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f57eb36ecbe0fc510036adff84824dd3c24bb781e21bfa67b69d556aa85214f" +dependencies = [ + "pin-project", + "rand 0.8.5", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.35", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit", +] + +[[package]] +name = "toml" +version = "0.9.10+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "iri-string", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.111", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c071456adef4aca59bf6a583c46b90ff5eb0b4f758fc347cea81290288f37ce1" +dependencies = [ + "image", + "libwebp-sys", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "webpki-roots" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "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-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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.4", + "hmac", + "indexmap", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "thiserror 2.0.17", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111f7d9820f05fd715df3144e254d6fc02ee4088b0644c0ffd0efc9e6d9d2773" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d915729b0e7d5fe35c2f294c5dc10b30207cc637920e5b59077bfa3da63f28" +dependencies = [ + "zune-core 0.5.0", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f2636a7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "imageforge" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +axum = { version = "0.8", features = ["multipart"] } +tokio = { version = "1", features = ["full"] } +tower-http = { version = "0.6", features = ["cors", "trace", "compression-full", "fs"] } +axum-extra = { version = "0.12", features = ["cookie"] } +time = "0.3" + +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" + +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } + +dotenvy = "0.15" + +sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json"] } +redis = { version = "0.24", features = ["tokio-comp", "connection-manager", "streams"] } + +# Auth / security +argon2 = "0.5" +base64 = "0.22" +hex = "0.4" +hmac = "0.12" +jsonwebtoken = "9" +rand = "0.8" +sha2 = "0.10" +aes-gcm = "0.10" + +# Images +image = { version = "0.25", features = ["avif-native"] } +oxipng = "9" +ravif = "0.11" +webp = "0.3" +rgb = "0.8" +img-parts = "0.4" + +# HTTP client (Stripe API) +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } + +# Mail +lettre = { version = "0.11", default-features = false, features = ["tokio1", "tokio1-rustls-tls", "builder", "smtp-transport"] } + +# ZIP download (batch) +tokio-util = { version = "0.7", features = ["io"] } +zip = "2" diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b79f48 --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +# ImageForge - Rust 图片压缩服务 + +一个基于 Rust 的高性能图片压缩服务,提供 Web 界面 + 对外 API(API Key)+ 计费能力,支持用户系统与管理员后台。 + +## 项目概述 + +### 核心功能 + +- **图片压缩**:支持 PNG/JPG/JPEG/WebP/AVIF/GIF/BMP/TIFF/ICO(GIF 仅静态) +- **批量处理**:支持多图片同时上传和处理 +- **压缩率**:1-100(数值越大压缩越强) +- **用户系统**:注册、登录、API Key 管理 +- **计费与用量**:套餐/订阅/配额/发票 +- **管理员后台**:用户管理、系统监控、配置管理 + +### 技术栈 + +| 层级 | 技术选型 | 说明 | +|------|----------|------| +| 后端框架 | Axum | 高性能异步 Web 框架 | +| 图片处理 | image-rs + oxipng + webp + ravif | PNG/WebP/AVIF 编解码与优化 | +| 数据库 | PostgreSQL + SQLx | 类型安全的异步数据库 | +| 缓存/队列 | Redis | 会话管理、限流、任务队列(Streams) | +| 前端 | Vue3 + TypeScript | SPA 单页应用 | +| 认证 | JWT + API Key | 双重认证机制 | +| 存储 | S3 兼容 / 本地 | 对象存储 + 预签名 URL(推荐) | +| 计费 | Stripe | Checkout/Portal/Webhook | + +## 目录结构 + +> 说明:以下为目标目录结构(规划),会随实现逐步补齐。 + +``` +imageforge/ +├── Cargo.toml +├── src/ +│ ├── main.rs # 入口 +│ ├── config.rs # 配置管理 +│ ├── error.rs # 错误处理 +│ ├── lib.rs +│ │ +│ ├── api/ # API 路由 +│ │ ├── mod.rs +│ │ ├── auth.rs # 认证相关 +│ │ ├── compress.rs # 压缩相关 +│ │ ├── user.rs # 用户相关 +│ │ └── admin.rs # 管理员相关 +│ │ +│ ├── services/ # 业务逻辑 +│ │ ├── mod.rs +│ │ ├── compress.rs # 压缩服务 +│ │ ├── auth.rs # 认证服务 +│ │ ├── user.rs # 用户服务 +│ │ └── storage.rs # 存储服务 +│ │ +│ ├── models/ # 数据模型 +│ │ ├── mod.rs +│ │ ├── user.rs +│ │ ├── image.rs +│ │ └── api_key.rs +│ │ +│ ├── compress/ # 压缩核心 +│ │ ├── mod.rs +│ │ ├── png.rs # PNG 压缩(oxipng + pngquant) +│ │ ├── jpeg.rs # JPEG 压缩(mozjpeg) +│ │ ├── webp.rs # WebP 压缩 +│ │ └── avif.rs # AVIF 压缩 +│ │ +│ └── middleware/ # 中间件 +│ ├── mod.rs +│ ├── auth.rs # 认证中间件 +│ └── rate_limit.rs # 限流中间件 +│ +├── migrations/ # 数据库迁移 +├── static/ # 静态资源 +├── frontend/ # 前端项目 +└── docker/ # Docker 配置 +``` + +## 快速开始 + +### 环境要求 + +- Rust(建议使用最新 stable;当前依赖链要求较新的 Rust,建议 `>= 1.85`) +- PostgreSQL 16+ +- Redis 7+ +- Node.js 20+(前端构建) + +### 本地开发 + +```bash +# 配置环境变量 +cp .env.example .env + +# 启动 PostgreSQL / Redis(开发) +docker compose -f docker/docker-compose.dev.yml up -d + +# 初始化数据库 +psql "$DATABASE_URL" -f migrations/001_init.sql + +# 启动后端 +cargo run + +# 启动前端(另一个终端) +cd frontend && npm run dev +``` + +### Docker 部署 + +```bash +# 该 compose 仅包含 postgres/redis,服务本体请按 docs/deployment.md 构建运行 +docker compose -f docker/docker-compose.dev.yml up -d +``` + +## 文档索引 + +- [开工前确认清单](./docs/confirm.md) +- [产品需求(PRD)](./docs/prd.md) +- [技术架构设计](./docs/architecture.md) +- [API 接口文档](./docs/api.md) +- [数据库设计](./docs/database.md) +- [前端设计](./docs/frontend.md) +- [UI/UX 设计](./docs/ui.md) +- [计费与用量设计](./docs/billing.md) +- [安全与风控](./docs/security.md) +- [邮件服务](./docs/email.md) +- [可观测性](./docs/observability.md) +- [部署指南](./docs/deployment.md) +- [服务条款(模板)](./docs/terms.md) +- [隐私政策(模板)](./docs/privacy.md) + +## 已确认清单 + +- [x] 支付渠道首期选型:Stripe +- [x] 计费策略:硬配额(无超额按量) +- [x] 匿名试用:支持,每日 10 次 +- [x] 订阅周期:按订阅周期(非自然月) +- [x] 邮件服务:注册验证 + 密码重置(SMTP,预置多服务商模板) +- [x] 默认语言:中文 +- [x] Free 套餐 API:不开放(仅 Pro/Business 可用) + +## 待完成清单(上线前) + +- [x] 法务页面:隐私政策、服务条款(已提供模板) +- [ ] 域名与品牌名最终确认 + +## License + +MIT diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..0fd537f --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,46 @@ +FROM rust:1.92-bookworm AS builder + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + cmake \ + nasm \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +COPY Cargo.toml Cargo.lock ./ +COPY src ./src +COPY migrations ./migrations +COPY templates ./templates + +RUN cargo build --release + +FROM node:20-alpine AS frontend-builder + +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm ci +COPY frontend ./ +RUN npm run build + +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=builder /app/target/release/imageforge ./imageforge +COPY --from=frontend-builder /app/frontend/dist ./static +COPY migrations ./migrations + +RUN mkdir -p uploads + +ENV HOST=0.0.0.0 +ENV PORT=8080 + +EXPOSE 8080 + +CMD ["./imageforge"] + diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml new file mode 100644 index 0000000..1a297dc --- /dev/null +++ b/docker/docker-compose.dev.yml @@ -0,0 +1,25 @@ +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: imageforge + POSTGRES_PASSWORD: devpassword + POSTGRES_DB: imageforge + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + +volumes: + postgres_data: + redis_data: + diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..2d5dcfc --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,53 @@ +events { + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + client_max_body_size 100M; + + gzip on; + gzip_types text/plain text/css application/json application/javascript; + + upstream imageforge_api { + server api:8080; + } + + server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + + location /api/ { + proxy_pass http://imageforge_api; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /downloads/ { + proxy_pass http://imageforge_api; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /health { + proxy_pass http://imageforge_api; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } + } +} + diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..822b083 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,685 @@ +# API 接口文档(v1)- ImageForge + +面向两类使用者: +- **网站(Web)**:上传/批量/历史/账单等(可能包含匿名试用)。 +- **对外 API(Developer API)**:API Key 调用、可计量可计费、适配 CI/CD 与服务端集成。 + +产品范围与计费口径见: +- `docs/prd.md` +- `docs/billing.md` + +--- + +## 1. 基础信息 + +- **Base URL**: `https://your-domain.com/api/v1` +- **数据格式**: JSON(除明确标注“返回二进制”接口) +- **时间格式**: ISO 8601 / UTC(如:`2025-01-15T10:30:00Z`) +- **ID 格式**: UUID 字符串 + +--- + +## 2. 认证 + +支持三种身份: + +### 2.1 JWT(网站/管理后台) +```http +Authorization: Bearer +``` + +### 2.2 API Key(对外 API) +```http +X-API-Key: +``` + +> **注意**:仅 **Pro** 和 **Business** 套餐用户可创建 API Key。Free 用户尝试创建时返回 `FORBIDDEN`(HTTP `403`)。 + +### 2.3 匿名试用(仅网站场景) +- 不提供 API Key; +- 通过 Cookie 维持匿名会话(服务端签发),仅允许较小文件与较低频率。 +- 每日 10 次(以成功压缩文件数计);超出返回 `QUOTA_EXCEEDED`(HTTP `402`)。 +- 日界:自然日(UTC+8),次日 00:00 重置。 +- **匿名试用硬限制:Cookie + IP 双限制**(两者任一超出都拒绝),降低刷会话绕过风险。 + +--- + +## 3. 通用约定 + +### 3.1 幂等(强烈建议) +对会产生计费/创建任务的接口,建议客户端传: +```http +Idempotency-Key: +``` + +规则(建议口径): +- 同一个 `Idempotency-Key` 在 TTL 内重复请求,若请求参数一致则返回首次结果(不重复扣费/不重复创建任务)。 +- 若参数不一致,返回 `409 IDEMPOTENCY_CONFLICT`。 + +### 3.2 限流(Rate Limit) +超出限制返回: +- HTTP `429` +- 头:`Retry-After: ` + +建议头(可选): +- `RateLimit-Limit` +- `RateLimit-Remaining` +- `RateLimit-Reset` + +### 3.3 配额(Quota / Billing) +配额不足(当期额度耗尽)返回: +- HTTP `402` +- 错误码:`QUOTA_EXCEEDED` + +配额周期: +- Pro/Business(付费):按订阅周期重置(`period_start` ~ `period_end`),不是自然月。 +- Free(未订阅):按自然月(UTC+8)重置。 +- 匿名试用:按自然日(UTC+8)重置。 + +建议头(可选): +- `X-Quota-Limit` +- `X-Quota-Remaining` +- `X-Quota-Reset-At` + +### 3.4 通用响应格式(JSON) +成功: +```json +{ "success": true, "data": {} } +``` + +错误: +```json +{ + "success": false, + "error": { + "code": "ERROR_CODE", + "message": "错误描述", + "request_id": "req_..." + } +} +``` + +### 3.5 错误码(建议集合) +| 错误码 | HTTP | 说明 | +|---|---:|---| +| `INVALID_REQUEST` | 400 | 参数不合法 | +| `INVALID_IMAGE` | 400 | 图片解码失败/文件损坏 | +| `UNSUPPORTED_FORMAT` | 400 | 不支持的格式 | +| `TOO_MANY_PIXELS` | 400 | 像素超限(防图片炸弹) | +| `UNAUTHORIZED` | 401 | 未认证 | +| `FORBIDDEN` | 403 | 权限不足 | +| `NOT_FOUND` | 404 | 资源不存在 | +| `IDEMPOTENCY_CONFLICT` | 409 | 幂等 key 冲突 | +| `QUOTA_EXCEEDED` | 402 | 配额不足 | +| `FILE_TOO_LARGE` | 413 | 文件过大 | +| `RATE_LIMITED` | 429 | 请求过于频繁 | +| `EMAIL_NOT_VERIFIED` | 403 | 邮箱未验证 | +| `INVALID_TOKEN` | 400 | Token 无效或已过期 | +| `COMPRESSION_FAILED` | 500 | 压缩失败 | +| `STORAGE_UNAVAILABLE` | 503 | 存储不可用 | +| `MAIL_SEND_FAILED` | 500 | 邮件发送失败 | + +--- + +## 4. 认证接口 + +### 4.1 用户注册 +```http +POST /auth/register +Content-Type: application/json +``` + +请求体: +```json +{ "email": "user@example.com", "password": "securepassword123", "username": "myusername" } +``` + +响应: +```json +{ + "success": true, + "data": { + "user": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "email": "user@example.com", + "username": "myusername", + "email_verified": false, + "created_at": "2025-01-15T10:30:00Z" + }, + "token": "eyJhbGciOi...", + "message": "注册成功,验证邮件已发送至您的邮箱" + } +} +``` + +> **注意**:注册后自动发送验证邮件。用户需验证邮箱后才能使用压缩功能(未验证时调用压缩接口返回 `EMAIL_NOT_VERIFIED`)。 + +### 4.2 用户登录 +```http +POST /auth/login +Content-Type: application/json +``` + +请求体: +```json +{ "email": "user@example.com", "password": "securepassword123" } +``` + +响应: +```json +{ + "success": true, + "data": { + "token": "eyJhbGciOi...", + "expires_at": "2025-01-22T10:30:00Z", + "user": { "id": "550e8400-e29b-41d4-a716-446655440000", "email": "user@example.com", "username": "myusername", "role": "user" } + } +} +``` + +### 4.3 刷新 Token +```http +POST /auth/refresh +Authorization: Bearer +``` + +### 4.4 登出 +```http +POST /auth/logout +Authorization: Bearer +``` + +### 4.5 发送验证邮件 +用户注册后自动发送一次;此接口用于重新发送。 + +```http +POST /auth/send-verification +Authorization: Bearer +``` + +**限流**:同一用户 1 分钟内最多 1 次 + +响应: +```json +{ "success": true, "data": { "message": "验证邮件已发送,请查收" } } +``` + +### 4.6 验证邮箱 +```http +POST /auth/verify-email +Content-Type: application/json +``` + +请求体: +```json +{ "token": "verification-token-from-email" } +``` + +响应: +```json +{ "success": true, "data": { "message": "邮箱验证成功" } } +``` + +### 4.7 请求密码重置 +```http +POST /auth/forgot-password +Content-Type: application/json +``` + +请求体: +```json +{ "email": "user@example.com" } +``` + +**限流**:同一 IP 1 分钟内最多 3 次 + +响应(无论邮箱是否存在都返回成功,防止枚举): +```json +{ "success": true, "data": { "message": "如果该邮箱已注册,您将收到重置邮件" } } +``` + +### 4.8 重置密码 +```http +POST /auth/reset-password +Content-Type: application/json +``` + +请求体: +```json +{ "token": "reset-token-from-email", "new_password": "new-secure-password" } +``` + +响应: +```json +{ "success": true, "data": { "message": "密码重置成功,请重新登录" } } +``` + +--- + +## 5. 图片压缩接口 + +### 5.1 单图压缩(同步,返回 JSON + 下载链接) +适用于网站与轻量同步调用(服务端可选择是否落盘/落对象存储)。 + +```http +POST /compress +Content-Type: multipart/form-data +Authorization: Bearer # 或 X-API-Key;网站匿名试用可不带 +Idempotency-Key: # 建议 +``` + +表单字段: +| 字段 | 类型 | 必填 | 说明 | +|---|---|---:|---| +| `file` | File | 是 | 图片文件 | +| `compression_rate` | Integer | 否 | 压缩率 1-100(数值越大压缩越强),优先级高于 `level` | +| `level` | String | 否 | `high` / `medium` / `low`(兼容参数,默认 `medium`) | +| `output_format` | String | 否 | 已停用,仅支持保持原格式 | +| `max_width` | Integer | 否 | 最大宽度(等比缩放) | +| `max_height` | Integer | 否 | 最大高度(等比缩放) | +| `preserve_metadata` | Boolean | 否 | 是否保留元数据(默认 `false`) | + +响应: +```json +{ + "success": true, + "data": { + "task_id": "550e8400-e29b-41d4-a716-446655440100", + "file_id": "550e8400-e29b-41d4-a716-446655440101", + "format_in": "png", + "format_out": "png", + "original_size": 1024000, + "compressed_size": 256000, + "saved_bytes": 768000, + "saved_percent": 75.0, + "download_url": "/downloads/550e8400-e29b-41d4-a716-446655440101", + "expires_at": "2025-01-15T11:30:00Z", + "billing": { "units_charged": 1 } + } +} +``` + +### 5.2 单图压缩(同步,直接返回二进制) +更贴近开发者体验,适用于 SDK/CI。 + +```http +POST /compress/direct +Content-Type: multipart/form-data +X-API-Key: # 或 Bearer token(不建议匿名) +Idempotency-Key: # 建议 +``` + +成功响应: +- HTTP `200` +- Body:压缩后的图片二进制 +- `Content-Type`: `image/png` / `image/jpeg` / `image/webp` / `image/avif` / `image/gif` / `image/bmp` / `image/tiff` / `image/x-icon` + +建议响应头(示例): +```http +ImageForge-Original-Size: 1024000 +ImageForge-Compressed-Size: 256000 +ImageForge-Saved-Bytes: 768000 +ImageForge-Saved-Percent: 75.0 +ImageForge-Units-Charged: 1 +``` + +### 5.3 批量压缩(异步任务) +适用于多文件或大文件;由 Worker 处理并持续更新进度。 + +```http +POST /compress/batch +Content-Type: multipart/form-data +Authorization: Bearer # 或 X-API-Key +Idempotency-Key: # 建议 +``` + +表单字段: +| 字段 | 类型 | 必填 | 说明 | +|---|---|---:|---| +| `files[]` | File[] | 是 | 图片文件数组(上限由套餐决定) | +| `compression_rate` | Integer | 否 | 压缩率 1-100(数值越大压缩越强),优先级高于 `level` | +| `level` | String | 否 | `high` / `medium` / `low`(兼容参数) | +| `output_format` | String | 否 | 已停用,仅支持保持原格式 | +| `preserve_metadata` | Boolean | 否 | 是否保留元数据(默认 `false`) | + +响应: +```json +{ + "success": true, + "data": { + "task_id": "550e8400-e29b-41d4-a716-446655440200", + "total_files": 10, + "status": "pending", + "status_url": "/compress/tasks/550e8400-e29b-41d4-a716-446655440200" + } +} +``` + +配额规则补充: +- 若本周期剩余单位不足以覆盖本次上传的文件数,服务端应直接返回 `402 QUOTA_EXCEEDED`(不创建任务)。 + +### 5.4 查询任务状态 +```http +GET /compress/tasks/{task_id} +Authorization: Bearer # 或 X-API-Key;匿名试用需携带 Cookie 会话 +``` + +响应: +```json +{ + "success": true, + "data": { + "task_id": "550e8400-e29b-41d4-a716-446655440200", + "status": "completed", + "progress": 100, + "total_files": 10, + "completed_files": 10, + "failed_files": 0, + "files": [ + { + "file_id": "550e8400-e29b-41d4-a716-446655440201", + "original_name": "photo1.png", + "original_size": 1024000, + "compressed_size": 256000, + "saved_percent": 75.0, + "status": "completed", + "download_url": "/downloads/550e8400-e29b-41d4-a716-446655440201" + } + ], + "download_all_url": "/downloads/tasks/550e8400-e29b-41d4-a716-446655440200", + "created_at": "2025-01-15T10:30:00Z", + "completed_at": "2025-01-15T10:31:00Z", + "expires_at": "2025-01-22T10:30:00Z" + } +} +``` + +### 5.5 取消任务(可选) +```http +POST /compress/tasks/{task_id}/cancel +Authorization: Bearer +``` + +### 5.6 删除任务与文件(隐私/合规) +```http +DELETE /compress/tasks/{task_id} +Authorization: Bearer +``` + +--- + +## 6. 下载接口 + +### 6.1 下载单个文件 +```http +GET /downloads/{file_id} +Authorization: Bearer # 或 X-API-Key;匿名试用需 Cookie 会话 +``` + +### 6.2 下载批量 ZIP +```http +GET /downloads/tasks/{task_id} +Authorization: Bearer # 或 X-API-Key;匿名试用需 Cookie 会话 +``` + +--- + +## 7. 用户接口 + +### 7.1 获取当前用户信息 +```http +GET /user/profile +Authorization: Bearer +``` + +响应(示例): +```json +{ + "success": true, + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "email": "user@example.com", + "username": "myusername", + "role": "user" + } +} +``` + +### 7.2 更新用户信息 +```http +PUT /user/profile +Authorization: Bearer +Content-Type: application/json +``` + +### 7.3 修改密码 +```http +PUT /user/password +Authorization: Bearer +Content-Type: application/json +``` + +### 7.4 获取压缩历史 +```http +GET /user/history?page=1&limit=20 +Authorization: Bearer +``` + +--- + +## 8. API Key 管理 + +### 8.1 获取 API Key 列表 +```http +GET /user/api-keys +Authorization: Bearer +``` + +### 8.2 创建 API Key +```http +POST /user/api-keys +Authorization: Bearer +Content-Type: application/json +``` + +请求体: +```json +{ "name": "Production Server", "permissions": ["compress", "batch_compress"] } +``` + +响应: +```json +{ + "success": true, + "data": { + "id": "550e8400-e29b-41d4-a716-446655440300", + "name": "Production Server", + "key": "if_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "message": "请保存此 Key,它只会显示一次" + } +} +``` + +### 8.3 轮换 API Key(可选) +```http +POST /user/api-keys/{key_id}/rotate +Authorization: Bearer +``` + +### 8.4 删除/禁用 API Key +```http +DELETE /user/api-keys/{key_id} +Authorization: Bearer +``` + +--- + +## 9. 计费与用量(Billing) + +### 9.1 获取套餐列表(公开) +```http +GET /billing/plans +``` + +响应(示例): +```json +{ + "success": true, + "data": { + "plans": [ + { + "id": "550e8400-e29b-41d4-a716-446655440900", + "code": "pro_monthly", + "name": "Pro(月付)", + "currency": "CNY", + "amount_cents": 1999, + "interval": "monthly", + "included_units_per_period": 10000, + "max_file_size_mb": 20, + "max_files_per_batch": 50, + "retention_days": 7, + "features": { "webhook": true } + } + ] + } +} +``` + +### 9.2 获取当前订阅 +```http +GET /billing/subscription +Authorization: Bearer +``` + +### 9.3 获取当期用量 +```http +GET /billing/usage +Authorization: Bearer +``` + +响应: +```json +{ + "success": true, + "data": { + "period_start": "2025-01-01T00:00:00Z", + "period_end": "2025-02-01T00:00:00Z", + "used_units": 120, + "included_units": 10000, + "bonus_units": 500, + "total_units": 10500, + "remaining_units": 10380 + } +} +``` + +### 9.4 创建 Checkout(订阅/升级) +```http +POST /billing/checkout +Authorization: Bearer +Content-Type: application/json +Idempotency-Key: +``` + +请求体: +```json +{ "plan_id": "550e8400-e29b-41d4-a716-446655440900" } +``` + +响应: +```json +{ "success": true, "data": { "checkout_url": "https://pay.example.com/..." } } +``` + +### 9.5 打开客户 Portal(管理支付方式/取消订阅) +```http +POST /billing/portal +Authorization: Bearer +``` + +### 9.6 发票列表 +```http +GET /billing/invoices?page=1&limit=20 +Authorization: Bearer +``` + +--- + +## 10. Webhooks(支付回调) + +> 无需登录;必须验签与幂等处理,详见 `docs/billing.md` 与 `docs/security.md`。 + +### 10.1 Stripe 回调(示例) +```http +POST /webhooks/stripe +Content-Type: application/json +Stripe-Signature: t=...,v1=... +``` + +--- + +## 11. 管理员接口 + +> 需要管理员权限(`role: admin`) + +### 11.1 获取系统统计 +```http +GET /admin/stats +Authorization: Bearer +``` + +### 11.2 用户管理(示例) +```http +GET /admin/users?page=1&limit=20&search=keyword +Authorization: Bearer +``` + +### 11.3 系统配置 +```http +GET /admin/config +Authorization: Bearer +``` + +```http +PUT /admin/config +Authorization: Bearer +Content-Type: application/json +``` + +### 11.4 任务管理 +```http +GET /admin/tasks?status=processing&page=1 +Authorization: Bearer +``` + +```http +POST /admin/tasks/{task_id}/cancel +Authorization: Bearer +``` + +### 11.5 计费管理(建议) +```http +GET /admin/billing/subscriptions?page=1&limit=20 +Authorization: Bearer +``` + +```http +POST /admin/billing/credits +Authorization: Bearer +Content-Type: application/json +``` + +--- + +## 12. WebSocket(网站任务进度) + +网站侧可用 WebSocket 或 SSE(SSE 更易穿透代理)。当前先保留 WebSocket 方案: + +``` +ws://your-domain.com/ws/tasks/{task_id}?token= +``` + +消息(示例): +```json +{ "type": "progress", "data": { "task_id": "550e8400-e29b-41d4-a716-446655440200", "progress": 50, "completed_files": 5 } } +``` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..15be66f --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,334 @@ +# 技术架构设计 + +> 目标:支撑“网站 + 对外 API + 计费”的商用闭环。产品范围见 `docs/prd.md`,计费口径见 `docs/billing.md`。 + +## 系统架构图 + +``` + ┌───────────────────────────────┐ + │ CDN (静态资源/下载可选加速) │ + └──────────────┬────────────────┘ + │ + ┌──────▼──────┐ + │ Nginx/Caddy │ + │ TLS/反向代理 │ + └──────┬──────┘ + ┌───────────────────────────┼───────────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ + │ Web 前端 (Vue3) │ │ API 服务 (Axum) │ │ Admin 前端(Vue3)│ + │ 上传/结果/账单 │ │ 认证/计费/接口 │ │ 运营/风控/配置 │ + └─────────────────┘ └───────┬─────────┘ └─────────────────┘ + │ + ┌──────────────────┼──────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ + │ PostgreSQL (DB) │ │ Redis (缓存/队列)│ │ 对象存储 (S3) │ + │ 用户/任务/账单/用量│ │ Streams/RateLimit│ │ 原图/压缩结果 │ + └─────────┬───────┘ └───────┬─────────┘ └─────────┬───────┘ + │ │ │ + │ ▼ │ + │ ┌───────────────┐ │ + │ │ Worker (Rust) │ │ + │ │ 压缩/计量/回写 │ │ + │ └───────────────┘ │ + │ │ + ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ + │ 支付渠道/网关 │◄──────Webhooks───────│ API(Webhook处理) │ + │ Stripe │ │ 订阅/发票/状态 │ + └─────────────────┘ └─────────────────┘ +``` + +## 核心组件设计 + +### 0. 服务拆分(推荐) + +为避免 CPU 密集的压缩任务影响 API 延迟,建议最小拆分为: +- **API 服务**:认证、限流、计费/订阅、任务编排、回调、签名 URL、管理后台 API。 +- **Worker 服务**:执行图片压缩(CPU 密集)、写入结果、落用量账本、推送进度。 + +本地开发可以合并进一个进程(feature flag);生产建议分开部署并可独立扩容。 + +### 1. 压缩引擎 + +```rust +// 压缩配置 +pub enum CompressionLevel { + /// 高压缩比 - 有损压缩,文件最小 + High, + /// 中等压缩 - 平衡模式 + Medium, + /// 低压缩比 - 无损/近无损,质量优先 + Low, +} + +pub struct CompressionConfig { + pub level: CompressionLevel, + pub output_format: Option, // 可选转换格式 + pub max_width: Option, // 可选调整尺寸 + pub max_height: Option, + pub preserve_metadata: bool, // 是否保留元数据(默认 false) +} +``` + +### 2. 压缩策略 + +| 格式 | 高压缩比(有损) | 低压缩比(无损) | 使用库 | +|------|-----------------|-----------------|--------| +| PNG | pngquant 量化到 256 色 | oxipng 无损优化 | `imagequant` + `oxipng` | +| JPEG | mozjpeg quality=60 | mozjpeg quality=90 | `mozjpeg` | +| WebP | lossy quality=75 | lossless | `webp` | +| AVIF | quality=50 | quality=90 | `ravif` | + +```rust +// 压缩核心逻辑 +pub trait ImageCompressor: Send + Sync { + async fn compress(&self, input: &[u8], config: &CompressionConfig) -> Result>; + fn supported_formats(&self) -> Vec; +} + +pub struct PngCompressor; +pub struct JpegCompressor; +pub struct WebpCompressor; +pub struct AvifCompressor; + +// 统一压缩入口 +pub struct CompressionEngine { + compressors: HashMap>, +} + +impl CompressionEngine { + pub async fn compress(&self, input: &[u8], config: &CompressionConfig) -> Result { + let format = detect_format(input)?; + let compressor = self.compressors.get(&format) + .ok_or(Error::UnsupportedFormat)?; + + let output = compressor.compress(input, config).await?; + + Ok(CompressionResult { + original_size: input.len(), + compressed_size: output.len(), + format, + data: output, + }) + } +} +``` + +### 3. 任务处理模型 + +支持两种模式: + +#### 同步模式(小文件/单文件) +``` +请求 -> 压缩 -> 直接返回结果 +``` + +#### 异步模式(大文件/批量) +``` +请求 -> 创建任务 -> 返回任务ID + ↓ + 后台Worker处理 + ↓ + 客户端轮询/WebSocket通知 + ↓ + 下载结果 +``` + +```rust +pub enum TaskStatus { + Pending, + Processing, + Completed, + Failed, +} + +pub struct CompressionTask { + pub id: Uuid, + pub user_id: Option, // 游客为空 + pub session_id: Option, // 游客会话(Cookie) + pub status: TaskStatus, + pub files: Vec, + pub config: CompressionConfig, + pub created_at: DateTime, + pub completed_at: Option>, +} + +pub struct FileTask { + pub id: Uuid, + pub original_name: String, + pub original_size: u64, + pub compressed_size: Option, + pub status: TaskStatus, + pub error: Option, +} +``` + +### 4. 用户认证系统 + +```rust +// JWT Claims +#[derive(Serialize, Deserialize)] +pub struct Claims { + pub sub: Uuid, // 用户ID + pub role: UserRole, // 用户角色 + pub exp: i64, // 过期时间 +} + +pub enum UserRole { + User, + Admin, +} + +// API Key 认证 +pub struct ApiKey { + pub id: Uuid, + pub user_id: Uuid, + pub key_prefix: String, // 前缀索引(仅展示用) + pub key_hash: String, // 推荐:HMAC-SHA256(key, server_pepper) 或 sha256+pepper + pub name: String, + pub permissions: Vec, + pub rate_limit: u32, // 每分钟请求数 + pub created_at: DateTime, + pub last_used_at: Option>, +} + +pub enum Permission { + Compress, + BatchCompress, + ReadStats, + BillingRead, // 查看账单/用量(可选) + WebhookManage, // 管理 Webhook(可选) +} +``` + +### 5. 限流与配额(Rate Limit & Quota) + +用途区分: +- **限流**:保护服务(超出返回 HTTP `429`) +- **配额**:试用/计费限制(超出返回 HTTP `402` / `QUOTA_EXCEEDED`) + +默认建议(最终以 `system_config` + 套餐 `plans.*` 为准): +- 匿名试用:`10 req/min` + `10 units/day` + `5MB/文件` + `5 文件/批量` +- 登录用户:`60 req/min`;文件大小/批量/保留期/周期额度由套餐决定 +- API Key:`100 req/min`(可配置);文件大小/批量/周期额度由套餐/Key 覆盖决定 + +### 6. 用量计量与计费(Metering & Billing) + +计量口径见 `docs/billing.md`,架构上建议: +- Worker 在每个文件成功输出后写入 `usage_events`(账本明细),并更新 `usage_periods`(按订阅周期聚合)。 +- API 在创建任务/接收同步压缩请求时做**配额预检**(快速失败),Worker 做**最终扣减**(账本落地,保证一致性)。 +- 对外 API 强烈建议支持 `Idempotency-Key`;DB 侧存储“幂等记录 + 响应摘要”,避免重复扣减与重复任务。 + +### 7. 支付回调(Webhooks) + +Stripe 通常通过 webhook 推送订阅/支付状态变更: +- API 服务提供 `/webhooks/{provider}` 入口,**验签 + 幂等 + 可重放**。 +- webhook 事件入库后异步处理(避免回调超时),更新订阅/发票状态,并写审计日志。 + +## 核心依赖 + +```toml +[dependencies] +# Web 框架 +axum = "0.7" +tokio = { version = "1", features = ["full"] } +tower = "0.4" +tower-http = { version = "0.5", features = ["cors", "fs", "compression"] } + +# 图片处理 +image = "0.25" +oxipng = "9" +imagequant = "4" # PNG 有损压缩(pngquant 核心) +mozjpeg = "0.10" # JPEG 压缩 +webp = "0.3" # WebP 编解码 +ravif = "0.11" # AVIF 编码 + +# 数据库 +sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "uuid", "chrono"] } + +# Redis +redis = { version = "0.24", features = ["tokio-comp"] } + +# 认证 +jsonwebtoken = "9" +argon2 = "0.5" # 密码哈希 + +# 工具 +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" +tracing = "0.1" +tracing-subscriber = "0.3" + +# 配置 +config = "0.14" +dotenvy = "0.15" +``` + +## 性能考虑 + +### 1. 并发处理 +- 使用 Tokio 异步运行时 +- 图片压缩使用 `spawn_blocking` 避免阻塞异步线程 +- 可配置 Worker 线程数 + +```rust +// 在独立线程池中执行 CPU 密集型压缩 +let result = tokio::task::spawn_blocking(move || { + compress_image_sync(&data, &config) +}).await??; +``` + +### 2. 内存管理 +- 流式处理大文件 +- 限制并发压缩任务数 +- 压缩完成后立即清理临时文件 + +### 3. 缓存策略 +- Redis 缓存用户会话 +- 可选:相同图片哈希缓存结果(去重) + +## 安全考虑 + +1. **输入验证**:检查文件魔数,不仅依赖扩展名 +2. **文件大小限制**:防止 DoS +3. **像素/维度限制**:防止“图片炸弹”(解码后超大) +4. **路径遍历防护**:存储时使用 UUID 命名 +5. **SQL 注入防护**:使用参数化查询(SQLx 自动处理) +6. **XSS 防护**:前端输出转义 +7. **CSRF 防护**:SameSite Cookie + Token +8. **速率限制**:防止滥用 +9. **默认移除元数据**:避免泄露定位/设备信息(除非用户明确开启保留) + +## 可扩展性 + +### 水平扩展 +``` + ┌─────────────┐ + │ Load Balancer│ + └──────┬──────┘ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ API 实例1 │ │ API 实例2 │ │ API 实例3 │ + └──────────┘ └──────────┘ └──────────┘ + │ │ │ + └───────────────┼───────────────┘ + ▼ + ┌─────────────────┐ + │ 共享 PostgreSQL │ + │ 共享 Redis │ + │ 共享 S3 存储 │ + └─────────────────┘ +``` + +### 后续可添加功能 +- 消息队列(RabbitMQ/NATS)处理异步任务 +- 分布式任务调度 +- CDN 加速下载 diff --git a/docs/billing.md b/docs/billing.md new file mode 100644 index 0000000..e9ab781 --- /dev/null +++ b/docs/billing.md @@ -0,0 +1,191 @@ +# 计费与用量设计(Stripe + 硬配额)- ImageForge + +目标:形成“套餐/订阅/用量/发票/支付/风控”可落地的闭环,为后续实现提供清晰边界与数据模型依据。 + +--- + +## 1. 计费模型(已确认) + +已确认口径: +- 支付渠道:**Stripe** +- 超额策略:**硬配额**(超过当期额度返回 `QUOTA_EXCEEDED` / HTTP `402`) +- 配额周期:**按订阅周期**(Stripe `current_period_start` ~ `current_period_end`),不是自然月 + +### 1.0 周期定义(统一口径) +为避免前后端与账本口径不一致,本项目将“周期”分为三类: +- **Pro/Business(付费)**:按 Stripe 订阅周期(`current_period_start` ~ `current_period_end`)。 +- **Free(未订阅)**:按自然月(UTC+8)重置,用于展示“本月已用/剩余/重置时间”。 +- **匿名试用**:按自然日(UTC+8)重置,仅用于网站试用(默认每日 10 次)。 + +### 1.1 计量单位 +**compression_unit**:每成功压缩 1 个输出文件计 1 单位。 + +规则: +- 同一任务中失败的文件不计费。 +- 同一请求重试若携带相同 `Idempotency-Key`,不重复计费(返回相同结果)。 +- 输出格式转换(png->webp 等)不额外加价(首期),后续可按“高级功能”计价。 + +### 1.2 套餐(Plan) +套餐是“功能 + 配额 + 限制”的集合,建议存为可配置(DB 或配置中心)。 + +最小字段: +- `included_units_per_period`:周期含量(对付费为订阅周期;对 Free 为自然月)。 +- `max_file_size_mb` / `max_files_per_batch` / `concurrency_limit` +- `retention_days`:结果保留期。 +- `features`:例如 webhook、团队、IP 白名单等开关。 + +### 1.3 订阅(Subscription) +采用“月度订阅 + 含量(硬配额)”: +- 用户在每个订阅周期获得固定含量(`included_units_per_period`)。 +- 超出含量:直接拒绝(`QUOTA_EXCEEDED` / HTTP `402`)。 + +--- + +## 2. 用量计算与配额扣减(Metering) + +### 2.1 何时扣减 +- **成功生成输出**时扣减(以文件粒度)。 +- 对于异步任务:Worker 完成文件压缩后写入用量事件;API/前端通过任务查询看到实时用量。 + +### 2.2 幂等与去重 +需要两层保护: +1) **请求幂等**:`Idempotency-Key` 防止重复创建任务/重复扣减。 +2) **用量幂等**:每个输出文件生成唯一 `usage_event_id`(或以 `task_file_id` 唯一)确保不会重复入账。 + +### 2.3 用量数据结构(建议) +- `usage_events`:明细账本(append-only),用于可追溯与对账。 + - `user_id` / `api_key_id` / `source`(web/api) + - `task_id` / `task_file_id` + - `units`(通常为 1) + - `bytes_in` / `bytes_out` / `format_in` / `format_out` + - `occurred_at` +- `usage_periods`:按订阅周期聚合(加速配额判断)。 + - `user_id` + `period_start` + `period_end` 唯一 + - `used_units`、`bytes_in`、`bytes_out` 等 + +配额判断: +- 写入 `usage_events` 前先检查本周期 `used_units` 是否已达 `included_units_per_period`(并发场景需原子性:事务/锁/原子计数)。 +- 失败时返回 `QUOTA_EXCEEDED`,并在响应中附带“已用/上限/重置时间”。 + +### 2.4 并发安全的扣减算法(推荐) +目标:在高并发下也不能“超扣”。 + +推荐做法(Worker 侧每成功文件 1 次扣减): +- 先拿到当期 `included_units`(来自 plan)。 +- 用单条 SQL 做“条件更新”,只有未超额时才 +1: + +```sql +UPDATE usage_periods +SET used_units = used_units + 1, + bytes_in = bytes_in + $1, + bytes_out = bytes_out + $2, + updated_at = NOW() +WHERE user_id = $3 + AND period_start = $4 + AND period_end = $5 + AND used_units + 1 <= $6 +RETURNING used_units; +``` + +如果 `RETURNING` 无行(0 行更新),表示额度已耗尽:该文件应标记为 `QUOTA_EXCEEDED`,并丢弃输出(不返回/不落盘/不入账)。 + +> 说明:为保持“成功才扣减”的口径,2.4 默认按“成功后尝试扣减”实现;在配额临界点可能出现“先压缩、后发现额度不足”的少量无效计算。若要完全避免,可升级为“预留额度 + 失败回滚”的方案(复杂度更高,后续可做 V1+ 优化)。 + +### 2.5 批量任务与配额(硬配额) +为尽量减少“上传后才发现额度不足”的体验问题,建议双层策略: +- **创建任务前预检**:`remaining_units < files_count` 时直接返回 `402 QUOTA_EXCEEDED`(不创建任务)。 +- **扣减时最终校验**:写入账本/聚合时仍按 2.4 做原子扣减,防止并发竞态导致超额。 + +说明: +- 批量任务的计量仍以“成功文件数”为准;失败文件(含 `QUOTA_EXCEEDED`)不计费。 +- 前端建议在上传前调用 `GET /billing/usage`(登录)或读取配额头(API)做本地提示/拦截。 + +--- + +## 3. 订阅生命周期(状态机) + +### 3.1 状态 +建议: +- `trialing`:试用中(可选) +- `active`:正常 +- `past_due`:欠费(支付失败) +- `paused`:暂停(可选) +- `canceled`:已取消 +- `incomplete`:创建未完成(例如支付页未完成) + +### 3.2 关键动作 +- 升级/降级套餐:下周期生效(首期建议),避免按天结算复杂度。 +- 取消订阅:`cancel_at_period_end=true`,到期生效;到期后自动降级到 Free(不续费)。 + +--- + +## 4. 支付集成(Stripe) + +### 4.1 核心对象映射(建议) +- `users.billing_customer_id` ↔ Stripe `customer.id` +- `plans` ↔ Stripe `product/price`(建议每个 plan 对应一个 Stripe `price`) +- `subscriptions.provider_subscription_id` ↔ Stripe `subscription.id` +- `invoices.provider_invoice_id` ↔ Stripe `invoice.id` +- `payments.provider_payment_id` ↔ Stripe `payment_intent.id`(或 charge id,按实现选) + +### 4.2 Checkout / Portal +- Checkout:后端创建 Stripe Checkout Session,前端跳转 `checkout_url`。 +- Portal:后端创建 Stripe Billing Portal Session,前端跳转管理支付方式/取消订阅。 + +### 4.3 Stripe Webhook(商用必须) +要求: +- **验签**:使用 `STRIPE_WEBHOOK_SECRET` 校验 `Stripe-Signature`。 +- **事件幂等**:按 `provider_event_id` 去重(落库 `webhook_events`)。 +- **乱序容忍**:以 Stripe 事件时间 + 当前 DB 状态做“只前进不回退”更新。 +- **可重放**:保存原始 payload(脱敏)用于排查。 + +建议首期处理的事件(示例): +- `checkout.session.completed` +- `customer.subscription.created` +- `customer.subscription.updated` +- `customer.subscription.deleted` +- `invoice.paid` +- `invoice.payment_failed` + +--- + +## 5. 发票与对账 + +### 5.1 发票(Invoice) +发票用于: +- 向用户展示本期费用、税务信息(后续)、支付状态。 +- 与支付记录/订阅周期关联。 + +最小字段: +- `invoice_number`(展示用) +- `currency`、`total_amount` +- `status`:draft/open/paid/void/uncollectible(可简化) +- `period_start` / `period_end` +- `paid_at` + +### 5.2 对账原则 +- 以 `usage_events` 为最终真相(source of truth)。 +- 以 `invoice` 为结算结果(面向用户)。 +- 管理后台可以按用户/周期拉取用量明细,支持导出 CSV。 + +--- + +## 6. 风控策略(与计费强相关) + +### 6.1 防盗刷 +- API Key 支持:权限范围、速率限制、(可选)IP 白名单、(可选)域名白名单(仅对 Web 场景有效)。 +- 异常检测:短时间内用量突增、来自异常国家/ASN、重复失败率高。 + +### 6.2 欠费与滥用处理 +- 欠费(past_due):建议限制创建新任务,并在 UI 引导用户去 Portal 完成支付。 +- 严重滥用:冻结用户/API Key,并记录审计日志。 + +--- + +## 7. 需要在文档中保持一致的“最终口径” + +上线前必须定稿并在 `docs/api.md`、`docs/database.md`、`docs/ui.md` 同步: +- 套餐表(默认配额值)。 +- 配额刷新周期(按订阅周期)。 +- 超额策略(硬配额 / HTTP 402)。 +- 匿名试用是否开放、开放到什么程度。 diff --git a/docs/confirm.md b/docs/confirm.md new file mode 100644 index 0000000..6b0539c --- /dev/null +++ b/docs/confirm.md @@ -0,0 +1,46 @@ +# 开工前确认清单 - ImageForge + +目的:把“产品口径/计费口径/关键体验”一次性定稿,避免边做边改导致返工。 + +--- + +## 1. 已确认(按你的要求已写入各文档) + +- 支付:Stripe +- 超额策略:硬配额(超出返回 `402 QUOTA_EXCEEDED`) +- 订阅周期:按 Stripe 订阅周期(`current_period_start` ~ `current_period_end`),不是自然月 +- 匿名试用:支持;每日 10 次(以成功压缩文件数计) +- Free 套餐:不开放对外 API(仅 Pro/Business 可创建 API Key) +- 邮件:注册邮箱验证 + 密码重置(SMTP) +- 默认语言:中文 + +--- + +## 2. 需要你确认的默认口径(我已在文档里按“建议默认值”写死) + +1) **Free 配额周期** +- 当前写法:Free(未订阅)按自然月(UTC+8)重置;Pro/Business 按订阅周期。 + +2) **匿名试用的“日界”与识别** +- 当前写法:匿名试用按自然日(UTC+8)00:00 重置;采用 Cookie + IP 双限制。 + +3) **批量任务遇到额度不足时的行为** +- 当前写法:`POST /compress/batch` 若本周期剩余单位不足以覆盖上传文件数,直接返回 `402`,不创建任务。 + +4) **默认套餐参数(可改)** +- Free:500 / 月,5MB 单文件,10/批量,保留 24h +- Pro:10,000 / 订阅周期,20MB 单文件,50/批量,保留 7 天 +- Business:100,000+ / 订阅周期,50MB 单文件,200/批量,保留 30 天 + +5) **邮箱未验证是否禁止压缩** +- 当前文档口径:注册用户未验证邮箱时,调用压缩接口返回 `EMAIL_NOT_VERIFIED`(匿名试用不受影响)。 + +--- + +## 3. 你只需要回复我 5 个点(同意/修改) + +- Free 配额周期:按自然月(UTC+8)是否 OK? +- 匿名试用:按自然日(UTC+8)是否 OK?是否要“仅 Cookie”还是“Cookie + IP 双限制”? +- 批量额度不足:是否坚持“直接 402 不建任务”,还是允许“部分成功/部分失败”? +- 套餐默认值:Free/Pro/Business 的配额、大小、保留期是否调整? +- 邮箱验证:是否必须验证后才能压缩? diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..077c152 --- /dev/null +++ b/docs/database.md @@ -0,0 +1,583 @@ +# 数据库设计(PostgreSQL + Redis)- ImageForge + +目标:支撑“网站 + 对外 API + 计费”的核心数据闭环,并为后续实现提供可迁移的表结构参考。 + +相关口径: +- 产品范围:`docs/prd.md` +- 计费与用量:`docs/billing.md` +- 安全:`docs/security.md` + +--- + +## 1. 概述 + +### 1.1 技术选型 +- **PostgreSQL**:系统真相来源(用户/任务/用量账本/订阅/发票/审计)。 +- **Redis**:缓存(会话、API Key 缓存)、限流计数、队列(Streams)与进度推送(PubSub 可选)。 + +### 1.2 设计原则 +- **用量可追溯**:以 `usage_events`(明细账本)作为最终真相,可对账/可追责。 +- **幂等可落地**:`idempotency_keys` 保障重试不重复扣费/不重复建任务。 +- **多租户可扩展**:后续可加 team/org,不影响现有表的主键与关系。 +- **安全默认**:不存明文 API Key;审计日志不写入敏感明文。 + +--- + +## 2. PostgreSQL 扩展与类型 + +### 2.1 UUID 生成 +本设计默认使用 `gen_random_uuid()`,需要启用 `pgcrypto`: +```sql +CREATE EXTENSION IF NOT EXISTS pgcrypto; +``` + +> 注意:如果改用 `uuid-ossp`,则应统一改为 `uuid_generate_v4()`,避免文档与实现不一致。 + +### 2.2 枚举类型(建议) +```sql +CREATE TYPE user_role AS ENUM ('user', 'admin'); +CREATE TYPE task_status AS ENUM ('pending', 'processing', 'completed', 'failed', 'cancelled'); +CREATE TYPE file_status AS ENUM ('pending', 'processing', 'completed', 'failed'); +CREATE TYPE compression_level AS ENUM ('high', 'medium', 'low'); +CREATE TYPE task_source AS ENUM ('web', 'api'); + +CREATE TYPE subscription_status AS ENUM ('trialing', 'active', 'past_due', 'paused', 'canceled', 'incomplete'); +CREATE TYPE invoice_status AS ENUM ('draft', 'open', 'paid', 'void', 'uncollectible'); +CREATE TYPE payment_status AS ENUM ('pending', 'succeeded', 'failed', 'refunded'); +``` + +--- + +## 3. ER 图(核心关系) + +``` + ┌──────────────┐ ┌──────────────┐ + │ plans │◄───────│ subscriptions│ + └──────┬───────┘ └──────┬───────┘ + │ │ + │ ▼ + │ ┌──────────────┐ + │ │ invoices │◄────┐ + │ └──────┬───────┘ │ + │ │ │ + │ ▼ │ + │ ┌──────────────┐ │ + │ │ payments │ │ + │ └──────────────┘ │ + │ │ + ▼ │ + ┌──────────────┐ ┌──────────────┐ │ + │ users │──────►│ api_keys │ │ + └──────┬───────┘ └──────────────┘ │ + │ │ + ▼ │ + ┌──────────────┐ ┌──────────────┐ │ + │ tasks │──────►│ task_files │ │ + └──────┬───────┘ └──────┬───────┘ │ + │ │ │ + ▼ ▼ │ + ┌──────────────┐ ┌──────────────┐ │ + │ usage_events │◄──────│ idempotency │ │ + └──────┬───────┘ └──────────────┘ │ + │ │ + ▼ │ + ┌──────────────┐ │ + │ usage_periods│ │ + └──────────────┘ │ + │ + ┌───────────────────────────────▼┐ + │ webhook_events │ + └────────────────────────────────┘ +``` + +--- + +## 4. 表结构(建议) + +> 以下 SQL 用于“设计说明”;真正落地时建议拆分迁移文件(core/billing/metering)。 + +### 4.1 users - 用户 +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) NOT NULL UNIQUE, + username VARCHAR(50) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + role user_role NOT NULL DEFAULT 'user', + is_active BOOLEAN NOT NULL DEFAULT true, + + -- 邮箱验证 + email_verified_at TIMESTAMPTZ, + + -- 计费侧映射(可选,取决于支付渠道) + billing_customer_id VARCHAR(200), + + -- 覆盖限制(运营用;NULL 表示用套餐默认) + rate_limit_override INTEGER, + storage_limit_mb INTEGER, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_users_role ON users(role); +CREATE INDEX idx_users_created_at ON users(created_at); +CREATE INDEX idx_users_email_verified ON users(email_verified_at) WHERE email_verified_at IS NULL; +``` + +### 4.2 api_keys - API Key +```sql +CREATE TABLE api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + name VARCHAR(100) NOT NULL, + key_prefix VARCHAR(20) NOT NULL, -- 展示/索引用(如 "if_live_abcd") + key_hash VARCHAR(255) NOT NULL, -- 推荐:HMAC-SHA256(full_key, API_KEY_PEPPER) + + permissions JSONB NOT NULL DEFAULT '["compress"]', + rate_limit INTEGER NOT NULL DEFAULT 100, + is_active BOOLEAN NOT NULL DEFAULT true, + + last_used_at TIMESTAMPTZ, + last_used_ip INET, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_api_keys_user_id ON api_keys(user_id); +CREATE UNIQUE INDEX idx_api_keys_prefix ON api_keys(key_prefix); +CREATE INDEX idx_api_keys_is_active ON api_keys(is_active); +``` + +### 4.3 email_verifications - 邮箱验证 +```sql +CREATE TABLE email_verifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(64) NOT NULL, -- SHA256(token),不存明文 + expires_at TIMESTAMPTZ NOT NULL, + verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_email_verifications_token ON email_verifications(token_hash); +CREATE INDEX idx_email_verifications_user_id ON email_verifications(user_id); +CREATE INDEX idx_email_verifications_expires ON email_verifications(expires_at) WHERE verified_at IS NULL; +``` + +### 4.4 password_resets - 密码重置 +```sql +CREATE TABLE password_resets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(64) NOT NULL, -- SHA256(token),不存明文 + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_password_resets_token ON password_resets(token_hash); +CREATE INDEX idx_password_resets_user_id ON password_resets(user_id); +CREATE INDEX idx_password_resets_expires ON password_resets(expires_at) WHERE used_at IS NULL; +``` + +### 4.5 plans - 套餐 +```sql +CREATE TABLE plans ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(50) NOT NULL UNIQUE, -- 展示/运营用(如 "free", "pro_monthly") + name VARCHAR(100) NOT NULL, + description TEXT, + + -- Stripe 映射(对接 Checkout 时使用) + stripe_product_id VARCHAR(200), + stripe_price_id VARCHAR(200), + + currency VARCHAR(10) NOT NULL DEFAULT 'CNY', + amount_cents INTEGER NOT NULL DEFAULT 0, + interval VARCHAR(20) NOT NULL DEFAULT 'monthly', -- 可后续改 ENUM + + included_units_per_period INTEGER NOT NULL, + max_file_size_mb INTEGER NOT NULL, + max_files_per_batch INTEGER NOT NULL, + concurrency_limit INTEGER NOT NULL, + retention_days INTEGER NOT NULL, + + features JSONB NOT NULL DEFAULT '{}'::jsonb, + is_active BOOLEAN NOT NULL DEFAULT true, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_plans_is_active ON plans(is_active); +``` + +### 4.4 subscriptions - 订阅 +```sql +CREATE TABLE subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + plan_id UUID NOT NULL REFERENCES plans(id), + + status subscription_status NOT NULL DEFAULT 'incomplete', + current_period_start TIMESTAMPTZ NOT NULL, + current_period_end TIMESTAMPTZ NOT NULL, + + cancel_at_period_end BOOLEAN NOT NULL DEFAULT false, + canceled_at TIMESTAMPTZ, + + provider VARCHAR(20) NOT NULL DEFAULT 'none', -- none/stripe + provider_customer_id VARCHAR(200), + provider_subscription_id VARCHAR(200), + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_subscriptions_user_id ON subscriptions(user_id); +CREATE INDEX idx_subscriptions_status ON subscriptions(status); +``` + +### 4.5 invoices - 发票/账单 +```sql +CREATE TABLE invoices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + subscription_id UUID REFERENCES subscriptions(id) ON DELETE SET NULL, + + invoice_number VARCHAR(50) NOT NULL UNIQUE, + status invoice_status NOT NULL DEFAULT 'open', + currency VARCHAR(10) NOT NULL DEFAULT 'CNY', + total_amount_cents INTEGER NOT NULL DEFAULT 0, + + period_start TIMESTAMPTZ, + period_end TIMESTAMPTZ, + + provider VARCHAR(20) NOT NULL DEFAULT 'none', + provider_invoice_id VARCHAR(200), + hosted_invoice_url TEXT, + pdf_url TEXT, + + due_at TIMESTAMPTZ, + paid_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_invoices_user_id ON invoices(user_id); +CREATE INDEX idx_invoices_status ON invoices(status); +``` + +### 4.6 payments - 支付记录 +```sql +CREATE TABLE payments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + invoice_id UUID REFERENCES invoices(id) ON DELETE SET NULL, + + provider VARCHAR(20) NOT NULL DEFAULT 'none', + provider_payment_id VARCHAR(200), + + status payment_status NOT NULL DEFAULT 'pending', + currency VARCHAR(10) NOT NULL DEFAULT 'CNY', + amount_cents INTEGER NOT NULL DEFAULT 0, + paid_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_payments_user_id ON payments(user_id); +CREATE INDEX idx_payments_status ON payments(status); +``` + +### 4.7 webhook_events - Webhook 事件(支付回调) +```sql +CREATE TABLE webhook_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + provider VARCHAR(20) NOT NULL, + provider_event_id VARCHAR(200) NOT NULL, + event_type VARCHAR(200) NOT NULL, + payload JSONB NOT NULL, + received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + processed_at TIMESTAMPTZ, + status VARCHAR(20) NOT NULL DEFAULT 'received', -- received/processed/failed + error_message TEXT +); + +CREATE UNIQUE INDEX idx_webhook_events_unique ON webhook_events(provider, provider_event_id); +CREATE INDEX idx_webhook_events_status ON webhook_events(status); +``` + +### 4.8 tasks - 压缩任务 +```sql +CREATE TABLE tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + user_id UUID REFERENCES users(id) ON DELETE SET NULL, -- 游客为空 + session_id VARCHAR(100), -- 游客会话 + api_key_id UUID REFERENCES api_keys(id) ON DELETE SET NULL, -- API 调用可记录 + + source task_source NOT NULL DEFAULT 'web', + status task_status NOT NULL DEFAULT 'pending', + + compression_level compression_level NOT NULL DEFAULT 'medium', + output_format VARCHAR(10), -- NULL 表示保持原格式 + max_width INTEGER, + max_height INTEGER, + preserve_metadata BOOLEAN NOT NULL DEFAULT false, + + total_files INTEGER NOT NULL DEFAULT 0, + completed_files INTEGER NOT NULL DEFAULT 0, + failed_files INTEGER NOT NULL DEFAULT 0, + total_original_size BIGINT NOT NULL DEFAULT 0, + total_compressed_size BIGINT NOT NULL DEFAULT 0, + + error_message TEXT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + + -- 到期清理:匿名可默认 24h;登录用户应由应用按套餐写入更长 retention + expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '24 hours') +); + +CREATE INDEX idx_tasks_user_id ON tasks(user_id); +CREATE INDEX idx_tasks_session_id ON tasks(session_id); +CREATE INDEX idx_tasks_status ON tasks(status); +CREATE INDEX idx_tasks_created_at ON tasks(created_at); +CREATE INDEX idx_tasks_expires_at ON tasks(expires_at); +``` + +### 4.9 task_files - 任务文件 +```sql +CREATE TABLE task_files ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + + original_name VARCHAR(255) NOT NULL, + original_format VARCHAR(10) NOT NULL, + output_format VARCHAR(10) NOT NULL, + + original_size BIGINT NOT NULL, + compressed_size BIGINT, + saved_percent DECIMAL(6, 2), + + storage_path VARCHAR(500), -- S3 key 或本地路径 + status file_status NOT NULL DEFAULT 'pending', + error_message TEXT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ +); + +CREATE INDEX idx_task_files_task_id ON task_files(task_id); +CREATE INDEX idx_task_files_status ON task_files(status); +``` + +### 4.10 idempotency_keys - 幂等记录 +```sql +CREATE TABLE idempotency_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + api_key_id UUID REFERENCES api_keys(id) ON DELETE CASCADE, + + idempotency_key VARCHAR(128) NOT NULL, + request_hash VARCHAR(64) NOT NULL, + + response_status INTEGER NOT NULL, + response_body JSONB, -- JSON 接口可存;二进制接口建议存“元信息 + 对象指针” + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX idx_idempotency_user_key ON idempotency_keys(user_id, idempotency_key) WHERE user_id IS NOT NULL; +CREATE UNIQUE INDEX idx_idempotency_api_key_key ON idempotency_keys(api_key_id, idempotency_key) WHERE api_key_id IS NOT NULL; +CREATE INDEX idx_idempotency_expires_at ON idempotency_keys(expires_at); +``` + +### 4.11 usage_events - 用量账本(明细) +```sql +CREATE TABLE usage_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + api_key_id UUID REFERENCES api_keys(id) ON DELETE SET NULL, + source task_source NOT NULL, + + task_id UUID REFERENCES tasks(id) ON DELETE SET NULL, + task_file_id UUID REFERENCES task_files(id) ON DELETE SET NULL, + + units INTEGER NOT NULL DEFAULT 1, + bytes_in BIGINT NOT NULL DEFAULT 0, + bytes_out BIGINT NOT NULL DEFAULT 0, + format_in VARCHAR(10), + format_out VARCHAR(10), + + occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_usage_events_task_file_unique ON usage_events(task_file_id) WHERE task_file_id IS NOT NULL; +CREATE INDEX idx_usage_events_user_time ON usage_events(user_id, occurred_at); +CREATE INDEX idx_usage_events_api_key_time ON usage_events(api_key_id, occurred_at); +``` + +### 4.12 usage_periods - 用量聚合(按订阅周期) +```sql +CREATE TABLE usage_periods ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + subscription_id UUID REFERENCES subscriptions(id) ON DELETE SET NULL, + + period_start TIMESTAMPTZ NOT NULL, + period_end TIMESTAMPTZ NOT NULL, + + used_units INTEGER NOT NULL DEFAULT 0, + bytes_in BIGINT NOT NULL DEFAULT 0, + bytes_out BIGINT NOT NULL DEFAULT 0, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE(user_id, period_start, period_end) +); + +CREATE INDEX idx_usage_periods_user_period ON usage_periods(user_id, period_start); +``` + +### 4.13 system_config - 系统配置 +```sql +CREATE TABLE system_config ( + key VARCHAR(100) PRIMARY KEY, + value JSONB NOT NULL, + description TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_by UUID REFERENCES users(id) +); +``` + +### 4.14 audit_logs - 审计日志 +```sql +CREATE TABLE audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + action VARCHAR(50) NOT NULL, -- login/compress/config_change/billing/... + resource_type VARCHAR(50), + resource_id UUID, + details JSONB, + ip_address INET, + user_agent TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id); +CREATE INDEX idx_audit_logs_action ON audit_logs(action); +CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at); +``` + +--- + +## 5. Redis 数据结构(建议) + +### 5.1 会话(匿名/网站) +``` +Key: session:{session_id} +Value: JSON { user_id, role, created_at, expires_at } +TTL: 7 days +``` + +### 5.2 限流计数 +``` +Key: rate_limit:{scope}:{id}:{minute} +Value: request_count +TTL: 60 seconds +``` + +### 5.2.1 匿名试用每日额度(硬限制) +``` +Key: anon_quota:{session_id}:{yyyy-mm-dd} +Value: used_units +TTL: 48 hours + +# yyyy-mm-dd:自然日(UTC+8),次日 00:00 重置 + +# 必须:叠加 IP 维度,降低刷 session 的风险(两者任一超出都拒绝) +Key: anon_quota_ip:{ip}:{yyyy-mm-dd} +Value: used_units +TTL: 48 hours +``` + +### 5.3 队列(Redis Streams) +``` +Stream: stream:compress_jobs +Group: compress_workers +Message fields: { task_id, priority, created_at } +``` + +### 5.4 任务进度(可选) +``` +PubSub: pubsub:task:{task_id} +Message: JSON { progress, completed_files, current_file } +``` + +### 5.5 API Key 缓存(可选) +``` +Key: api_key:{key_prefix} +Value: JSON { api_key_id, user_id, permissions, rate_limit, ... } +TTL: 5 minutes +``` + +--- + +## 6. 数据清理策略(必须) + +- **过期任务**:按 `tasks.expires_at` 清理任务/文件/对象存储。 +- **幂等记录**:按 `idempotency_keys.expires_at` 清理。 +- **邮箱验证 Token**:按 `email_verifications.expires_at` 清理已过期且未验证的记录。 +- **密码重置 Token**:按 `password_resets.expires_at` 清理已过期或已使用的记录。 +- **Webhook 事件**:保留 30~90 天(便于排查),过期清理或归档。 +- **用量账本**:`usage_events` 建议长期保留(对账),必要时做分区(按月/按季度)。 + +示例: +```sql +-- 清理过期任务 +DELETE FROM tasks WHERE expires_at < NOW(); + +-- 清理幂等记录 +DELETE FROM idempotency_keys WHERE expires_at < NOW(); + +-- 清理邮箱验证 Token(保留已验证的,清理过期未验证的) +DELETE FROM email_verifications WHERE expires_at < NOW() AND verified_at IS NULL; + +-- 清理密码重置 Token(保留 7 天内的记录用于审计) +DELETE FROM password_resets WHERE expires_at < NOW() - INTERVAL '7 days'; + +-- 清理 Webhook 事件 +DELETE FROM webhook_events WHERE received_at < NOW() - INTERVAL '90 days'; +``` + +--- + +## 7. 初始化数据(建议) + +### 7.1 默认套餐 +```sql +INSERT INTO plans (code, name, stripe_price_id, currency, amount_cents, interval, included_units_per_period, max_file_size_mb, max_files_per_batch, concurrency_limit, retention_days, features) +VALUES + ('free', 'Free', NULL, 'CNY', 0, 'monthly', 500, 5, 10, 2, 1, '{"api": false, "webhook": false}'), + ('pro_monthly', 'Pro(月付)', 'price_xxx_pro_monthly', 'CNY', 1999, 'monthly', 10000, 20, 50, 8, 7, '{"api": true, "webhook": true}'), + ('business_monthly', 'Business(月付)', 'price_xxx_business_monthly', 'CNY', 9999, 'monthly', 100000, 50, 200, 32, 30, '{"api": true, "webhook": true, "ip_allowlist": true}'); +``` + +### 7.2 默认系统配置 +```sql +INSERT INTO system_config (key, value, description) VALUES +('features', '{"registration_enabled": true, "api_key_enabled": true, "anonymous_upload_enabled": true, "email_verification_required": true}', '功能开关'), +('rate_limits', '{"anonymous_per_minute": 10, "anonymous_units_per_day": 10, "user_per_minute": 60, "api_key_per_minute": 100}', '速率限制默认值'), +('file_limits', '{"max_image_pixels": 40000000}', '图片安全限制(像素上限等)'), +('mail', '{"enabled": true, "provider": "custom", "from": "noreply@example.com", "from_name": "ImageForge"}', '邮件服务配置(密码加密存储)'); +``` diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..176abd0 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,711 @@ +# 部署指南 + +## 环境准备 + +### 系统要求 + +- Linux (Ubuntu 22.04+ / Debian 12+ 推荐) +- 2+ CPU 核心(启用独立 Worker 建议 4+) +- 4GB+ 内存 +- 50GB+ 磁盘空间 + +### 依赖安装 + +```bash +# Ubuntu/Debian +sudo apt update +sudo apt install -y \ + build-essential \ + pkg-config \ + libssl-dev \ + libpq-dev \ + cmake \ + nasm \ + libjpeg-dev \ + libpng-dev \ + libwebp-dev + +# 安装 Rust +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +source ~/.cargo/env + +# 初始化数据库会用到 psql(建议安装 PostgreSQL client) +sudo apt install -y postgresql-client + +# 安装 Node.js (前端构建) +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt install -y nodejs +``` + +--- + +## 本地开发 + +### 1. 启动数据库服务 + +```bash +# 使用 Docker Compose 启动 PostgreSQL 和 Redis +docker-compose -f docker/docker-compose.dev.yml up -d +``` + +`docker/docker-compose.dev.yml`: +```yaml +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: imageforge + POSTGRES_PASSWORD: devpassword + POSTGRES_DB: imageforge + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + + # 可选:MinIO(S3 兼容,本地开发更接近生产) + # minio: + # image: minio/minio:RELEASE.2024-01-28T20-20-01Z + # command: server /data --console-address ":9001" + # environment: + # MINIO_ROOT_USER: minioadmin + # MINIO_ROOT_PASSWORD: minioadmin + # ports: + # - "9000:9000" + # - "9001:9001" + # volumes: + # - minio_data:/data + +volumes: + postgres_data: + redis_data: + minio_data: +``` + +### 2. 配置环境变量 + +```bash +cp .env.example .env +``` + +`.env.example`: +```bash +# 运行模式:建议将 API 与 Worker 分开运行 +IMAGEFORGE_ROLE=api # api | worker + +# 服务配置 +HOST=0.0.0.0 +PORT=8080 +PUBLIC_BASE_URL=http://localhost:8080 +RUST_LOG=info,imageforge=debug + +# 数据库 +DATABASE_URL=postgres://imageforge:devpassword@localhost:5432/imageforge + +# Redis +REDIS_URL=redis://localhost:6379 + +# JWT(网站/管理后台) +JWT_SECRET=your-super-secret-key-change-in-production +JWT_EXPIRY_HOURS=168 + +# API Key +API_KEY_PEPPER=please-change-this-in-production + +# 存储(生产建议 S3/MinIO + 预签名 URL) +STORAGE_TYPE=local # local | s3 +STORAGE_PATH=./uploads +# 预签名下载链接过期(分钟) +SIGNED_URL_TTL_MINUTES=60 + +# S3 配置(如果使用 S3/MinIO) +# S3_ENDPOINT=http://localhost:9000 +# S3_BUCKET=your-bucket +# S3_REGION=us-east-1 +# S3_ACCESS_KEY=xxx +# S3_SECRET_KEY=xxx + +# 计费(已确认:Stripe) +BILLING_PROVIDER=stripe +STRIPE_SECRET_KEY=sk_test_xxx +STRIPE_WEBHOOK_SECRET=whsec_xxx + +# 限制(默认值;最终以套餐/用户覆盖为准) +ALLOW_ANONYMOUS_UPLOAD=true +ANON_MAX_FILE_SIZE_MB=5 +ANON_MAX_FILES_PER_BATCH=5 +ANON_DAILY_UNITS=10 +MAX_IMAGE_PIXELS=40000000 +IDEMPOTENCY_TTL_HOURS=24 + +# 结果保留(匿名默认;登录用户按套餐 retention_days) +ANON_RETENTION_HOURS=24 + +# 管理员初始账户 +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=changeme123 +``` + +### 3. 初始化数据库 + +```bash +# 运行首期迁移(可重复执行) +psql "$DATABASE_URL" -f migrations/001_init.sql +``` + +### 4. 启动开发服务器 + +```bash +# 后端 API (热重载) +cargo install cargo-watch +IMAGEFORGE_ROLE=api cargo watch -x run + +# 后端 Worker(另一个终端,处理异步/批量任务) +IMAGEFORGE_ROLE=worker cargo watch -x run + +# 前端 (另一个终端) +cd frontend +npm install +npm run dev +``` + +### 5. Stripe Webhook 本地调试(可选) + +本地调试 Stripe 订阅/支付状态,通常需要将 Stripe Webhook 转发到本机: + +```bash +# 1) 安装并登录 Stripe CLI(按官方文档) +# 2) 监听并转发到你的后端回调地址 +stripe listen --forward-to http://localhost:8080/api/v1/webhooks/stripe + +# CLI 会输出一个 whsec_...,写入 .env 的 STRIPE_WEBHOOK_SECRET +``` + +--- + +## 生产部署 + +> 注意:以下内容为生产部署模板示例;仓库当前首期仅提供开发用 `docker/docker-compose.dev.yml`,生产 compose/Dockerfile/nginx/k8s 等可在开工阶段按需落地并调整。 + +### 方案一:Docker Compose(推荐小规模) + +`docker/docker-compose.prod.yml`: +```yaml +version: '3.8' + +services: + api: + build: + context: .. + dockerfile: docker/Dockerfile + environment: + - IMAGEFORGE_ROLE=api + - BILLING_PROVIDER=stripe + - PUBLIC_BASE_URL=https://your-domain.com + - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY} + - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET} + - DATABASE_URL=postgres://imageforge:${DB_PASSWORD}@postgres:5432/imageforge + - REDIS_URL=redis://redis:6379 + - JWT_SECRET=${JWT_SECRET} + - STORAGE_TYPE=local + - STORAGE_PATH=/app/uploads + ports: + - "8080:8080" + volumes: + - uploads:/app/uploads + depends_on: + - postgres + - redis + restart: unless-stopped + + worker: + build: + context: .. + dockerfile: docker/Dockerfile + environment: + - IMAGEFORGE_ROLE=worker + - DATABASE_URL=postgres://imageforge:${DB_PASSWORD}@postgres:5432/imageforge + - REDIS_URL=redis://redis:6379 + - JWT_SECRET=${JWT_SECRET} + - STORAGE_TYPE=local + - STORAGE_PATH=/app/uploads + volumes: + - uploads:/app/uploads + depends_on: + - postgres + - redis + restart: unless-stopped + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: imageforge + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: imageforge + volumes: + - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + + redis: + image: redis:7-alpine + volumes: + - redis_data:/data + restart: unless-stopped + + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + - /etc/letsencrypt:/etc/letsencrypt:ro + depends_on: + - api + restart: unless-stopped + +volumes: + uploads: + postgres_data: + redis_data: +``` + +`docker/Dockerfile`: +```dockerfile +FROM rust:1.92-bookworm AS builder + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + cmake \ + nasm \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +COPY Cargo.toml Cargo.lock ./ +COPY src ./src +COPY migrations ./migrations +COPY templates ./templates + +RUN cargo build --release + +# 前端构建阶段 +FROM node:20-alpine AS frontend-builder + +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm ci +COPY frontend ./ +RUN npm run build + +# 运行阶段 +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=builder /app/target/release/imageforge ./imageforge +COPY --from=frontend-builder /app/frontend/dist ./static +COPY migrations ./migrations + +RUN mkdir -p uploads + +ENV HOST=0.0.0.0 +ENV PORT=8080 + +EXPOSE 8080 + +CMD ["./imageforge"] +``` + +`docker/nginx.conf`: +```nginx +events { + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + + # 日志 + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + # 文件上传大小限制 + client_max_body_size 100M; + + # Gzip + gzip on; + gzip_types text/plain text/css application/json application/javascript; + + upstream backend { + server api:8080; + } + + server { + listen 80; + server_name your-domain.com; + return 301 https://$server_name$request_uri; + } + + server { + listen 443 ssl http2; + server_name your-domain.com; + + ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; + + # SSL 配置 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; + ssl_prefer_server_ciphers off; + + # 静态文件 + location /static/ { + proxy_pass http://backend; + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # WebSocket + location /ws/ { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 86400; + } + + # API 和其他请求 + location / { + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} +``` + +### 部署步骤 + +```bash +# 1. 创建 .env 文件 +cat > .env << EOF +DB_PASSWORD=your-secure-db-password +JWT_SECRET=your-very-long-random-jwt-secret-at-least-32-chars +STRIPE_SECRET_KEY=sk_live_xxx +STRIPE_WEBHOOK_SECRET=whsec_xxx +EOF + +# 2. 获取 SSL 证书 +sudo certbot certonly --standalone -d your-domain.com + +# 3. 构建并启动 +docker-compose -f docker/docker-compose.prod.yml up -d --build + +# 4. 查看日志 +docker-compose -f docker/docker-compose.prod.yml logs -f api +``` + +--- + +### 方案二:Kubernetes(大规模) + +`k8s/deployment.yaml`: +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: imageforge +spec: + replicas: 3 + selector: + matchLabels: + app: imageforge + template: + metadata: + labels: + app: imageforge + spec: + containers: + - name: imageforge + image: your-registry/imageforge:latest + ports: + - containerPort: 8080 + env: + - name: IMAGEFORGE_ROLE + value: api + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: imageforge-secrets + key: database-url + - name: REDIS_URL + valueFrom: + secretKeyRef: + name: imageforge-secrets + key: redis-url + - name: JWT_SECRET + valueFrom: + secretKeyRef: + name: imageforge-secrets + key: jwt-secret + resources: + requests: + memory: "512Mi" + cpu: "500m" + limits: + memory: "2Gi" + cpu: "2000m" + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: imageforge +spec: + selector: + app: imageforge + ports: + - port: 80 + targetPort: 8080 + type: ClusterIP +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: imageforge + annotations: + kubernetes.io/ingress.class: nginx + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + tls: + - hosts: + - your-domain.com + secretName: imageforge-tls + rules: + - host: your-domain.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: imageforge + port: + number: 80 + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: imageforge-worker +spec: + replicas: 2 + selector: + matchLabels: + app: imageforge-worker + template: + metadata: + labels: + app: imageforge-worker + spec: + containers: + - name: imageforge-worker + image: your-registry/imageforge:latest + env: + - name: IMAGEFORGE_ROLE + value: worker + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: imageforge-secrets + key: database-url + - name: REDIS_URL + valueFrom: + secretKeyRef: + name: imageforge-secrets + key: redis-url + - name: JWT_SECRET + valueFrom: + secretKeyRef: + name: imageforge-secrets + key: jwt-secret + resources: + requests: + memory: "512Mi" + cpu: "500m" + limits: + memory: "4Gi" + cpu: "4000m" +``` + +--- + +## 监控与日志 + +### Prometheus 指标 + +应用暴露 `/metrics` 端点: + +```rust +// 在代码中添加指标 +use prometheus::{Counter, Histogram}; + +lazy_static! { + static ref COMPRESSION_REQUESTS: Counter = Counter::new( + "imageforge_compression_requests_total", + "Total number of compression requests" + ).unwrap(); + + static ref COMPRESSION_DURATION: Histogram = Histogram::with_opts( + HistogramOpts::new( + "imageforge_compression_duration_seconds", + "Time spent compressing images" + ) + ).unwrap(); +} +``` + +### Grafana 仪表板 + +监控项目: +- 请求量 / QPS +- 响应时间 P50/P95/P99 +- 错误率 +- 压缩任务队列长度 +- CPU / 内存使用率 +- 磁盘使用率 + +### 日志聚合 + +使用 ELK Stack 或 Loki: + +```yaml +# docker-compose 添加 Loki +loki: + image: grafana/loki:2.9.0 + ports: + - "3100:3100" + command: -config.file=/etc/loki/local-config.yaml + +promtail: + image: grafana/promtail:2.9.0 + volumes: + - /var/log:/var/log + - ./promtail-config.yml:/etc/promtail/config.yml + command: -config.file=/etc/promtail/config.yml +``` + +--- + +## 备份策略 + +### 数据库备份 + +```bash +#!/bin/bash +# backup.sh + +DATE=$(date +%Y%m%d_%H%M%S) +BACKUP_DIR=/backups + +# PostgreSQL 备份 +docker exec postgres pg_dump -U imageforge imageforge | gzip > $BACKUP_DIR/db_$DATE.sql.gz + +# 保留最近 7 天的备份 +find $BACKUP_DIR -name "db_*.sql.gz" -mtime +7 -delete + +# 可选:上传到 S3 +# aws s3 cp $BACKUP_DIR/db_$DATE.sql.gz s3://your-bucket/backups/ +``` + +添加到 crontab: +```bash +0 3 * * * /path/to/backup.sh +``` + +### 上传文件备份 + +如果使用本地存储,定期同步到 S3: +```bash +aws s3 sync /app/uploads s3://your-bucket/uploads --delete +``` + +--- + +## 故障排查 + +### 常见问题 + +**1. 数据库连接失败** +```bash +# 检查 PostgreSQL 状态 +docker-compose logs postgres + +# 测试连接 +docker exec -it postgres psql -U imageforge -d imageforge -c "SELECT 1" +``` + +**2. 压缩失败** +```bash +# 检查应用日志 +docker-compose logs api | grep ERROR +docker-compose logs worker | grep ERROR + +# 检查磁盘空间 +df -h +``` + +**3. 内存不足** +```bash +# 查看内存使用 +docker stats + +# 调整容器内存限制 +``` + +**4. 上传超时** +```bash +# 检查 Nginx 配置 +# client_max_body_size 和 proxy_read_timeout +``` + +### 健康检查端点 + +``` +GET /health +{ + "status": "healthy", + "database": "connected", + "redis": "connected", + "storage": "available", + "uptime": 3600 +} +``` diff --git a/docs/email.md b/docs/email.md new file mode 100644 index 0000000..238ac8d --- /dev/null +++ b/docs/email.md @@ -0,0 +1,471 @@ +# 邮件服务设计 - ImageForge + +目标:提供开箱即用的邮件服务,支持注册验证和密码重置,管理员只需配置邮箱地址和授权码即可使用。 + +--- + +## 1. 功能范围 + +### 1.1 首期必须 +- **注册邮箱验证**:用户注册后发送验证邮件,点击链接完成激活 +- **密码重置**:用户申请重置密码,发送重置链接邮件 + +### 1.2 后续可选(V1+) +- 订阅到期提醒 +- 配额即将用尽提醒 +- 支付成功/失败通知 +- 安全告警(异地登录、API Key 创建等) + +--- + +## 2. 技术方案 + +### 2.1 SMTP 直连 +使用标准 SMTP 协议,通过 `lettre` (Rust) 发送邮件,无需第三方 SaaS 依赖。 + +```toml +# Cargo.toml +[dependencies] +lettre = { version = "0.11", default-features = false, features = ["tokio1", "tokio1-rustls-tls", "builder", "smtp-transport"] } +``` + +### 2.2 预置服务商模板 + +管理员只需选择服务商并填写邮箱/授权码,系统自动填充 SMTP 配置: + +| 服务商 | SMTP 地址 | 端口 | 加密 | 备注 | +|--------|-----------|------|------|------| +| QQ 邮箱 | `smtp.qq.com` | 465 | SSL | 需开启 SMTP 服务并获取授权码 | +| 163 邮箱 | `smtp.163.com` | 465 | SSL | 需开启 SMTP 服务并获取授权码 | +| 阿里企业邮箱 | `smtp.qiye.aliyun.com` | 465 | SSL | 使用邮箱密码 | +| 腾讯企业邮箱 | `smtp.exmail.qq.com` | 465 | SSL | 需获取授权码 | +| Gmail | `smtp.gmail.com` | 587 | STARTTLS | 需开启两步验证并生成应用专用密码 | +| Outlook/365 | `smtp.office365.com` | 587 | STARTTLS | 使用账号密码 | +| 自定义 | 用户填写 | 用户填写 | 用户选择 | 支持任意 SMTP 服务器 | + +--- + +## 3. 配置设计 + +### 3.1 环境变量 + +```bash +# .env.example + +# 邮件服务配置 +MAIL_ENABLED=true +# 开发环境:当 MAIL_ENABLED=false 时,可打开该开关把验证/重置链接打印到日志(便于本地联调) +MAIL_LOG_LINKS_WHEN_DISABLED=true + +# 预置服务商(可选:qq / 163 / aliyun_enterprise / tencent_enterprise / gmail / outlook / custom) +MAIL_PROVIDER=qq + +# 发件邮箱(必填) +MAIL_FROM=noreply@example.com + +# 授权码/密码(必填) +MAIL_PASSWORD=your-smtp-authorization-code + +# 发件人名称(可选,默认 "ImageForge") +MAIL_FROM_NAME=ImageForge + +# === 以下仅 MAIL_PROVIDER=custom 时需要 === +# MAIL_SMTP_HOST=smtp.example.com +# MAIL_SMTP_PORT=465 +# MAIL_SMTP_ENCRYPTION=ssl # ssl / starttls / none +``` + +### 3.2 数据库配置(管理后台可改) + +```sql +-- system_config 表 +INSERT INTO system_config (key, value, description) VALUES +('mail', '{ + "enabled": true, + "provider": "qq", + "from": "noreply@example.com", + "from_name": "ImageForge", + "password_encrypted": "...", + "custom_smtp": null +}', '邮件服务配置'); +``` + +### 3.3 Rust 配置结构 + +```rust +#[derive(Debug, Clone, Deserialize)] +pub struct MailConfig { + pub enabled: bool, + pub provider: MailProvider, + pub from: String, + pub from_name: String, + pub password: String, // 运行时解密 + pub custom_smtp: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub enum MailProvider { + QQ, + NetEase163, + AliyunEnterprise, + TencentEnterprise, + Gmail, + Outlook, + Custom, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CustomSmtpConfig { + pub host: String, + pub port: u16, + pub encryption: SmtpEncryption, +} + +#[derive(Debug, Clone, Deserialize)] +pub enum SmtpEncryption { + Ssl, + StartTls, + None, +} + +impl MailProvider { + pub fn smtp_config(&self) -> (String, u16, SmtpEncryption) { + match self { + Self::QQ => ("smtp.qq.com".into(), 465, SmtpEncryption::Ssl), + Self::NetEase163 => ("smtp.163.com".into(), 465, SmtpEncryption::Ssl), + Self::AliyunEnterprise => ("smtp.qiye.aliyun.com".into(), 465, SmtpEncryption::Ssl), + Self::TencentEnterprise => ("smtp.exmail.qq.com".into(), 465, SmtpEncryption::Ssl), + Self::Gmail => ("smtp.gmail.com".into(), 587, SmtpEncryption::StartTls), + Self::Outlook => ("smtp.office365.com".into(), 587, SmtpEncryption::StartTls), + Self::Custom => panic!("Custom provider requires explicit config"), + } + } +} +``` + +--- + +## 4. 邮件模板 + +### 4.1 模板存储 + +建议使用内嵌模板(编译时包含),支持变量替换: + +``` +templates/ +├── email_verification.html +├── email_verification.txt +├── password_reset.html +└── password_reset.txt +``` + +### 4.2 注册验证邮件 + +**主题**:`验证您的 ImageForge 账号` + +**HTML 模板** (`email_verification.html`): + +```html + + + + + + 验证您的邮箱 + + + +
+
+ +
+
+

欢迎注册 ImageForge

+

您好,{{username}}!

+

感谢您注册 ImageForge。请点击下方按钮验证您的邮箱地址:

+

+ 验证邮箱 +

+

或复制以下链接到浏览器打开:

+ +

+ 此链接将在 24 小时后失效。如果您没有注册 ImageForge 账号,请忽略此邮件。 +

+
+ +
+ + +``` + +**纯文本模板** (`email_verification.txt`): + +```text +欢迎注册 ImageForge + +您好,{{username}}! + +感谢您注册 ImageForge。请点击以下链接验证您的邮箱地址: + +{{verification_url}} + +此链接将在 24 小时后失效。如果您没有注册 ImageForge 账号,请忽略此邮件。 + +--- +© {{year}} ImageForge +此邮件由系统自动发送,请勿直接回复。 +``` + +### 4.3 密码重置邮件 + +**主题**:`重置您的 ImageForge 密码` + +**HTML 模板** (`password_reset.html`): + +```html + + + + + + 重置密码 + + + +
+
+ +
+
+

重置您的密码

+

您好,{{username}}!

+

我们收到了重置您 ImageForge 账号密码的请求。请点击下方按钮设置新密码:

+

+ 重置密码 +

+

或复制以下链接到浏览器打开:

+ +
+ 安全提示:此链接将在 1 小时后失效。如果您没有请求重置密码,请忽略此邮件,您的账号仍然安全。 +
+
+ +
+ + +``` + +--- + +## 5. 数据库设计 + +### 5.1 邮箱验证 Token + +```sql +CREATE TABLE email_verifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(64) NOT NULL, -- SHA256(token) + expires_at TIMESTAMPTZ NOT NULL, + verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_email_verifications_token ON email_verifications(token_hash); +CREATE INDEX idx_email_verifications_user_id ON email_verifications(user_id); +CREATE INDEX idx_email_verifications_expires_at ON email_verifications(expires_at); +``` + +### 5.2 密码重置 Token + +```sql +CREATE TABLE password_resets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(64) NOT NULL, -- SHA256(token) + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_password_resets_token ON password_resets(token_hash); +CREATE INDEX idx_password_resets_user_id ON password_resets(user_id); +CREATE INDEX idx_password_resets_expires_at ON password_resets(expires_at); +``` + +### 5.3 用户表扩展 + +```sql +ALTER TABLE users ADD COLUMN email_verified_at TIMESTAMPTZ; +``` + +--- + +## 6. API 接口 + +### 6.1 发送验证邮件 + +```http +POST /auth/send-verification +Authorization: Bearer +``` + +**响应**: +```json +{ "success": true, "data": { "message": "验证邮件已发送,请查收" } } +``` + +**限流**:同一用户 1 分钟内最多发送 1 次 + +### 6.2 验证邮箱 + +```http +POST /auth/verify-email +Content-Type: application/json +``` + +**请求体**: +```json +{ "token": "verification-token-from-email" } +``` + +**响应**: +```json +{ "success": true, "data": { "message": "邮箱验证成功" } } +``` + +### 6.3 请求密码重置 + +```http +POST /auth/forgot-password +Content-Type: application/json +``` + +**请求体**: +```json +{ "email": "user@example.com" } +``` + +**响应**(无论邮箱是否存在都返回成功,防止枚举): +```json +{ "success": true, "data": { "message": "如果该邮箱已注册,您将收到重置邮件" } } +``` + +**限流**:同一 IP 1 分钟内最多请求 3 次 + +### 6.4 重置密码 + +```http +POST /auth/reset-password +Content-Type: application/json +``` + +**请求体**: +```json +{ + "token": "reset-token-from-email", + "new_password": "new-secure-password" +} +``` + +**响应**: +```json +{ "success": true, "data": { "message": "密码重置成功,请重新登录" } } +``` + +--- + +## 7. 安全考虑 + +### 7.1 Token 安全 +- Token 使用 `crypto-secure random`(32 字节 base64) +- 数据库只存 `SHA256(token)`,不存明文 +- Token 单次有效,使用后立即标记 `used_at` + +### 7.2 时效控制 +- 邮箱验证 Token:24 小时有效 +- 密码重置 Token:1 小时有效 + +### 7.3 防滥用 +- 发送邮件接口严格限流 +- 密码重置不泄露"邮箱是否存在" +- 失败尝试记录审计日志 + +### 7.4 授权码加密存储 +- SMTP 授权码在数据库中加密存储(AES-256-GCM) +- 密钥来自环境变量或密钥管理服务 + +--- + +## 8. 管理后台配置界面 + +管理后台提供邮件服务配置页面: + +``` +邮件服务配置 +├── 启用状态:[开关] +├── 服务商选择:[下拉:QQ邮箱 / 163邮箱 / 阿里企业邮 / 腾讯企业邮 / Gmail / Outlook / 自定义] +├── 发件邮箱:[输入框] +├── 授权码/密码:[密码输入框] +├── 发件人名称:[输入框,默认 ImageForge] +├── (自定义时显示) +│ ├── SMTP 服务器:[输入框] +│ ├── 端口:[输入框] +│ └── 加密方式:[下拉:SSL / STARTTLS / 无] +└── [测试发送] [保存配置] +``` + +**测试发送**:向管理员邮箱发送测试邮件,验证配置是否正确。 + +--- + +## 9. 常见邮件服务商配置指南 + +### 9.1 QQ 邮箱 +1. 登录 QQ 邮箱 → 设置 → 账户 +2. 找到「POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务」 +3. 开启「SMTP 服务」 +4. 按提示发送短信获取授权码 +5. 将授权码填入系统配置 + +### 9.2 163 邮箱 +1. 登录 163 邮箱 → 设置 → POP3/SMTP/IMAP +2. 开启「SMTP 服务」 +3. 设置客户端授权密码 +4. 将授权密码填入系统配置 + +### 9.3 Gmail +1. 登录 Google 账号 → 安全性 +2. 开启「两步验证」 +3. 生成「应用专用密码」(选择"邮件"+"其他") +4. 将应用专用密码填入系统配置 + +### 9.4 阿里企业邮箱 +1. 使用邮箱地址和登录密码即可 +2. SMTP 服务默认开启 diff --git a/docs/frontend.md b/docs/frontend.md new file mode 100644 index 0000000..4093b20 --- /dev/null +++ b/docs/frontend.md @@ -0,0 +1,158 @@ +# 前端工程设计(Vue3)- ImageForge + +目标:支撑“网站压缩 + 开发者 API 控制台 + 计费/发票 + 管理后台”的一个 Vue3 SPA(或同仓多入口)。 + +UI/UX 规格见 `docs/ui.md`。 + +--- + +## 1. 技术栈(确定) + +- Vue 3 + TypeScript + Vite +- 路由:Vue Router +- 状态:Pinia +- 网络:Fetch 或 Axios(统一封装,支持幂等头、错误归一) +- 样式:Tailwind CSS(推荐)或 CSS Variables + 自研组件 +- 工具:VueUse、Day.js(或 date-fns)、Zod(表单校验可选) + +--- + +## 2. 路由与页面 + +### 2.1 公共 +``` +/ 首页压缩 +/pricing 套餐与 FAQ +/docs 开发者文档(引导) +/login +/register +/terms +/privacy +``` + +### 2.2 用户控制台(登录) +``` +/dashboard +/dashboard/history +/dashboard/api-keys +/dashboard/billing +/dashboard/settings +``` + +### 2.3 管理后台(管理员) +``` +/admin +/admin/users +/admin/tasks +/admin/billing +/admin/config +``` + +--- + +## 3. 前端项目结构(建议) + +``` +src/ + app/ # 路由、布局、鉴权守卫 + pages/ # 页面(route components) + components/ # 通用组件(UI、上传、表格等) + features/ + compress/ # 压缩:上传、任务、下载 + billing/ # 套餐、订阅、发票、用量 + apiKeys/ # API Key 管理 + admin/ # 管理后台 + services/ # API 封装(http client + endpoints) + stores/ # Pinia stores + styles/ # 主题变量、tailwind 入口 + utils/ # 格式化、文件校验、错误处理 +``` + +--- + +## 4. API 调用规范(前端约定) + +### 4.1 Base URL +- 统一使用 `/api/v1` + +### 4.2 幂等与重试 +- 对 `POST /compress/*`、`POST /billing/checkout` 等请求默认注入 `Idempotency-Key`(UUID)。 +- 网络重试仅限“明确幂等”的请求(否则会重复扣费/重复建任务)。 + +### 4.3 错误处理 +将后端错误码映射为统一 UI 提示: +- `QUOTA_EXCEEDED`:引导升级/查看账单页 +- `RATE_LIMITED`:展示倒计时(读取 `Retry-After`) +- `FILE_TOO_LARGE` / `TOO_MANY_PIXELS`:定位到具体文件并提示如何处理 + +--- + +## 5. 压缩流程(Web) + +### 5.1 同步 vs 异步 +- 小文件/少量:可直接调用 `POST /compress`,拿到 `download_url`。 +- 批量/大文件:调用 `POST /compress/batch`,拿到 `task_id` 后: + - 优先 WebSocket/SSE 订阅进度; + - fallback:轮询 `GET /compress/tasks/{task_id}`。 + +### 5.2 上传前校验 +前端必须做“用户体验级校验”(后端仍需二次校验): +- 格式白名单(png/jpg/jpeg/webp/avif/gif/bmp/tiff/ico,GIF 仅静态) +- 文件大小与数量(按匿名/登录/套餐提示不同上限) +- 匿名试用:每日 10 次限制提示(达到后引导登录/升级) +- 可选:读取图片宽高(避免明显超限) + +--- + +## 6. 计费与用量(前端展示) + +对接 `docs/api.md` 的 Billing 模块: +- `/pricing` 页面:读取 `GET /billing/plans` +- 控制台概览:读取 `GET /billing/usage`、`GET /billing/subscription` +- 订阅升级:调用 `POST /billing/checkout` 获取 `checkout_url` 并跳转 +- 支付方式/取消订阅:调用 `POST /billing/portal` 获取 portal 链接 +- 发票列表:`GET /billing/invoices` + +UI 必须展示: +- 当期已用/剩余、重置时间 +- 当前订阅状态(active/past_due/canceled) + +--- + +## 7. API Key 控制台(开发者体验) + +页面提供三类信息: +1) Key 管理:创建/禁用/轮换(创建时只展示一次完整 Key) +2) 用量:本周期已用/剩余(与 Billing 用量一致) +3) 快速接入:curl 示例 + 常见错误码 + 幂等建议 + +--- + +## 8. 安全建议(前端侧) + +- 若使用 Bearer Token:避免 localStorage(XSS 风险),优先 HttpOnly Cookie 会话(需要 CSRF 策略)。 +- 上传与下载链接:明确到期时间与隐私说明(默认去 EXIF)。 +- 管理后台路由加守卫:`role=admin` 才可进入。 + +--- + +## 9. 主题变量(CSS Variables) + +首期可用 Tailwind 或自研组件,但建议保留一层 CSS 变量,方便后续主题化/暗色模式: + +```css +:root { + --bg: 248 250 252; + --card: 255 255 255; + --text: 15 23 42; + --muted: 71 85 105; + --border: 226 232 240; + + --brand: 99 102 241; + --brand-strong: 79 70 229; + + --success: 34 197 94; + --warning: 245 158 11; + --danger: 239 68 68; +} +``` diff --git a/docs/observability.md b/docs/observability.md new file mode 100644 index 0000000..6c22cc5 --- /dev/null +++ b/docs/observability.md @@ -0,0 +1,99 @@ +# 可观测性设计(日志/指标/追踪)- ImageForge + +目标:让“压缩效果、性能瓶颈、队列健康、计费正确性、滥用风险”都能被观测与告警,便于商用运营。 + +--- + +## 1. 统一规范 + +### 1.1 请求标识 +- 每个 HTTP 请求生成 `request_id`(或从网关透传),写入: + - 响应头:`X-Request-Id` + - 日志字段:`request_id` + - Trace:`trace_id/span_id`(如启用 OpenTelemetry) + +### 1.2 日志格式 +- 结构化日志(JSON)优先,便于 Loki/ELK 聚合。 +- 禁止记录:明文密码、JWT、API Key、Webhook secret。 + +建议最小字段: +- `timestamp`、`level`、`service`(api/worker)、`request_id` +- `user_id`(可空)、`api_key_id`(可空)、`ip`、`user_agent` +- `route`、`method`、`status`、`latency_ms` +- `task_id`、`task_file_id`(压缩链路) +- `bytes_in`、`bytes_out`、`format_in/out`、`compression_level` + +--- + +## 2. 指标(Prometheus) + +### 2.1 API 服务指标 +请求类: +- `http_requests_total{route,method,status}` +- `http_request_duration_seconds_bucket{route,method}` + +鉴权与风控: +- `auth_fail_total{reason}` +- `rate_limited_total{scope}`(anonymous/user/api_key) +- `quota_exceeded_total{plan}` + +计费链路: +- `billing_webhook_total{provider,event_type,result}` +- `subscription_state_total{state}` +- `invoice_total{status}` + +### 2.2 Worker 指标 +队列与吞吐: +- `jobs_received_total` +- `jobs_inflight` +- `jobs_completed_total{result}` +- `job_duration_seconds_bucket{format,level}` + +压缩效果: +- `bytes_in_total`、`bytes_out_total`、`bytes_saved_total` +- `compression_ratio_bucket{format,level}` + +资源与异常: +- `decode_failed_total{reason}` +- `pixel_limit_hit_total` + +### 2.3 Redis/队列指标(可选) +- Streams 消费延迟、pending 数量、dead-letter 数量(如实现)。 + +--- + +## 3. 追踪(Tracing) + +建议:API 与 Worker 使用 OpenTelemetry,打通跨服务链路: +- API:`create_task` span、`auth` span、`db` span、`redis` span +- Worker:`fetch_job` span、`download_input` span、`compress` span、`upload_output` span、`metering` span + +价值: +- 发现耗时集中点(解码/编码/S3/DB)。 +- 对账问题定位(用量事件写入失败/重复)。 + +--- + +## 4. 仪表板与告警(建议) + +### 4.1 SLO(建议起点) +- API:P95 < 300ms(不含压缩直返)、错误率 < 0.5% +- Worker:队列积压 < N(按规模定义),失败率 < 1% + +### 4.2 告警 +可用性: +- `http 5xx` 激增 +- `/health` 探活失败 + +队列健康: +- pending/inflight 持续上升 +- 单任务耗时异常增长 + +计费正确性: +- webhook 处理失败 +- 订阅状态异常(active->incomplete 回退等) + +滥用风险: +- 单 key/单 IP 用量突增 +- 格式探测失败率异常 + diff --git a/docs/prd.md b/docs/prd.md new file mode 100644 index 0000000..8300ee8 --- /dev/null +++ b/docs/prd.md @@ -0,0 +1,162 @@ +# 产品需求文档(PRD)- ImageForge + +目标:做一个接近商用的图片压缩网站 + 对外 API + 计费系统(可订阅/可计量)。 + +> 本文只描述“做什么/做到什么程度”。技术实现细节在 `docs/architecture.md`、`docs/api.md`、`docs/database.md`、`docs/deployment.md`、`docs/ui.md`。 + +--- + +## 1. 产品定位 + +### 1.1 一句话 +提供高质量、稳定、可规模化的图片压缩服务:既能让普通用户在网页上批量压缩,也能让开发者通过 API Key 集成到 CI/CD 或业务系统,并按使用量/套餐计费。 + +### 1.2 核心价值 +- **效果**:压缩比与画质控制可预期(可选有损/无损/近无损)。 +- **体验**:网站拖拽即用、批量任务、可下载 ZIP、历史可追溯。 +- **工程化**:对外 API 稳定、可观测、可限流、可计量、可计费。 +- **安全合规**:默认去除隐私元数据、明确保留期限、支持删除。 + +### 1.3 非目标(明确不做/后做) +- 图像编辑(裁剪/滤镜/水印)不作为核心能力(后续可扩展)。 +- CDN 图片处理(按 URL 在线压缩、自动适配)不作为首期必须。 +- 视频/动图(GIF/APNG)暂不纳入首期(除非强需求)。 + +--- + +## 2. 用户与角色 + +### 2.1 用户类型 +- **游客(Anonymous)**:无需注册即可试用网站压缩(强限制、短保留)。 +- **注册用户(User)**:使用网站 + 管理 API Key + 查看用量/发票。 +- **企业/团队用户(Team,可选)**:多人协作、共享额度、角色权限(可作为 V1+)。 +- **管理员(Admin)**:风控、配置、用户/任务/账单审核、运营数据。 + +### 2.2 典型场景 +- 设计师:批量压缩并打包下载,保留 7 天内历史。 +- 开发者:CI 里调用 API,在发布前批量压缩静态资源。 +- 运营:导出周期用量、查看节省带宽、按部门拆分账单(团队版)。 + +--- + +## 3. 功能范围(按模块) + +### 3.1 网站压缩(Web) +- 拖拽/选择图片(单次多文件)。 +- 压缩参数: + - 压缩率:1-100(数值越大压缩越强)。 + - 输出格式:保持原格式。 + - 可选:限制宽高(等比缩放)。 + - 可选:是否保留元数据(默认不保留)。 +- 结果展示: + - 逐文件:原始大小、压缩后大小、压缩率、状态、下载。 + - 汇总:总节省、下载 ZIP、失败文件原因。 +- 历史记录: + - 最近任务列表(可筛选:日期/格式/状态)。 + - 可再次下载(未过期)、或重新发起压缩(使用相同参数)。 +- 游客试用: + - 自动创建匿名会话(Cookie),只允许较小文件/较少数量。 + - 到期自动清理,不提供“历史”永久保存。 + +### 3.2 对外 API(Developer API) +- API Key:创建/禁用/轮换/权限范围(最小权限)。 +- 压缩接口: + - 同步单图(可返回二进制或下载链接)。 + - 批量/大文件异步任务(任务状态、下载 ZIP)。 + - 可选:Webhook 回调(替代轮询/WS,用于服务端集成)。 +- 工程化能力: + - 幂等(Idempotency-Key),避免重复扣费/重复任务。 + - 配额与用量头信息(本订阅周期已用/剩余/上限)。 + - 速率限制(标准 `Retry-After` + 速率头)。 + +### 3.3 计费与用量(Billing & Metering) +详见 `docs/billing.md`,PRD 层面要求: +- 具备 **套餐**(Free/Pro/Business)与 **配额**(每订阅周期压缩次数、文件大小/批量上限、保留期等)。 +- 具备 **订阅** 生命周期:试用、激活、到期、欠费、取消、恢复。 +- 具备 **发票**(Invoice)与 **支付记录**(Payment)可追溯。 +- 具备 **用量计量**:以“成功压缩的文件数”为主计量单位(可扩展到字节、转换格式等)。 +- 具备 **风控策略**:异常调用/盗刷/滥用限制与告警。 + +### 3.4 管理后台(Admin) +- 用户管理:冻结/解冻、限流覆盖、手动调整套餐/额度、查看登录/调用记录。 +- 任务管理:查看任务队列/失败原因、取消任务、重试。 +- 计费管理:查看订阅与发票、手动赠送额度、处理退款(首期可做“手动退款登记”)。 +- 系统配置:全局限流、文件限制、保留期、功能开关(注册开关等)。 +- 监控面板:QPS、延迟、错误率、队列长度、CPU/内存、S3/DB/Redis 状态。 + +--- + +## 4. 套餐与配额(建议默认值,可调) + +> 这些是“产品默认建议”,最终可在上线前确认并固化到配置/数据库。 + +| 项 | Free | Pro | Business | +|---|---:|---:|---:| +| 每周期压缩次数(成功文件数) | 500 | 10,000 | 100,000+ | +| 单文件大小上限 | 5 MB | 20 MB | 50 MB | +| 单次批量上限 | 10 | 50 | 200 | +| 并发(建议) | 2 | 8 | 32 | +| 结果保留期 | 24 小时 | 7 天 | 30 天 | +| API 访问 | ❌ | ✅ | ✅ | +| Webhook | ❌ | ✅ | ✅ | +| SSO/团队 | ❌ | ❌ | 可选 | + +周期定义(用于“本周期已用/剩余/重置时间”的展示与扣减): +- **Pro/Business(付费)**:按 Stripe 订阅周期(`current_period_start` ~ `current_period_end`),不是自然月。 +- **Free(未订阅)**:按自然月重置(UTC+8)。 + +匿名试用(无需登录): +- 每日 10 次(以成功压缩文件数计,失败不计) +- 日界:自然日(UTC+8),次日 00:00 重置 +- 不提供 API Key +- 结果保留 24 小时 + +计量单位: +- **compression_unit**:每成功压缩 1 个输出文件计 1。 +- 对于批量任务:按文件粒度计量;失败文件不计量。 +- 幂等:同一个 Idempotency-Key 重试不重复计量。 + +--- + +## 5. 关键体验与质量指标(NFR) + +### 5.1 性能与稳定性(建议目标) +- 99% 的同步单图压缩在 3s 内完成(小图、常见格式)。 +- 批量任务在可预期时间内完成(提供进度/预估)。 +- 系统可水平扩展:API 与 Worker 可独立扩容。 + +### 5.2 安全与合规(必须) +- 默认移除 EXIF 等隐私元数据(可配置允许保留,但需明确提示)。 +- 上传内容按保留期自动删除,且支持用户主动删除。 +- API Key 仅创建时显示一次;支持轮换与禁用。 +- 具备基础风控:IP/账号/API Key 限流、异常突增告警。 + +--- + +## 6. MVP / V1 里程碑建议 + +### MVP(可上线收费的最小闭环) +- 网站压缩(同步 + 批量异步)+ 下载/ZIP + 历史(登录用户)。 +- API Key + 同步单图(直接返回二进制)+ 异步批量(任务/下载)。 +- 用量计量 + 套餐配额(Free/Pro)+ 订阅(至少一种支付渠道)+ 发票列表。 +- 管理后台:用户/任务/配置/用量查看 + 手动赠送额度。 + +### V1+(商用增强) +- Webhook + SDK(TS/Python/Go)+ OpenAPI 自动生成。 +- 团队/组织、多 Key 管理、细粒度权限、IP 白名单。 +- 企业发票/税务字段、对公转账/线下支付流程。 +- 风控升级:验证码、黑名单、设备指纹、异常画像。 + +--- + +## 7. 已确认口径(开工前) +- 支付渠道:Stripe +- 计费策略:硬配额(超额返回 `QUOTA_EXCEEDED` / HTTP 402) +- 配额周期:按订阅周期(非自然月) +- 匿名试用:支持(每日 10 次),不提供 API Key +- Free 套餐 API:不开放(仅 Pro/Business 可创建 API Key) +- 邮件服务:注册需邮箱验证 + 密码重置(SMTP,预置多服务商模板) +- 默认语言:中文 + +## 8. 待完成清单(上线前必须定稿) +- 法务页面:隐私政策、服务条款、数据保留与删除说明。(已提供模板,建议上线前法务审核) diff --git a/docs/privacy.md b/docs/privacy.md new file mode 100644 index 0000000..03809ac --- /dev/null +++ b/docs/privacy.md @@ -0,0 +1,114 @@ +# 隐私政策(示例模板)- ImageForge + +> 提示:本文为通用模板,**不构成法律意见**。上线前建议由专业人士结合你的主体信息、所在地法律与实际数据流进行审核与调整。 + +最后更新:2025-12-18 + +--- + +## 1. 我们收集哪些信息 + +### 1.1 账号信息 +- 邮箱、用户名、密码哈希(不存明文) +- 邮箱验证状态 + +### 1.2 使用与设备信息(日志/审计) +- IP 地址、User-Agent、请求时间、接口路径、错误信息 +- 与安全、对账相关的审计记录(不包含明文密码、JWT、API Key) + +### 1.3 计费信息 +- 订阅状态、发票与支付记录(通过 Stripe) +- Stripe 返回的客户/订阅/支付标识(如 customer_id、subscription_id 等) + +### 1.4 你上传的内容 +- 你上传的图片文件(用于压缩处理与结果下载) +- 与图片相关的必要元信息(如格式、大小、压缩比例) + +> 默认情况下我们会移除图片 EXIF 等元数据(定位/设备信息),除非你在压缩时明确选择保留。 + +--- + +## 2. 我们如何使用信息 + +我们使用上述信息用于: +- 提供与改进图片压缩服务(生成压缩结果、任务状态、下载) +- 账号与安全(登录、邮箱验证、密码重置、防滥用) +- 计费与对账(订阅、发票、支付状态同步) +- 客服与故障排查(定位问题、处理投诉与支持) + +--- + +## 3. 我们如何共享信息 + +我们不会出售你的个人信息。我们可能在以下场景共享必要信息: +- **Stripe**:用于订阅、支付与账单管理 +- **邮件服务商/SMTP**:用于发送验证邮件与密码重置邮件 +- **基础设施服务**(如对象存储/CDN):用于存储与分发压缩结果 + +我们仅在提供服务所必需范围内共享信息,并尽力要求第三方采取合理安全措施。 + +--- + +## 4. 数据存储与保留 + +### 4.1 图片与结果文件 +- 匿名试用与不同套餐有不同的保留期(例如:匿名 24 小时;Pro 7 天;Business 30 天)。 +- 过期后系统会自动删除任务与相关文件(可能存在一定延迟)。 +- 你可能可以在控制台手动删除任务/文件(如提供该功能)。 + +### 4.2 日志与审计 +出于安全与对账需要,我们可能会保留部分审计记录更长时间(并尽量脱敏)。 + +--- + +## 5. Cookies 与本地存储 + +我们可能使用 Cookie/本地存储用于: +- 匿名试用会话(维持试用状态与配额计数) +- 登录状态(如使用 Cookie 会话) +- 安全控制(如 CSRF 防护) + +你可以通过浏览器设置清除 Cookie,但这可能影响部分功能可用性。 + +--- + +## 6. 你的权利 + +你可以: +- 访问与修改账号信息 +- 请求删除账号(如提供该功能) +- 请求删除任务/文件(如提供该功能) + +如需人工协助,请通过页面或邮箱联系我们。 + +--- + +## 7. 安全措施 + +我们采取合理措施保护数据安全,例如: +- 密码使用安全算法哈希存储 +- API Key 不以明文存储 +- 访问控制、限流与审计日志 + +但互联网并非绝对安全,我们无法保证百分之百安全。 + +--- + +## 8. 未成年人 + +本服务不面向未成年人提供。如你是未成年人,请在监护人同意与指导下使用。 + +--- + +## 9. 本政策的变更 + +我们可能更新本隐私政策。重大变更会通过站内公告、邮件或其他方式提示。你继续使用服务即视为接受更新后的政策。 + +--- + +## 10. 联系方式 + +如对隐私政策有疑问或请求,请联系: +- 邮箱:privacy@your-domain.com +- 网站:https://your-domain.com + diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..f3d807f --- /dev/null +++ b/docs/security.md @@ -0,0 +1,119 @@ +# 安全与风控设计 - ImageForge + +目标:在“上传文件 + 对外 API + 计费”场景下,将最常见、最致命的安全与滥用风险前置到设计阶段,确保后续实现时有统一口径。 + +--- + +## 1. 威胁模型(摘要) + +核心资产: +- 用户账号、API Key、订阅与账单数据 +- 计算资源(CPU/内存/带宽/存储)与服务可用性 +- 用户上传图片(可能包含隐私/商业机密) + +主要攻击面: +- 上传入口(文件炸弹、DoS、恶意内容、路径/存储穿越) +- 认证入口(撞库、弱密码、Token 泄露) +- API Key(盗用、重放、暴力猜测) +- Webhook(伪造事件、重放、乱序) +- 管理后台(权限越权、配置投毒) + +--- + +## 2. 认证与会话 + +### 2.1 用户登录 +- 密码哈希:`argon2id`(带独立 salt,参数可配置)。 +- 登录保护:基础限速 + 失败次数冷却;可选验证码(V1+)。 +- 账号状态:`is_active=false` 直接拒绝登录与 API。 + +### 2.2 JWT 使用建议 +- 对外 API:支持 Bearer Token(适合 CLI/SDK)。 +- 网站(Vue3):优先使用 HttpOnly Cookie 承载会话(降低 XSS 泄露风险),如使用 localStorage 必须配合严格 CSP。 + +--- + +## 3. API Key 安全 + +### 3.1 Key 生成与展示 +- Key 仅在创建时展示一次(前端明确提示“请立即保存”)。 +- Key 前缀(`key_prefix`)用于列表展示与快速检索。 + +### 3.2 Key 存储与校验 +推荐:`key_hash = HMAC-SHA256(full_key, API_KEY_PEPPER)`,只存 hash,不存明文。 + +理由: +- 校验快,适合高 QPS; +- pepper 作为服务器秘密(配置/密钥管理系统),泄露风险可控; +- 避免 bcrypt/argon2 用在高频 key 校验导致性能瓶颈。 + +### 3.3 权限与限制 +- 最小权限:permissions(compress/batch/read_stats/billing_read 等)。 +- 支持禁用/轮换;可选 IP 白名单(Business/V1+)。 +- 每次请求记录 `last_used_at/last_used_ip/user_agent`(审计)。 + +--- + +## 4. 上传与图片处理安全 + +### 4.1 输入校验 +- 只依赖扩展名不安全:必须校验魔数/探测真实格式。 +- 设定上限: + - `max_file_size_mb` + - `max_pixels`(宽×高) + - `max_dimension`(单边) + - 解码超时(Worker 层,避免卡死) + +### 4.2 资源隔离 +- 压缩属 CPU 密集型:放到 Worker;API 只做编排与轻量校验。 +- Worker 限制并发:按“用户/套餐”与“全局”双维度控制。 +- 对异常图片:快速失败并记录审计与指标(格式错误/像素超限/解码失败)。 + +### 4.3 元数据(隐私) +- 默认移除 EXIF(定位/设备信息),除非用户明确开启 `preserve_metadata=true`。 +- UI 必须清晰提示该开关的隐私含义。 + +--- + +## 5. 计费风控(防盗刷/滥用) + +- **幂等**:`Idempotency-Key` 防止重试导致重复扣费。 +- **配额硬限制**:到达当期额度返回 `QUOTA_EXCEEDED`(HTTP 402)。 +- **匿名试用**:每日 10 次(成功文件数计),采用 **Cookie + IP** 双维度 Redis 计数做硬限制。 +- **异常检测**(告警即可,首期不必自动封禁): + - 短时间内用量突增 + - 失败率异常升高(疑似 fuzzing/探测) + - 单 Key 多 IP 快速切换 + +--- + +## 6. Webhook 安全 + +必须要求: +- 验签(provider 签名 + webhook secret)。 +- 事件幂等:按 `provider_event_id` 去重。 +- 重放保护:记录 `received_at` 与处理状态,拒绝重复处理。 +- 最小暴露:webhook 路由不接受浏览器跨域调用,不返回敏感信息。 + +--- + +## 7. Web 安全(前端/网关) + +### 7.1 HTTP 安全头(建议由 Nginx 设置) +- `Strict-Transport-Security` +- `Content-Security-Policy`(至少限制脚本来源;如用第三方支付跳转按需放开) +- `X-Content-Type-Options: nosniff` +- `Referrer-Policy` +- `Permissions-Policy` + +### 7.2 CORS 策略 +- 若前后端同域:尽量不启用宽松 CORS。 +- 若分离部署:CORS 白名单仅放行前端域名;对 `/webhooks/*` 禁止 CORS。 + +--- + +## 8. 数据安全与保留 + +- 结果保留期:按套餐(Free 24h、Pro 7d、Business 30d 等),匿名更短。 +- 支持用户主动删除任务/文件(立即删除对象存储 + DB 标记/审计)。 +- 审计日志留存与脱敏:保留必要字段(IP、UA、动作、对象 ID),避免写入明文密钥/Token。 diff --git a/docs/terms.md b/docs/terms.md new file mode 100644 index 0000000..9a05379 --- /dev/null +++ b/docs/terms.md @@ -0,0 +1,117 @@ +# 服务条款(示例模板)- ImageForge + +> 提示:本文为通用模板,**不构成法律意见**。上线前建议由专业人士结合你的主体信息、所在地法律与实际业务流程进行审核与调整。 + +最后更新:2025-12-18 + +--- + +## 1. 接受条款 + +欢迎使用 ImageForge(下称“本服务”)。当你访问或使用本服务(包括网站与 API)时,即表示你已阅读、理解并同意受本条款约束。如果你不同意本条款,请停止使用本服务。 + +--- + +## 2. 服务内容 + +本服务提供图片压缩与格式转换能力,包括但不限于: +- 网站上传压缩与批量压缩(含匿名试用) +- 开发者 API(API Key 调用) +- 订阅与计费(Stripe) +- 任务历史、下载与结果存储(按保留期自动清理) + +我们可能随时调整服务功能、参数与限制(例如文件大小、并发、速率、保留期等),并在合理范围内进行公告或提示。 + +--- + +## 3. 账号与安全 + +3.1 你可能需要注册账号才能使用部分功能。你应提供真实、准确、完整的信息并及时更新。 +3.2 你应妥善保管账号凭据与 API Key。因你保管不善导致的损失由你自行承担。 +3.3 我们可能对异常登录、滥用行为采取限制、冻结或终止服务措施。 + +--- + +## 4. 匿名试用与限制 + +4.1 匿名试用用于体验本服务,存在使用限制(例如:每日次数、文件大小、批量数量、保留期等)。 +4.2 我们可基于安全与风控原因随时调整匿名试用策略或停止匿名试用。 + +--- + +## 5. API 使用与开发者责任 + +5.1 开发者 API 仅对符合条件的用户开放(例如:Pro/Business 套餐)。 +5.2 你不得绕过鉴权、限流、配额或其他安全控制。 +5.3 你应在你的产品中向最终用户提供必要的告知(如你上传处理了用户图片、保留期、隐私政策等)。 + +--- + +## 6. 计费、订阅与退款 + +6.1 本服务通过 Stripe 提供订阅计费能力。你在购买订阅时应确认价格、周期、包含额度、超额策略与取消规则。 +6.2 **硬配额**:当期额度耗尽后,新增压缩请求将被拒绝(可能返回 `402 QUOTA_EXCEEDED`)。 +6.3 取消订阅通常在当前计费周期结束后生效。 +6.4 退款政策:可根据你的实际运营策略另行约定(建议在上线前补充清晰规则)。 + +--- + +## 7. 内容与知识产权 + +7.1 你上传的图片及其相关权利归你或权利人所有。你应确保你拥有上传、处理该内容的合法权利。 +7.2 你授予我们在提供本服务所必需范围内处理、存储、传输该内容的许可(例如生成压缩结果、提供下载链接、用于排障日志中的必要元信息)。 +7.3 本服务的软件、界面、商标、文档等知识产权归我们或权利人所有,除非另有明确授权。 + +--- + +## 8. 禁止行为 + +你不得利用本服务进行包括但不限于以下行为: +- 上传或传播违法、侵权、恶意内容 +- 使用自动化方式进行超出合理范围的抓取、压测、滥用 +- 试图入侵、绕过鉴权、伪造请求或篡改数据 +- 传播病毒、木马或其他破坏性代码 +- 违反适用法律法规或本条款的其他行为 + +--- + +## 9. 数据保留与删除 + +9.1 压缩结果与任务记录会按你的套餐/设置保留一定时间,过期后自动删除。 +9.2 你可能可以在控制台手动删除任务/文件(如提供该功能)。 +9.3 与服务安全、对账、审计相关的必要日志可能会保留更长时间(并进行脱敏处理)。 + +--- + +## 10. 免责声明 + +10.1 本服务按“现状”提供。我们将尽力提供稳定服务,但不对完全无故障、无中断作出承诺。 +10.2 由于网络、第三方服务(如 Stripe、邮件服务商、对象存储)等原因造成的延迟或失败,我们将在合理范围内协助处理,但不承担超出法律允许范围的责任。 + +--- + +## 11. 责任限制 + +在适用法律允许的范围内,我们对因使用或无法使用本服务所导致的间接损失、利润损失、数据丢失等不承担责任。若法律要求承担责任,我们的责任上限可按你最近一个计费周期实际支付金额(或合理上限)计算(具体上限可按你运营策略补充)。 + +--- + +## 12. 终止 + +12.1 你可以停止使用本服务并按指引取消订阅。 +12.2 如你违反本条款或存在风险行为,我们可暂停或终止向你提供服务,并保留追究责任的权利。 + +--- + +## 13. 条款变更 + +我们可能会更新本条款。重大变更将通过站内公告、邮件或其他方式提示。你继续使用服务即视为接受更新后的条款。 + +--- + +## 14. 联系方式 + +如对本条款有疑问,请联系: +- 邮箱:support@your-domain.com +- 网站:https://your-domain.com + diff --git a/docs/ui.md b/docs/ui.md new file mode 100644 index 0000000..0952305 --- /dev/null +++ b/docs/ui.md @@ -0,0 +1,134 @@ +# UI/UX 设计文档 - ImageForge + +目标:做一个接近商用的体验(简单、可信、可解释),同时覆盖“网站压缩 + 开发者 API + 计费”三条主线。 + +--- + +## 1. 设计原则 + +1) **一眼可用**:首页即工具,不把用户“逼进登录”才能体验。 +2) **结果可信**:清晰展示“压缩前后对比、节省多少、是否去除元数据、链接多久过期”。 +3) **开发者友好**:API Key、用量、示例代码和错误处理在控制台里一站式找到。 +4) **商用闭环**:升级/取消/发票/支付状态明确,避免“扣费但不知道扣了什么”。 + +--- + +## 2. 信息架构(站点地图) + +### 2.1 公共区域(无需登录) +- `/` 首页(压缩工具) +- `/pricing` 价格页(套餐对比、FAQ) +- `/docs` 开发者文档入口(API 概览、SDK、示例) +- `/login` `/register` +- `/terms` `/privacy` + +### 2.2 用户控制台(需要登录) +- `/dashboard` 概览(当期用量、套餐、最近任务) +- `/dashboard/history` 历史任务 +- `/dashboard/api-keys` API Key 管理 + 用量头说明 +- `/dashboard/billing` 订阅与发票 +- `/dashboard/settings` 账号设置(密码、删除账号等) + +### 2.3 管理后台(管理员) +- `/admin` 概览(QPS、错误率、队列长度、订阅状态分布) +- `/admin/users` 用户管理(冻结、限流覆盖、赠送额度) +- `/admin/tasks` 任务管理(取消/重试、失败原因) +- `/admin/billing` 订阅/发票/支付事件(Webhook)排查 +- `/admin/config` 全局配置(开关、限流、文件限制、保留期默认) + +--- + +## 3. 关键页面规格(线框级) + +### 3.1 首页(压缩工具) +核心组件: +- 顶栏:Logo + `/pricing` `/docs` + 登录/注册(登录后显示头像菜单) +- 上传区:拖拽 + 点击选择 + 限制提示(格式/大小/数量) +- 参数区:压缩率、尺寸限制、元数据开关(输出格式保持原图) +- 结果区:文件列表(缩略图、大小、节省%、状态、下载/重试) +- 汇总区:总节省、下载 ZIP、清空 +- 信任区:隐私说明(默认去 EXIF)、保留期说明、状态页/联系入口 + +交互要点: +- 上传后立即生成本地缩略图与原始大小;压缩进度逐文件显示。 +- 默认提示“匿名试用:每日 10 次”,登录后提示“升级解锁更大额度/保留期”。 + +### 3.2 价格页(Pricing) +结构建议: +- Hero:一句话价值 + CTA(开始压缩 / 查看 API) +- 套餐卡片:Free / Pro / Business(三列) +- 对比表:文件大小/批量上限/保留期/Webhook/团队 +- FAQ:计量单位、超额策略、退款、隐私与保留 + +### 3.3 控制台概览(Dashboard) +上半区(KPI 卡片): +- 当期已用/剩余(进度条) +- 节省流量累计(本周期/总计) +- API 调用数(本周期) +- 当前套餐与到期时间 + +下半区: +- 最近任务列表(状态、文件数、节省、操作) +- 升级提示(当剩余额度 < 20%) + +### 3.4 API Key 管理 +列表字段: +- 名称、前缀、权限、限流、最后使用时间/IP、状态(启用/禁用) + +创建/轮换: +- 创建时弹窗展示一次完整 Key(支持“一键复制”) +- 轮换说明:首期默认“立即轮换”(生成新 Key 并立即禁用旧 Key),避免双 Key 过渡带来的复杂度 + +开发者引导: +- 显示 curl 示例(调用 `POST /compress/direct`) +- 显示常见错误码(`QUOTA_EXCEEDED`、`RATE_LIMITED`)与重试策略(Idempotency-Key) + +### 3.5 订阅与发票(Billing) +模块: +- 当前订阅:套餐、周期、状态(active/past_due)、升级/取消按钮 +- 发票列表:编号、金额、状态、周期、下载/跳转支付(provider) +- 支付方式入口:跳转客户 Portal(如 Stripe portal) + +### 3.6 历史任务(History) +筛选: +- 时间范围、状态、来源(web/api)、格式、压缩模式 + +列表: +- 任务创建时间、文件数、节省、到期时间、下载 ZIP、删除(隐私) + +--- + +## 4. 视觉与组件规范(建议) + +### 4.1 设计风格 +- 清爽、留白、强调数据对比(节省% 是视觉重点) +- 状态色:成功/警告/失败明确 + +### 4.2 主题变量(示例) +沿用 `docs/frontend.md` 的 CSS 变量,并补充: +- `--info`、`--border`、`--bg`、`--text` 等 +- 暗色模式(V1+) + +### 4.3 组件清单(公共) +- `AppLayout` / `AuthLayout` / `AdminLayout` +- `DropZone`、`FileCard`、`OptionsPanel` +- `UsageMeter`、`PlanCard`、`InvoiceTable` +- `Toast`、`Modal`、`Skeleton`、`EmptyState` + +--- + +## 5. 关键文案(必须在 UI 中出现) + +- 元数据:默认提示“默认会移除 EXIF 等元数据(定位/设备信息)” +- 保留期:下载链接到期时间(例如“24 小时后自动删除”) +- 计量:说明“成功压缩 1 个文件计 1 次” +- 错误:配额不足/限流时给出“如何解决”(登录/升级/稍后重试) + +--- + +## 6. 可访问性与体验细节 + +- 键盘可达:上传区、弹窗、按钮可 Tab 导航 +- 颜色对比:状态色满足可读性 +- 大文件/批量:明确“后台处理中,可关闭页面稍后回来” +- 移动端:首页只保留必要参数,高级参数折叠 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..33895ab --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,5 @@ +# Vue 3 + TypeScript + Vite + +This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..f8708c3 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2756 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@vueuse/core": "^14.1.0", + "pinia": "^3.0.4", + "vue": "^3.5.24", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@tailwindcss/typography": "^0.5.19", + "@types/node": "^24.10.1", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/tsconfig": "^0.8.1", + "autoprefixer": "^10.4.23", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17", + "typescript": "~5.9.3", + "vite": "^7.2.4", + "vue-tsc": "^3.1.4" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.5.tgz", + "integrity": "sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.5.tgz", + "integrity": "sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.5.tgz", + "integrity": "sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.5.tgz", + "integrity": "sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.5.tgz", + "integrity": "sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.5.tgz", + "integrity": "sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.5.tgz", + "integrity": "sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.5.tgz", + "integrity": "sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.5.tgz", + "integrity": "sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.5.tgz", + "integrity": "sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.5.tgz", + "integrity": "sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.5.tgz", + "integrity": "sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.5.tgz", + "integrity": "sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.5.tgz", + "integrity": "sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.5.tgz", + "integrity": "sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.5.tgz", + "integrity": "sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.5.tgz", + "integrity": "sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.5.tgz", + "integrity": "sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.5.tgz", + "integrity": "sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.5.tgz", + "integrity": "sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.5.tgz", + "integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.5.tgz", + "integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz", + "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz", + "integrity": "sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.53" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.26", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.26.tgz", + "integrity": "sha512-hH0SMitMxnB43OZpyF1IFPS9bgb2I3bpCh76m2WEK7BE0A0EzpYsRp0CCH2xNKshr7kacU5TQBLYn4zj7CG60A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.26" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.26", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.26.tgz", + "integrity": "sha512-JJw0Tt/kSFsIRmgTQF4JSt81AUSI1aEye5Zl65EeZ8H35JHnTvFGmpDOBn5iOxd48fyGE+ZvZBp5FcgAy/1Qhw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.26", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.26.tgz", + "integrity": "sha512-N87ecLD48Sp6zV9zID/5yuS1+5foj0DfuYGdQ6KHj/IbKvyKv1zNX6VCmnKYwtmHadEO6mFc2EKISiu3RDPAvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.26", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz", + "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.26", + "entities": "^7.0.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz", + "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz", + "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.26", + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz", + "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/language-core": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.1.8.tgz", + "integrity": "sha512-PfwAW7BLopqaJbneChNL6cUOTL3GL+0l8paYP5shhgY5toBNidWnMXWM+qDwL7MC9+zDtzCF2enT8r6VPu64iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.26", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.0.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz", + "integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz", + "integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz", + "integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.26", + "@vue/runtime-core": "3.5.26", + "@vue/shared": "3.5.26", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz", + "integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26" + }, + "peerDependencies": { + "vue": "3.5.26" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz", + "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.8.1.tgz", + "integrity": "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@vueuse/core": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.1.0.tgz", + "integrity": "sha512-rgBinKs07hAYyPF834mDTigH7BtPqvZ3Pryuzt1SD/lg5wEcWqvwzXXYGEDb2/cP0Sj5zSvHl3WkmMELr5kfWw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.1.0", + "@vueuse/shared": "14.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.1.0.tgz", + "integrity": "sha512-7hK4g015rWn2PhKcZ99NyT+ZD9sbwm7SGvp7k+k+rKGWnLjS/oQozoIZzWfCewSUeBmnJkIb+CNr7Zc/EyRnnA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.1.0.tgz", + "integrity": "sha512-EcKxtYvn6gx1F8z9J5/rsg3+lTQnvOruQd8fUecW99DCK04BkWD7z5KQ/wTAx+DazyoEE9dJt/zV8OIEQbM6kw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/alien-signals": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.1.tgz", + "integrity": "sha512-ogkIWbVrLwKtHY6oOAXaYkAxP+cTH7V5FZ5+Tm4NZFd8VDZ6uNMDrfzqctTZ42eTMCSR3ne3otpcxmqSnFfPYA==", + "dev": true, + "license": "MIT" + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.10.tgz", + "integrity": "sha512-2VIKvDx8Z1a9rTB2eCkdPE5nSe28XnA+qivGnWHoB40hMMt/h1hSz0960Zqsn6ZyxWXUie0EBdElKv8may20AA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001760", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", + "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz", + "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz", + "integrity": "sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.5", + "@rollup/rollup-android-arm64": "4.53.5", + "@rollup/rollup-darwin-arm64": "4.53.5", + "@rollup/rollup-darwin-x64": "4.53.5", + "@rollup/rollup-freebsd-arm64": "4.53.5", + "@rollup/rollup-freebsd-x64": "4.53.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.5", + "@rollup/rollup-linux-arm-musleabihf": "4.53.5", + "@rollup/rollup-linux-arm64-gnu": "4.53.5", + "@rollup/rollup-linux-arm64-musl": "4.53.5", + "@rollup/rollup-linux-loong64-gnu": "4.53.5", + "@rollup/rollup-linux-ppc64-gnu": "4.53.5", + "@rollup/rollup-linux-riscv64-gnu": "4.53.5", + "@rollup/rollup-linux-riscv64-musl": "4.53.5", + "@rollup/rollup-linux-s390x-gnu": "4.53.5", + "@rollup/rollup-linux-x64-gnu": "4.53.5", + "@rollup/rollup-linux-x64-musl": "4.53.5", + "@rollup/rollup-openharmony-arm64": "4.53.5", + "@rollup/rollup-win32-arm64-msvc": "4.53.5", + "@rollup/rollup-win32-ia32-msvc": "4.53.5", + "@rollup/rollup-win32-x64-gnu": "4.53.5", + "@rollup/rollup-win32-x64-msvc": "4.53.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", + "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-sfc": "3.5.26", + "@vue/runtime-dom": "3.5.26", + "@vue/server-renderer": "3.5.26", + "@vue/shared": "3.5.26" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/vue-tsc": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.1.8.tgz", + "integrity": "sha512-deKgwx6exIHeZwF601P1ktZKNF0bepaSN4jBU3AsbldPx9gylUc1JDxYppl82yxgkAgaz0Y0LCLOi+cXe9HMYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.26", + "@vue/language-core": "3.1.8" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8f830ab --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@vueuse/core": "^14.1.0", + "pinia": "^3.0.4", + "vue": "^3.5.24", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@tailwindcss/typography": "^0.5.19", + "@types/node": "^24.10.1", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/tsconfig": "^0.8.1", + "autoprefixer": "^10.4.23", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17", + "typescript": "~5.9.3", + "vite": "^7.2.4", + "vue-tsc": "^3.1.4" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/frontend/src/app/layouts/AdminLayout.vue b/frontend/src/app/layouts/AdminLayout.vue new file mode 100644 index 0000000..2756560 --- /dev/null +++ b/frontend/src/app/layouts/AdminLayout.vue @@ -0,0 +1,69 @@ + diff --git a/frontend/src/app/layouts/DashboardLayout.vue b/frontend/src/app/layouts/DashboardLayout.vue new file mode 100644 index 0000000..753ff57 --- /dev/null +++ b/frontend/src/app/layouts/DashboardLayout.vue @@ -0,0 +1,91 @@ + + + + diff --git a/frontend/src/app/layouts/PublicLayout.vue b/frontend/src/app/layouts/PublicLayout.vue new file mode 100644 index 0000000..b3eba7c --- /dev/null +++ b/frontend/src/app/layouts/PublicLayout.vue @@ -0,0 +1,86 @@ + + + + diff --git a/frontend/src/app/router.ts b/frontend/src/app/router.ts new file mode 100644 index 0000000..d0b09ba --- /dev/null +++ b/frontend/src/app/router.ts @@ -0,0 +1,93 @@ +import type { Pinia } from 'pinia' +import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' + +import { useAuthStore } from '@/stores/auth' + +export function createAppRouter(pinia: Pinia) { + const routes: RouteRecordRaw[] = [ + { + path: '/', + component: () => import('@/app/layouts/PublicLayout.vue'), + children: [ + { path: '', name: 'home', component: () => import('@/pages/HomePage.vue') }, + { path: 'pricing', name: 'pricing', component: () => import('@/pages/PricingPage.vue') }, + { path: 'docs', name: 'docs', component: () => import('@/pages/DocsPage.vue') }, + { path: 'login', name: 'login', component: () => import('@/pages/LoginPage.vue') }, + { path: 'register', name: 'register', component: () => import('@/pages/RegisterPage.vue') }, + { path: 'verify-email', name: 'verify-email', component: () => import('@/pages/VerifyEmailPage.vue') }, + { path: 'forgot-password', name: 'forgot-password', component: () => import('@/pages/ForgotPasswordPage.vue') }, + { path: 'reset-password', name: 'reset-password', component: () => import('@/pages/ResetPasswordPage.vue') }, + { path: 'terms', name: 'terms', component: () => import('@/pages/TermsPage.vue') }, + { path: 'privacy', name: 'privacy', component: () => import('@/pages/PrivacyPage.vue') }, + ], + }, + { + path: '/dashboard', + component: () => import('@/app/layouts/DashboardLayout.vue'), + meta: { requiresAuth: true }, + children: [ + { path: '', name: 'dashboard', component: () => import('@/pages/dashboard/DashboardHomePage.vue') }, + { + path: 'history', + name: 'dashboard-history', + component: () => import('@/pages/dashboard/DashboardHistoryPage.vue'), + }, + { + path: 'api-keys', + name: 'dashboard-api-keys', + component: () => import('@/pages/dashboard/DashboardApiKeysPage.vue'), + }, + { + path: 'billing', + name: 'dashboard-billing', + component: () => import('@/pages/dashboard/DashboardBillingPage.vue'), + }, + { + path: 'settings', + name: 'dashboard-settings', + component: () => import('@/pages/dashboard/DashboardSettingsPage.vue'), + }, + ], + }, + { + path: '/admin', + component: () => import('@/app/layouts/AdminLayout.vue'), + meta: { requiresAuth: true, requiresAdmin: true }, + children: [ + { path: '', name: 'admin', component: () => import('@/pages/admin/AdminHomePage.vue') }, + { path: 'users', name: 'admin-users', component: () => import('@/pages/admin/AdminUsersPage.vue') }, + { path: 'tasks', name: 'admin-tasks', component: () => import('@/pages/admin/AdminTasksPage.vue') }, + { path: 'billing', name: 'admin-billing', component: () => import('@/pages/admin/AdminBillingPage.vue') }, + { path: 'integrations', name: 'admin-integrations', component: () => import('@/pages/admin/AdminIntegrationsPage.vue') }, + { path: 'config', name: 'admin-config', component: () => import('@/pages/admin/AdminConfigPage.vue') }, + ], + }, + { path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('@/pages/NotFoundPage.vue') }, + ] + + const router = createRouter({ + history: createWebHistory(), + routes, + scrollBehavior: () => ({ left: 0, top: 0 }), + }) + + router.beforeEach((to) => { + const auth = useAuthStore(pinia) + + if (to.meta?.requiresAuth && !auth.isLoggedIn) { + return { name: 'login', query: { redirect: to.fullPath } } + } + + if ((to.name === 'login' || to.name === 'register') && auth.isLoggedIn) { + return { name: 'dashboard' } + } + + if (to.meta?.requiresAdmin && auth.user?.role !== 'admin') { + return { name: 'dashboard' } + } + + return true + }) + + return router +} diff --git a/frontend/src/assets/vue.svg b/frontend/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/frontend/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/HelloWorld.vue b/frontend/src/components/HelloWorld.vue new file mode 100644 index 0000000..b58e52b --- /dev/null +++ b/frontend/src/components/HelloWorld.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..30d0cde --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,20 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import './style.css' +import App from './App.vue' + +import { createAppRouter } from './app/router' +import { useAuthStore } from './stores/auth' + +const app = createApp(App) + +const pinia = createPinia() +app.use(pinia) + +const auth = useAuthStore(pinia) +auth.initFromStorage() + +const router = createAppRouter(pinia) +app.use(router) + +app.mount('#app') diff --git a/frontend/src/pages/DocsPage.vue b/frontend/src/pages/DocsPage.vue new file mode 100644 index 0000000..9fbc0ae --- /dev/null +++ b/frontend/src/pages/DocsPage.vue @@ -0,0 +1,30 @@ + diff --git a/frontend/src/pages/ForgotPasswordPage.vue b/frontend/src/pages/ForgotPasswordPage.vue new file mode 100644 index 0000000..347b7bc --- /dev/null +++ b/frontend/src/pages/ForgotPasswordPage.vue @@ -0,0 +1,75 @@ + + + + diff --git a/frontend/src/pages/HomePage.vue b/frontend/src/pages/HomePage.vue new file mode 100644 index 0000000..2990ef2 --- /dev/null +++ b/frontend/src/pages/HomePage.vue @@ -0,0 +1,422 @@ + + + diff --git a/frontend/src/pages/LoginPage.vue b/frontend/src/pages/LoginPage.vue new file mode 100644 index 0000000..baf544e --- /dev/null +++ b/frontend/src/pages/LoginPage.vue @@ -0,0 +1,93 @@ + + + diff --git a/frontend/src/pages/NotFoundPage.vue b/frontend/src/pages/NotFoundPage.vue new file mode 100644 index 0000000..16d00da --- /dev/null +++ b/frontend/src/pages/NotFoundPage.vue @@ -0,0 +1,17 @@ + + diff --git a/frontend/src/pages/PricingPage.vue b/frontend/src/pages/PricingPage.vue new file mode 100644 index 0000000..ce6346b --- /dev/null +++ b/frontend/src/pages/PricingPage.vue @@ -0,0 +1,73 @@ + + + + diff --git a/frontend/src/pages/PrivacyPage.vue b/frontend/src/pages/PrivacyPage.vue new file mode 100644 index 0000000..68de075 --- /dev/null +++ b/frontend/src/pages/PrivacyPage.vue @@ -0,0 +1,18 @@ + + diff --git a/frontend/src/pages/RegisterPage.vue b/frontend/src/pages/RegisterPage.vue new file mode 100644 index 0000000..aae8985 --- /dev/null +++ b/frontend/src/pages/RegisterPage.vue @@ -0,0 +1,100 @@ + + + + diff --git a/frontend/src/pages/ResetPasswordPage.vue b/frontend/src/pages/ResetPasswordPage.vue new file mode 100644 index 0000000..3912d7f --- /dev/null +++ b/frontend/src/pages/ResetPasswordPage.vue @@ -0,0 +1,83 @@ + + + + diff --git a/frontend/src/pages/TermsPage.vue b/frontend/src/pages/TermsPage.vue new file mode 100644 index 0000000..f2c71a9 --- /dev/null +++ b/frontend/src/pages/TermsPage.vue @@ -0,0 +1,20 @@ + + diff --git a/frontend/src/pages/VerifyEmailPage.vue b/frontend/src/pages/VerifyEmailPage.vue new file mode 100644 index 0000000..af2a8a2 --- /dev/null +++ b/frontend/src/pages/VerifyEmailPage.vue @@ -0,0 +1,61 @@ + + + diff --git a/frontend/src/pages/admin/AdminBillingPage.vue b/frontend/src/pages/admin/AdminBillingPage.vue new file mode 100644 index 0000000..0f8339f --- /dev/null +++ b/frontend/src/pages/admin/AdminBillingPage.vue @@ -0,0 +1,354 @@ + + + diff --git a/frontend/src/pages/admin/AdminConfigPage.vue b/frontend/src/pages/admin/AdminConfigPage.vue new file mode 100644 index 0000000..520305c --- /dev/null +++ b/frontend/src/pages/admin/AdminConfigPage.vue @@ -0,0 +1,134 @@ + + + diff --git a/frontend/src/pages/admin/AdminHomePage.vue b/frontend/src/pages/admin/AdminHomePage.vue new file mode 100644 index 0000000..a0955a8 --- /dev/null +++ b/frontend/src/pages/admin/AdminHomePage.vue @@ -0,0 +1,70 @@ + + + diff --git a/frontend/src/pages/admin/AdminIntegrationsPage.vue b/frontend/src/pages/admin/AdminIntegrationsPage.vue new file mode 100644 index 0000000..845abb4 --- /dev/null +++ b/frontend/src/pages/admin/AdminIntegrationsPage.vue @@ -0,0 +1,464 @@ + + + diff --git a/frontend/src/pages/admin/AdminTasksPage.vue b/frontend/src/pages/admin/AdminTasksPage.vue new file mode 100644 index 0000000..d62dd24 --- /dev/null +++ b/frontend/src/pages/admin/AdminTasksPage.vue @@ -0,0 +1,204 @@ + + + diff --git a/frontend/src/pages/admin/AdminUsersPage.vue b/frontend/src/pages/admin/AdminUsersPage.vue new file mode 100644 index 0000000..89940d8 --- /dev/null +++ b/frontend/src/pages/admin/AdminUsersPage.vue @@ -0,0 +1,163 @@ + + + diff --git a/frontend/src/pages/dashboard/DashboardApiKeysPage.vue b/frontend/src/pages/dashboard/DashboardApiKeysPage.vue new file mode 100644 index 0000000..f6dfcc5 --- /dev/null +++ b/frontend/src/pages/dashboard/DashboardApiKeysPage.vue @@ -0,0 +1,236 @@ + + + diff --git a/frontend/src/pages/dashboard/DashboardBillingPage.vue b/frontend/src/pages/dashboard/DashboardBillingPage.vue new file mode 100644 index 0000000..749e4d4 --- /dev/null +++ b/frontend/src/pages/dashboard/DashboardBillingPage.vue @@ -0,0 +1,202 @@ + + + diff --git a/frontend/src/pages/dashboard/DashboardHistoryPage.vue b/frontend/src/pages/dashboard/DashboardHistoryPage.vue new file mode 100644 index 0000000..ae455f2 --- /dev/null +++ b/frontend/src/pages/dashboard/DashboardHistoryPage.vue @@ -0,0 +1,344 @@ + + + diff --git a/frontend/src/pages/dashboard/DashboardHomePage.vue b/frontend/src/pages/dashboard/DashboardHomePage.vue new file mode 100644 index 0000000..461df32 --- /dev/null +++ b/frontend/src/pages/dashboard/DashboardHomePage.vue @@ -0,0 +1,143 @@ + + + diff --git a/frontend/src/pages/dashboard/DashboardSettingsPage.vue b/frontend/src/pages/dashboard/DashboardSettingsPage.vue new file mode 100644 index 0000000..66d634b --- /dev/null +++ b/frontend/src/pages/dashboard/DashboardSettingsPage.vue @@ -0,0 +1,275 @@ + + + diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..f78e1a5 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,524 @@ +import type { User } from '@/stores/auth' + +import { apiGet, apiJson, apiMultipart } from './http' + +export type CompressionLevel = 'high' | 'medium' | 'low' +export type OutputFormat = 'png' | 'jpeg' | 'webp' | 'avif' | 'gif' | 'bmp' | 'tiff' | 'ico' + +export interface RegisterResponse { + user: User + token: string + message: string +} + +export interface LoginResponse { + token: string + expires_at: string + user: User +} + +export async function register(email: string, password: string, username: string): Promise { + return apiJson('/api/v1/auth/register', { email, password, username }, null) +} + +export async function login(email: string, password: string): Promise { + return apiJson('/api/v1/auth/login', { email, password }, null) +} + +export async function sendVerification(token: string): Promise<{ message: string }> { + return apiJson<{ message: string }>('/api/v1/auth/send-verification', undefined, token, { method: 'POST' }) +} + +export async function verifyEmail(verificationToken: string): Promise<{ message: string }> { + return apiJson<{ message: string }>('/api/v1/auth/verify-email', { token: verificationToken }, null) +} + +export async function forgotPassword(email: string): Promise<{ message: string }> { + return apiJson<{ message: string }>('/api/v1/auth/forgot-password', { email }, null) +} + +export async function resetPassword(resetToken: string, newPassword: string): Promise<{ message: string }> { + return apiJson<{ message: string }>('/api/v1/auth/reset-password', { token: resetToken, new_password: newPassword }, null) +} + +export type UserProfile = User + +export async function getProfile(token: string): Promise { + return apiGet('/api/v1/user/profile', token) +} + +export async function updateProfile( + token: string, + payload: { email?: string; username?: string }, +): Promise<{ user: UserProfile; message: string }> { + return apiJson<{ user: UserProfile; message: string }>('/api/v1/user/profile', payload, token, { method: 'PUT' }) +} + +export async function updatePassword( + token: string, + currentPassword: string, + newPassword: string, +): Promise<{ message: string }> { + return apiJson<{ message: string }>( + '/api/v1/user/password', + { current_password: currentPassword, new_password: newPassword }, + token, + { method: 'PUT' }, + ) +} + +export interface CompressResponse { + task_id: string + file_id: string + format_in: string + format_out: string + original_size: number + compressed_size: number + saved_bytes: number + saved_percent: number + download_url: string + expires_at: string + billing: { units_charged: number } +} + +export interface CompressOptions { + level?: CompressionLevel + compression_rate?: number + output_format?: OutputFormat + max_width?: number + max_height?: number + preserve_metadata?: boolean +} + +export async function compressFile(file: File, options: CompressOptions, token?: string | null): Promise { + const form = new FormData() + form.append('file', file, file.name) + + if (options.level) form.append('level', options.level) + if (options.compression_rate) form.append('compression_rate', String(options.compression_rate)) + if (options.output_format) form.append('output_format', options.output_format) + if (options.max_width) form.append('max_width', String(options.max_width)) + if (options.max_height) form.append('max_height', String(options.max_height)) + if (options.preserve_metadata) form.append('preserve_metadata', 'true') + + return apiMultipart('/api/v1/compress', form, token) +} + +export interface PlanView { + id: string + code: string + name: string + currency: string + amount_cents: number + interval: string + included_units_per_period: number + max_file_size_mb: number + max_files_per_batch: number + retention_days: number + features: unknown +} + +export async function listPlans(): Promise<{ plans: PlanView[] }> { + return apiGet<{ plans: PlanView[] }>('/api/v1/billing/plans', null) +} + +export interface UsageResponse { + period_start: string + period_end: string + used_units: number + included_units: number + bonus_units: number + total_units: number + remaining_units: number +} + +export async function getUsage(token: string): Promise { + return apiGet('/api/v1/billing/usage', token) +} + +export interface SubscriptionView { + status: string + current_period_start: string + current_period_end: string + cancel_at_period_end: boolean + plan: PlanView +} + +export async function getSubscription(token: string): Promise<{ subscription: SubscriptionView }> { + return apiGet<{ subscription: SubscriptionView }>('/api/v1/billing/subscription', token) +} + +export interface InvoiceView { + invoice_number: string + status: string + currency: string + total_amount_cents: number + period_start?: string | null + period_end?: string | null + hosted_invoice_url?: string | null + pdf_url?: string | null + paid_at?: string | null + created_at: string +} + +export async function listInvoices( + token: string, + page = 1, + limit = 20, +): Promise<{ invoices: InvoiceView[]; page: number; limit: number }> { + const qs = new URLSearchParams({ page: String(page), limit: String(limit) }).toString() + return apiGet<{ invoices: InvoiceView[]; page: number; limit: number }>(`/api/v1/billing/invoices?${qs}`, token) +} + +export async function createCheckout(token: string, planId: string): Promise<{ checkout_url: string }> { + return apiJson<{ checkout_url: string }>('/api/v1/billing/checkout', { plan_id: planId }, token) +} + +export async function createPortal(token: string): Promise<{ url: string }> { + return apiJson<{ url: string }>('/api/v1/billing/portal', undefined, token, { method: 'POST' }) +} + +export interface ApiKeyView { + id: string + name: string + key_prefix: string + permissions: unknown + rate_limit: number + is_active: boolean + last_used_at?: string | null + last_used_ip?: string | null + created_at: string +} + +export async function listApiKeys(token: string): Promise<{ api_keys: ApiKeyView[] }> { + return apiGet<{ api_keys: ApiKeyView[] }>('/api/v1/user/api-keys', token) +} + +export interface CreateApiKeyResponse { + id: string + name: string + key_prefix: string + key: string + message: string +} + +export async function createApiKey( + token: string, + name: string, + permissions?: string[], +): Promise { + return apiJson('/api/v1/user/api-keys', { name, permissions }, token) +} + +export async function disableApiKey(token: string, keyId: string): Promise<{ message: string }> { + return apiJson<{ message: string }>(`/api/v1/user/api-keys/${keyId}`, undefined, token, { method: 'DELETE' }) +} + +export async function rotateApiKey(token: string, keyId: string): Promise { + return apiJson(`/api/v1/user/api-keys/${keyId}/rotate`, undefined, token) +} + +export interface HistoryFileView { + file_id: string + original_name: string + original_size: number + compressed_size?: number | null + saved_percent?: number | null + status: string + output_format: string + error_message?: string | null + download_url?: string | null +} + +export interface HistoryTaskView { + task_id: string + status: string + source: string + progress: number + total_files: number + completed_files: number + failed_files: number + created_at: string + completed_at?: string | null + expires_at: string + download_all_url?: string | null + files: HistoryFileView[] +} + +export interface HistoryResponse { + tasks: HistoryTaskView[] + page: number + limit: number + total: number +} + +export async function listHistory( + token: string, + params: { page?: number; limit?: number; status?: string } = {}, +): Promise { + const qs = new URLSearchParams() + if (params.page) qs.set('page', String(params.page)) + if (params.limit) qs.set('limit', String(params.limit)) + if (params.status) qs.set('status', params.status) + const suffix = qs.toString() + return apiGet(`/api/v1/user/history${suffix ? `?${suffix}` : ''}`, token) +} + +export interface AdminStats { + total_users: number + active_users: number + pending_tasks: number + processing_tasks: number + failed_tasks: number + completed_tasks: number + usage_events_24h: number + active_subscriptions: number +} + +export async function getAdminStats(token: string): Promise { + return apiGet('/api/v1/admin/stats', token) +} + +export interface AdminUserView { + id: string + email: string + username: string + role: string + is_active: boolean + email_verified: boolean + rate_limit_override?: number | null + storage_limit_mb?: number | null + created_at: string + subscription_status?: string | null +} + +export interface AdminUserListResponse { + users: AdminUserView[] + page: number + limit: number + total: number +} + +export async function listAdminUsers( + token: string, + params: { page?: number; limit?: number; search?: string } = {}, +): Promise { + const qs = new URLSearchParams() + if (params.page) qs.set('page', String(params.page)) + if (params.limit) qs.set('limit', String(params.limit)) + if (params.search) qs.set('search', params.search) + const suffix = qs.toString() + return apiGet(`/api/v1/admin/users${suffix ? `?${suffix}` : ''}`, token) +} + +export interface AdminTaskView { + id: string + status: string + source: string + total_files: number + completed_files: number + failed_files: number + error_message?: string | null + created_at: string + completed_at?: string | null + expires_at: string + user_id?: string | null + user_email?: string | null +} + +export interface AdminTaskListResponse { + tasks: AdminTaskView[] + page: number + limit: number + total: number +} + +export async function listAdminTasks( + token: string, + params: { page?: number; limit?: number; status?: string } = {}, +): Promise { + const qs = new URLSearchParams() + if (params.page) qs.set('page', String(params.page)) + if (params.limit) qs.set('limit', String(params.limit)) + if (params.status) qs.set('status', params.status) + const suffix = qs.toString() + return apiGet(`/api/v1/admin/tasks${suffix ? `?${suffix}` : ''}`, token) +} + +export async function cancelAdminTask(token: string, taskId: string): Promise<{ message: string }> { + return apiJson<{ message: string }>(`/api/v1/admin/tasks/${taskId}/cancel`, undefined, token) +} + +export interface AdminSubscriptionView { + id: string + status: string + current_period_start: string + current_period_end: string + cancel_at_period_end: boolean + plan_name: string + plan_code: string + currency: string + amount_cents: number + interval: string + user_id: string + user_email: string +} + +export interface AdminSubscriptionListResponse { + subscriptions: AdminSubscriptionView[] + page: number + limit: number + total: number +} + +export async function listAdminSubscriptions( + token: string, + params: { page?: number; limit?: number } = {}, +): Promise { + const qs = new URLSearchParams() + if (params.page) qs.set('page', String(params.page)) + if (params.limit) qs.set('limit', String(params.limit)) + const suffix = qs.toString() + return apiGet( + `/api/v1/admin/billing/subscriptions${suffix ? `?${suffix}` : ''}`, + token, + ) +} + +export interface AdminManualSubscriptionResponse { + message: string + subscription_id: string + user_id: string + plan_id: string + plan_name: string + period_start: string + period_end: string + status: string +} + +export async function createAdminSubscription( + token: string, + payload: { user_id: string; plan_id: string; months?: number; note?: string }, +): Promise { + return apiJson('/api/v1/admin/billing/subscriptions/manual', payload, token) +} + +export interface AdminCreditResponse { + message: string + period_start: string + period_end: string + used_units: number + bonus_units: number + total_units: number + remaining_units: number +} + +export async function grantAdminCredits( + token: string, + payload: { user_id: string; units: number; note?: string }, +): Promise { + return apiJson('/api/v1/admin/billing/credits', payload, token) +} + +export interface AdminConfigEntry { + key: string + value: unknown + description?: string | null + updated_at: string + updated_by?: string | null +} + +export async function listAdminConfig(token: string): Promise<{ configs: AdminConfigEntry[] }> { + return apiGet<{ configs: AdminConfigEntry[] }>('/api/v1/admin/config', token) +} + +export async function updateAdminConfig( + token: string, + payload: { key: string; value: unknown; description?: string | null }, +): Promise { + return apiJson('/api/v1/admin/config', payload, token, { method: 'PUT' }) +} + +export interface AdminPlanView { + id: string + code: string + name: string + currency: string + amount_cents: number + interval: string + included_units_per_period: number + max_file_size_mb: number + max_files_per_batch: number + retention_days: number + stripe_product_id?: string | null + stripe_price_id?: string | null + is_active: boolean +} + +export async function listAdminPlans(token: string): Promise<{ plans: AdminPlanView[] }> { + return apiGet<{ plans: AdminPlanView[] }>('/api/v1/admin/plans', token) +} + +export async function updateAdminPlan( + token: string, + planId: string, + payload: { stripe_product_id?: string | null; stripe_price_id?: string | null; is_active?: boolean }, +): Promise { + return apiJson(`/api/v1/admin/plans/${planId}`, payload, token, { method: 'PUT' }) +} + +export interface AdminStripeConfig { + secret_key_configured: boolean + webhook_secret_configured: boolean + secret_key_prefix?: string | null +} + +export async function getStripeConfig(token: string): Promise { + return apiGet('/api/v1/admin/stripe', token) +} + +export async function updateStripeConfig( + token: string, + payload: { secret_key?: string; webhook_secret?: string }, +): Promise { + return apiJson('/api/v1/admin/stripe', payload, token, { method: 'PUT' }) +} + +export interface MailCustomSmtp { + host: string + port: number + encryption: string +} + +export interface AdminMailConfig { + enabled: boolean + provider: string + from: string + from_name: string + custom_smtp?: MailCustomSmtp | null + password_configured: boolean + log_links_when_disabled: boolean +} + +export async function getMailConfig(token: string): Promise { + return apiGet('/api/v1/admin/mail', token) +} + +export async function updateMailConfig( + token: string, + payload: { + enabled: boolean + provider: string + from: string + from_name: string + password?: string + custom_smtp?: MailCustomSmtp | null + log_links_when_disabled?: boolean + }, +): Promise { + return apiJson('/api/v1/admin/mail', payload, token, { method: 'PUT' }) +} + +export async function sendMailTest(token: string, to?: string): Promise<{ message: string }> { + return apiJson<{ message: string }>('/api/v1/admin/mail/test', { to }, token) +} diff --git a/frontend/src/services/http.ts b/frontend/src/services/http.ts new file mode 100644 index 0000000..20495e3 --- /dev/null +++ b/frontend/src/services/http.ts @@ -0,0 +1,129 @@ +export type ApiSuccess = { + success: true + data: T +} + +export type ApiFailure = { + success: false + error: { + code: string + message: string + request_id: string + } +} + +export type ApiEnvelope = ApiSuccess | ApiFailure + +export class ApiError extends Error { + readonly code: string + readonly status: number + readonly requestId?: string + + constructor(code: string, status: number, message: string, requestId?: string) { + super(message) + this.code = code + this.status = status + this.requestId = requestId + } +} + +async function parseEnvelope(res: Response): Promise> { + const text = await res.text() + if (!text) { + if (res.ok) { + return { success: true, data: undefined as T } + } + return { + success: false, + error: { + code: 'HTTP_ERROR', + message: `HTTP ${res.status}`, + request_id: '', + }, + } + } + + try { + return JSON.parse(text) as ApiEnvelope + } catch { + if (res.ok) { + return { success: true, data: text as T } + } + return { + success: false, + error: { + code: 'INVALID_JSON', + message: `无法解析响应(HTTP ${res.status})`, + request_id: '', + }, + } + } +} + +function mergeHeaders(a?: HeadersInit, b?: HeadersInit): Headers { + const out = new Headers(a ?? {}) + for (const [k, v] of new Headers(b ?? {})) out.set(k, v) + return out +} + +export async function apiJson( + path: string, + body: unknown | undefined, + token?: string | null, + init?: RequestInit, +): Promise { + const headers = mergeHeaders(init?.headers, { accept: 'application/json' }) + if (body !== undefined) headers.set('content-type', 'application/json') + if (token) headers.set('authorization', `Bearer ${token}`) + + const res = await fetch(path, { + ...init, + method: init?.method ?? 'POST', + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }) + + const envelope = await parseEnvelope(res) + if (envelope.success) return envelope.data + + throw new ApiError(envelope.error.code, res.status, envelope.error.message, envelope.error.request_id) +} + +export async function apiGet(path: string, token?: string | null, init?: RequestInit): Promise { + const headers = mergeHeaders(init?.headers, { accept: 'application/json' }) + if (token) headers.set('authorization', `Bearer ${token}`) + + const res = await fetch(path, { + ...init, + method: 'GET', + headers, + }) + + const envelope = await parseEnvelope(res) + if (envelope.success) return envelope.data + + throw new ApiError(envelope.error.code, res.status, envelope.error.message, envelope.error.request_id) +} + +export async function apiMultipart( + path: string, + form: FormData, + token?: string | null, + init?: RequestInit, +): Promise { + const headers = mergeHeaders(init?.headers, { accept: 'application/json' }) + if (token) headers.set('authorization', `Bearer ${token}`) + + const res = await fetch(path, { + ...init, + method: 'POST', + headers, + body: form, + }) + + const envelope = await parseEnvelope(res) + if (envelope.success) return envelope.data + + throw new ApiError(envelope.error.code, res.status, envelope.error.message, envelope.error.request_id) +} + diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..12d00b9 --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,66 @@ +import { defineStore } from 'pinia' + +export type UserRole = 'user' | 'admin' + +export interface User { + id: string + email: string + username: string + role: UserRole + email_verified: boolean +} + +interface StoredAuth { + token: string + user: User +} + +const STORAGE_KEY = 'imageforge_auth' + +export const useAuthStore = defineStore('auth', { + state: () => ({ + token: null as string | null, + user: null as User | null, + }), + getters: { + isLoggedIn: (state) => Boolean(state.token), + }, + actions: { + initFromStorage() { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return + const parsed = JSON.parse(raw) as StoredAuth + if (!parsed?.token || !parsed?.user) return + this.token = parsed.token + this.user = parsed.user + } catch { + localStorage.removeItem(STORAGE_KEY) + } + }, + setAuth(token: string, user: User) { + this.token = token + this.user = user + const stored: StoredAuth = { token, user } + localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)) + }, + updateUser(user: User) { + this.user = user + if (!this.token) return + const stored: StoredAuth = { token: this.token, user } + localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)) + }, + logout() { + this.token = null + this.user = null + localStorage.removeItem(STORAGE_KEY) + }, + markEmailVerified() { + if (!this.user || this.user.email_verified) return + this.user = { ...this.user, email_verified: true } + if (!this.token) return + const stored: StoredAuth = { token: this.token, user: this.user } + localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)) + }, + }, +}) diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..5837a11 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,45 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --bg: 248 250 252; + --card: 255 255 255; + --text: 15 23 42; + --muted: 71 85 105; + --border: 226 232 240; + + --brand: 99 102 241; + --brand-strong: 79 70 229; + + --success: 34 197 94; + --warning: 245 158 11; + --danger: 239 68 68; +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + height: 100%; +} + +body { + margin: 0; + background: rgb(var(--bg)); + color: rgb(var(--text)); + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, + 'Apple Color Emoji', 'Segoe UI Emoji'; +} + +a { + color: rgb(var(--brand)); + text-decoration: none; +} +a:hover { + color: rgb(var(--brand-strong)); + text-decoration: underline; +} diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts new file mode 100644 index 0000000..2b9a6e1 --- /dev/null +++ b/frontend/src/utils/format.ts @@ -0,0 +1,23 @@ +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB'] + let value = bytes + let unit = 0 + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit += 1 + } + const digits = unit === 0 ? 0 : unit === 1 ? 1 : 2 + return `${value.toFixed(digits)} ${units[unit]}` +} + +export function formatCents(amountCents: number, currency: string): string { + const amount = (amountCents ?? 0) / 100 + const cc = (currency ?? 'CNY').toUpperCase() + return new Intl.NumberFormat('zh-CN', { + style: 'currency', + currency: cc, + maximumFractionDigits: 2, + }).format(amount) +} + diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..b44a352 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,10 @@ +import typography from '@tailwindcss/typography' + +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'], + theme: { + extend: {}, + }, + plugins: [typography], +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..d493206 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,20 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..2657253 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + server: { + proxy: { + '/api': { + target: process.env.VITE_PROXY_TARGET ?? 'http://127.0.0.1:8080', + changeOrigin: true, + }, + '/downloads': { + target: process.env.VITE_PROXY_TARGET ?? 'http://127.0.0.1:8080', + changeOrigin: true, + }, + '/health': { + target: process.env.VITE_PROXY_TARGET ?? 'http://127.0.0.1:8080', + changeOrigin: true, + }, + }, + }, +}) diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..b66bb88 --- /dev/null +++ b/migrations/001_init.sql @@ -0,0 +1,423 @@ +-- ImageForge initial schema +-- NOTE: This is a starting point; evolve via new migrations. + +BEGIN; + +-- Extensions +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +-- Enums +DO $$ BEGIN + CREATE TYPE user_role AS ENUM ('user', 'admin'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + CREATE TYPE task_status AS ENUM ('pending', 'processing', 'completed', 'failed', 'cancelled'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + CREATE TYPE file_status AS ENUM ('pending', 'processing', 'completed', 'failed'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + CREATE TYPE compression_level AS ENUM ('high', 'medium', 'low'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + CREATE TYPE task_source AS ENUM ('web', 'api'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + CREATE TYPE subscription_status AS ENUM ('trialing', 'active', 'past_due', 'paused', 'canceled', 'incomplete'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + CREATE TYPE invoice_status AS ENUM ('draft', 'open', 'paid', 'void', 'uncollectible'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + CREATE TYPE payment_status AS ENUM ('pending', 'succeeded', 'failed', 'refunded'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +-- users +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) NOT NULL UNIQUE, + username VARCHAR(50) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + role user_role NOT NULL DEFAULT 'user', + is_active BOOLEAN NOT NULL DEFAULT true, + + email_verified_at TIMESTAMPTZ, + + billing_customer_id VARCHAR(200), + + rate_limit_override INTEGER, + storage_limit_mb INTEGER, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); +CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at); +CREATE INDEX IF NOT EXISTS idx_users_email_verified ON users(email_verified_at) WHERE email_verified_at IS NULL; + +-- email_verifications +CREATE TABLE IF NOT EXISTS email_verifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(64) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_email_verifications_token ON email_verifications(token_hash); +CREATE INDEX IF NOT EXISTS idx_email_verifications_user_id ON email_verifications(user_id); +CREATE INDEX IF NOT EXISTS idx_email_verifications_expires ON email_verifications(expires_at) WHERE verified_at IS NULL; + +-- password_resets +CREATE TABLE IF NOT EXISTS password_resets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(64) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_password_resets_token ON password_resets(token_hash); +CREATE INDEX IF NOT EXISTS idx_password_resets_user_id ON password_resets(user_id); +CREATE INDEX IF NOT EXISTS idx_password_resets_expires ON password_resets(expires_at) WHERE used_at IS NULL; + +-- plans +CREATE TABLE IF NOT EXISTS plans ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(50) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + description TEXT, + + stripe_product_id VARCHAR(200), + stripe_price_id VARCHAR(200), + + currency VARCHAR(10) NOT NULL DEFAULT 'CNY', + amount_cents INTEGER NOT NULL DEFAULT 0, + interval VARCHAR(20) NOT NULL DEFAULT 'monthly', + + included_units_per_period INTEGER NOT NULL, + max_file_size_mb INTEGER NOT NULL, + max_files_per_batch INTEGER NOT NULL, + concurrency_limit INTEGER NOT NULL, + retention_days INTEGER NOT NULL, + + features JSONB NOT NULL DEFAULT '{}'::jsonb, + is_active BOOLEAN NOT NULL DEFAULT true, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_plans_is_active ON plans(is_active); + +-- subscriptions +CREATE TABLE IF NOT EXISTS subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + plan_id UUID NOT NULL REFERENCES plans(id), + + status subscription_status NOT NULL DEFAULT 'incomplete', + current_period_start TIMESTAMPTZ NOT NULL, + current_period_end TIMESTAMPTZ NOT NULL, + + cancel_at_period_end BOOLEAN NOT NULL DEFAULT false, + canceled_at TIMESTAMPTZ, + + provider VARCHAR(20) NOT NULL DEFAULT 'none', + provider_customer_id VARCHAR(200), + provider_subscription_id VARCHAR(200), + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id); +CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status); + +-- api_keys (Pro/Business only; enforced at application layer) +CREATE TABLE IF NOT EXISTS api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + name VARCHAR(100) NOT NULL, + key_prefix VARCHAR(20) NOT NULL, + key_hash VARCHAR(255) NOT NULL, + + permissions JSONB NOT NULL DEFAULT '["compress"]', + rate_limit INTEGER NOT NULL DEFAULT 100, + is_active BOOLEAN NOT NULL DEFAULT true, + + last_used_at TIMESTAMPTZ, + last_used_ip INET, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix); +CREATE INDEX IF NOT EXISTS idx_api_keys_is_active ON api_keys(is_active); + +-- tasks +CREATE TABLE IF NOT EXISTS tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + session_id VARCHAR(100), + api_key_id UUID REFERENCES api_keys(id) ON DELETE SET NULL, + + source task_source NOT NULL DEFAULT 'web', + status task_status NOT NULL DEFAULT 'pending', + + compression_level compression_level NOT NULL DEFAULT 'medium', + output_format VARCHAR(10), + max_width INTEGER, + max_height INTEGER, + preserve_metadata BOOLEAN NOT NULL DEFAULT false, + + total_files INTEGER NOT NULL DEFAULT 0, + completed_files INTEGER NOT NULL DEFAULT 0, + failed_files INTEGER NOT NULL DEFAULT 0, + total_original_size BIGINT NOT NULL DEFAULT 0, + total_compressed_size BIGINT NOT NULL DEFAULT 0, + + error_message TEXT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + + expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '24 hours') +); + +CREATE INDEX IF NOT EXISTS idx_tasks_user_id ON tasks(user_id); +CREATE INDEX IF NOT EXISTS idx_tasks_session_id ON tasks(session_id); +CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); +CREATE INDEX IF NOT EXISTS idx_tasks_created_at ON tasks(created_at); +CREATE INDEX IF NOT EXISTS idx_tasks_expires_at ON tasks(expires_at); + +-- task_files +CREATE TABLE IF NOT EXISTS task_files ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + + original_name VARCHAR(255) NOT NULL, + original_format VARCHAR(10) NOT NULL, + output_format VARCHAR(10) NOT NULL, + + original_size BIGINT NOT NULL, + compressed_size BIGINT, + saved_percent DECIMAL(6, 2), + + storage_path VARCHAR(500), + status file_status NOT NULL DEFAULT 'pending', + error_message TEXT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_task_files_task_id ON task_files(task_id); +CREATE INDEX IF NOT EXISTS idx_task_files_status ON task_files(status); + +-- idempotency_keys +CREATE TABLE IF NOT EXISTS idempotency_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + api_key_id UUID REFERENCES api_keys(id) ON DELETE CASCADE, + + idempotency_key VARCHAR(128) NOT NULL, + request_hash VARCHAR(64) NOT NULL, + + response_status INTEGER NOT NULL, + response_body JSONB, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_idempotency_user_key + ON idempotency_keys(user_id, idempotency_key) WHERE user_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_idempotency_api_key_key + ON idempotency_keys(api_key_id, idempotency_key) WHERE api_key_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_idempotency_expires_at ON idempotency_keys(expires_at); + +-- usage_events +CREATE TABLE IF NOT EXISTS usage_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + api_key_id UUID REFERENCES api_keys(id) ON DELETE SET NULL, + source task_source NOT NULL, + + task_id UUID REFERENCES tasks(id) ON DELETE SET NULL, + task_file_id UUID REFERENCES task_files(id) ON DELETE SET NULL, + + units INTEGER NOT NULL DEFAULT 1, + bytes_in BIGINT NOT NULL DEFAULT 0, + bytes_out BIGINT NOT NULL DEFAULT 0, + format_in VARCHAR(10), + format_out VARCHAR(10), + + occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_usage_events_task_file_unique + ON usage_events(task_file_id) WHERE task_file_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_usage_events_user_time ON usage_events(user_id, occurred_at); +CREATE INDEX IF NOT EXISTS idx_usage_events_api_key_time ON usage_events(api_key_id, occurred_at); + +-- usage_periods +CREATE TABLE IF NOT EXISTS usage_periods ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + subscription_id UUID REFERENCES subscriptions(id) ON DELETE SET NULL, + + period_start TIMESTAMPTZ NOT NULL, + period_end TIMESTAMPTZ NOT NULL, + + used_units INTEGER NOT NULL DEFAULT 0, + bytes_in BIGINT NOT NULL DEFAULT 0, + bytes_out BIGINT NOT NULL DEFAULT 0, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE(user_id, period_start, period_end) +); + +CREATE INDEX IF NOT EXISTS idx_usage_periods_user_period ON usage_periods(user_id, period_start); + +-- invoices +CREATE TABLE IF NOT EXISTS invoices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + subscription_id UUID REFERENCES subscriptions(id) ON DELETE SET NULL, + + invoice_number VARCHAR(50) NOT NULL UNIQUE, + status invoice_status NOT NULL DEFAULT 'open', + currency VARCHAR(10) NOT NULL DEFAULT 'CNY', + total_amount_cents INTEGER NOT NULL DEFAULT 0, + + period_start TIMESTAMPTZ, + period_end TIMESTAMPTZ, + + provider VARCHAR(20) NOT NULL DEFAULT 'none', + provider_invoice_id VARCHAR(200), + hosted_invoice_url TEXT, + pdf_url TEXT, + + due_at TIMESTAMPTZ, + paid_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_invoices_user_id ON invoices(user_id); +CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status); + +-- payments +CREATE TABLE IF NOT EXISTS payments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + invoice_id UUID REFERENCES invoices(id) ON DELETE SET NULL, + + provider VARCHAR(20) NOT NULL DEFAULT 'none', + provider_payment_id VARCHAR(200), + + status payment_status NOT NULL DEFAULT 'pending', + currency VARCHAR(10) NOT NULL DEFAULT 'CNY', + amount_cents INTEGER NOT NULL DEFAULT 0, + paid_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_payments_user_id ON payments(user_id); +CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status); + +-- webhook_events +CREATE TABLE IF NOT EXISTS webhook_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + provider VARCHAR(20) NOT NULL, + provider_event_id VARCHAR(200) NOT NULL, + event_type VARCHAR(200) NOT NULL, + payload JSONB NOT NULL, + received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + processed_at TIMESTAMPTZ, + status VARCHAR(20) NOT NULL DEFAULT 'received', + error_message TEXT +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_webhook_events_unique ON webhook_events(provider, provider_event_id); +CREATE INDEX IF NOT EXISTS idx_webhook_events_status ON webhook_events(status); + +-- system_config +CREATE TABLE IF NOT EXISTS system_config ( + key VARCHAR(100) PRIMARY KEY, + value JSONB NOT NULL, + description TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_by UUID REFERENCES users(id) +); + +-- audit_logs +CREATE TABLE IF NOT EXISTS audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + action VARCHAR(50) NOT NULL, + resource_type VARCHAR(50), + resource_id UUID, + details JSONB, + ip_address INET, + user_agent TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action); +CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at); + +-- Default config seeds +INSERT INTO system_config (key, value, description) +VALUES + ('features', '{"registration_enabled": true, "api_key_enabled": true, "anonymous_upload_enabled": true}', '功能开关'), + ('rate_limits', '{"anonymous_per_minute": 10, "anonymous_units_per_day": 10, "user_per_minute": 60, "api_key_per_minute": 100}', '速率限制默认值'), + ('file_limits', '{"max_image_pixels": 40000000}', '图片安全限制(像素上限等)') +ON CONFLICT (key) DO NOTHING; + +INSERT INTO plans (code, name, stripe_price_id, currency, amount_cents, interval, included_units_per_period, max_file_size_mb, max_files_per_batch, concurrency_limit, retention_days, features) +VALUES + ('free', 'Free', NULL, 'CNY', 0, 'monthly', 500, 5, 10, 2, 1, '{"webhook": false, "api": false}'), + ('pro_monthly', 'Pro(月付)', 'price_xxx_pro_monthly', 'CNY', 1999, 'monthly', 10000, 20, 50, 8, 7, '{"webhook": true, "api": true}'), + ('business_monthly', 'Business(月付)', 'price_xxx_business_monthly', 'CNY', 9999, 'monthly', 100000, 50, 200, 32, 30, '{"webhook": true, "api": true, "ip_allowlist": true}') +ON CONFLICT (code) DO NOTHING; + +COMMIT; + diff --git a/migrations/002_task_client_ip.sql b/migrations/002_task_client_ip.sql new file mode 100644 index 0000000..1ff6ad3 --- /dev/null +++ b/migrations/002_task_client_ip.sql @@ -0,0 +1,9 @@ +BEGIN; + +ALTER TABLE tasks + ADD COLUMN IF NOT EXISTS client_ip INET; + +CREATE INDEX IF NOT EXISTS idx_tasks_client_ip ON tasks(client_ip); + +COMMIT; + diff --git a/migrations/003_add_compression_rate.sql b/migrations/003_add_compression_rate.sql new file mode 100644 index 0000000..2440ece --- /dev/null +++ b/migrations/003_add_compression_rate.sql @@ -0,0 +1,6 @@ +BEGIN; + +ALTER TABLE tasks + ADD COLUMN IF NOT EXISTS compression_rate SMALLINT; + +COMMIT; diff --git a/migrations/004_add_bonus_units.sql b/migrations/004_add_bonus_units.sql new file mode 100644 index 0000000..a9aec09 --- /dev/null +++ b/migrations/004_add_bonus_units.sql @@ -0,0 +1,11 @@ +BEGIN; + +ALTER TABLE usage_periods + ADD COLUMN IF NOT EXISTS bonus_units INTEGER NOT NULL DEFAULT 0; + +UPDATE usage_periods +SET bonus_units = bonus_units + ABS(used_units), + used_units = 0 +WHERE used_units < 0; + +COMMIT; diff --git a/src/api/admin.rs b/src/api/admin.rs new file mode 100644 index 0000000..29305c0 --- /dev/null +++ b/src/api/admin.rs @@ -0,0 +1,1553 @@ +use crate::api::context; +use crate::api::envelope::Envelope; +use crate::error::{AppError, ErrorCode}; +use crate::services::billing; +use crate::services::mail; +use crate::services::settings; +use crate::services::settings::{MailConfigStored, MailCustomSmtp, StripeConfigStored}; +use crate::state::AppState; + +use axum::extract::{ConnectInfo, Path, Query, State}; +use axum::http::HeaderMap; +use axum::routing::{get, post, put}; +use axum::{Json, Router}; +use chrono::{DateTime, Datelike, Duration, FixedOffset, Timelike, TimeZone, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use std::net::{IpAddr, SocketAddr}; +use uuid::Uuid; + +pub fn router() -> Router { + Router::new() + .route("/admin/stats", get(get_stats)) + .route("/admin/users", get(list_users)) + .route("/admin/tasks", get(list_tasks)) + .route("/admin/tasks/{task_id}/cancel", post(cancel_task)) + .route("/admin/billing/subscriptions", get(list_subscriptions)) + .route( + "/admin/billing/subscriptions/manual", + post(create_manual_subscription), + ) + .route("/admin/billing/credits", post(grant_credits)) + .route("/admin/plans", get(list_plans)) + .route("/admin/plans/{plan_id}", put(update_plan)) + .route("/admin/stripe", get(get_stripe_config)) + .route("/admin/stripe", put(update_stripe_config)) + .route("/admin/mail", get(get_mail_config)) + .route("/admin/mail", put(update_mail_config)) + .route("/admin/mail/test", post(test_mail)) + .route("/admin/config", get(get_config)) + .route("/admin/config", put(update_config)) +} + +async fn require_admin( + state: &AppState, + jar: axum_extra::extract::cookie::CookieJar, + headers: &HeaderMap, + ip: IpAddr, +) -> Result<(axum_extra::extract::cookie::CookieJar, Uuid), AppError> { + let (jar, principal) = context::authenticate(state, jar, headers, ip).await?; + match principal { + context::Principal::User { user_id, role, .. } => { + if role == "admin" { + Ok((jar, user_id)) + } else { + Err(AppError::new(ErrorCode::Forbidden, "需要管理员权限")) + } + } + _ => Err(AppError::new(ErrorCode::Unauthorized, "未登录")), + } +} + +async fn resolve_user_id(state: &AppState, identifier: &str) -> Result { + let identifier = identifier.trim(); + if identifier.is_empty() { + return Err(AppError::new(ErrorCode::InvalidRequest, "用户 ID 不能为空")); + } + + if let Ok(uuid) = Uuid::parse_str(identifier) { + return sqlx::query_scalar("SELECT id FROM users WHERE id = $1") + .bind(uuid) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))? + .ok_or_else(|| AppError::new(ErrorCode::NotFound, "用户不存在")); + } + + let needle = identifier.to_lowercase(); + let mut ids: Vec = sqlx::query_scalar( + r#" + SELECT id + FROM users + WHERE lower(email) = $1 OR lower(username) = $1 + LIMIT 2 + "#, + ) + .bind(&needle) + .fetch_all(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?; + + if ids.len() > 1 { + return Err(AppError::new(ErrorCode::InvalidRequest, "用户标识不唯一")); + } + + if ids.is_empty() && identifier.len() >= 8 { + let like = format!("{needle}%"); + ids = sqlx::query_scalar( + r#" + SELECT id + FROM users + WHERE lower(id::text) LIKE $1 + LIMIT 2 + "#, + ) + .bind(&like) + .fetch_all(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?; + + if ids.len() > 1 { + return Err(AppError::new(ErrorCode::InvalidRequest, "用户 ID 前缀不唯一")); + } + } + + ids.pop() + .ok_or_else(|| AppError::new(ErrorCode::NotFound, "用户不存在")) +} + +#[derive(Debug, Serialize)] +struct AdminStats { + total_users: i64, + active_users: i64, + pending_tasks: i64, + processing_tasks: i64, + failed_tasks: i64, + completed_tasks: i64, + usage_events_24h: i64, + active_subscriptions: i64, +} + +async fn get_stats( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?; + + let total_users: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users") + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户统计失败").with_source(err))?; + + let active_users: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE is_active = true") + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户统计失败").with_source(err))?; + + #[derive(Debug, FromRow)] + struct TaskStatsRow { + pending: i64, + processing: i64, + failed: i64, + completed: i64, + } + + let task_stats = sqlx::query_as::<_, TaskStatsRow>( + r#" + SELECT + COUNT(*) FILTER (WHERE status = 'pending') AS pending, + COUNT(*) FILTER (WHERE status = 'processing') AS processing, + COUNT(*) FILTER (WHERE status = 'failed') AS failed, + COUNT(*) FILTER (WHERE status = 'completed') AS completed + FROM tasks + "#, + ) + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询任务统计失败").with_source(err))?; + + let usage_events_24h: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM usage_events WHERE occurred_at > NOW() - INTERVAL '24 hours'", + ) + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用量统计失败").with_source(err))?; + + let active_subscriptions: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM subscriptions WHERE status IN ('active', 'trialing', 'past_due')", + ) + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询订阅统计失败").with_source(err))?; + + Ok(Json(Envelope { + success: true, + data: AdminStats { + total_users, + active_users, + pending_tasks: task_stats.pending, + processing_tasks: task_stats.processing, + failed_tasks: task_stats.failed, + completed_tasks: task_stats.completed, + usage_events_24h, + active_subscriptions, + }, + })) +} + +#[derive(Debug, Deserialize)] +struct PagingQuery { + page: Option, + limit: Option, +} + +#[derive(Debug, Deserialize)] +struct UserQuery { + page: Option, + limit: Option, + search: Option, +} + +#[derive(Debug, FromRow, Serialize)] +struct AdminUserRow { + id: Uuid, + email: String, + username: String, + role: String, + is_active: bool, + email_verified_at: Option>, + rate_limit_override: Option, + storage_limit_mb: Option, + created_at: DateTime, + subscription_status: Option, +} + +#[derive(Debug, Serialize)] +struct AdminUserView { + id: Uuid, + email: String, + username: String, + role: String, + is_active: bool, + email_verified: bool, + rate_limit_override: Option, + storage_limit_mb: Option, + created_at: DateTime, + subscription_status: Option, +} + +#[derive(Debug, Serialize)] +struct AdminUsersResponse { + users: Vec, + page: u32, + limit: u32, + total: i64, +} + +async fn list_users( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Query(query): Query, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?; + + let limit = query.limit.unwrap_or(20).clamp(1, 100); + let page = query.page.unwrap_or(1).max(1); + let offset = (page - 1) * limit; + let search = query.search.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + + let total: i64 = if let Some(search) = &search { + let keyword = format!("%{}%", search); + sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE email ILIKE $1 OR username ILIKE $1") + .bind(keyword) + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))? + } else { + sqlx::query_scalar("SELECT COUNT(*) FROM users") + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))? + }; + + let users: Vec = if let Some(search) = &search { + let keyword = format!("%{}%", search); + sqlx::query_as::<_, AdminUserRow>( + r#" + SELECT + u.id, + u.email, + u.username, + u.role::text AS role, + u.is_active, + u.email_verified_at, + u.rate_limit_override, + u.storage_limit_mb, + u.created_at, + s.status::text AS subscription_status + FROM users u + LEFT JOIN LATERAL ( + SELECT status + FROM subscriptions + WHERE user_id = u.id + ORDER BY current_period_end DESC + LIMIT 1 + ) s ON true + WHERE u.email ILIKE $1 OR u.username ILIKE $1 + ORDER BY u.created_at DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(keyword) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))? + } else { + sqlx::query_as::<_, AdminUserRow>( + r#" + SELECT + u.id, + u.email, + u.username, + u.role::text AS role, + u.is_active, + u.email_verified_at, + u.rate_limit_override, + u.storage_limit_mb, + u.created_at, + s.status::text AS subscription_status + FROM users u + LEFT JOIN LATERAL ( + SELECT status + FROM subscriptions + WHERE user_id = u.id + ORDER BY current_period_end DESC + LIMIT 1 + ) s ON true + ORDER BY u.created_at DESC + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))? + }; + + let views = users + .into_iter() + .map(|row| AdminUserView { + id: row.id, + email: row.email, + username: row.username, + role: row.role, + is_active: row.is_active, + email_verified: row.email_verified_at.is_some(), + rate_limit_override: row.rate_limit_override, + storage_limit_mb: row.storage_limit_mb, + created_at: row.created_at, + subscription_status: row.subscription_status, + }) + .collect(); + + Ok(Json(Envelope { + success: true, + data: AdminUsersResponse { + users: views, + page, + limit, + total, + }, + })) +} + +#[derive(Debug, Deserialize)] +struct TaskQuery { + page: Option, + limit: Option, + status: Option, +} + +#[derive(Debug, FromRow, Serialize)] +struct AdminTaskRow { + id: Uuid, + status: String, + source: String, + total_files: i32, + completed_files: i32, + failed_files: i32, + error_message: Option, + created_at: DateTime, + completed_at: Option>, + expires_at: DateTime, + user_id: Option, + user_email: Option, +} + +#[derive(Debug, Serialize)] +struct AdminTasksResponse { + tasks: Vec, + page: u32, + limit: u32, + total: i64, +} + +async fn list_tasks( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Query(query): Query, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?; + + let limit = query.limit.unwrap_or(20).clamp(1, 100); + let page = query.page.unwrap_or(1).max(1); + let offset = (page - 1) * limit; + let status = query.status.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + + let total: i64 = if let Some(status) = &status { + sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE status::text = $1") + .bind(status) + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))? + } else { + sqlx::query_scalar("SELECT COUNT(*) FROM tasks") + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))? + }; + + let tasks: Vec = if let Some(status) = &status { + sqlx::query_as::<_, AdminTaskRow>( + r#" + SELECT + t.id, + t.status::text AS status, + t.source::text AS source, + t.total_files, + t.completed_files, + t.failed_files, + t.error_message, + t.created_at, + t.completed_at, + t.expires_at, + u.id AS user_id, + u.email AS user_email + FROM tasks t + LEFT JOIN users u ON u.id = t.user_id + WHERE t.status::text = $1 + ORDER BY t.created_at DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(status) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))? + } else { + sqlx::query_as::<_, AdminTaskRow>( + r#" + SELECT + t.id, + t.status::text AS status, + t.source::text AS source, + t.total_files, + t.completed_files, + t.failed_files, + t.error_message, + t.created_at, + t.completed_at, + t.expires_at, + u.id AS user_id, + u.email AS user_email + FROM tasks t + LEFT JOIN users u ON u.id = t.user_id + ORDER BY t.created_at DESC + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))? + }; + + Ok(Json(Envelope { + success: true, + data: AdminTasksResponse { + tasks, + page, + limit, + total, + }, + })) +} + +#[derive(Debug, Serialize)] +struct MessageResponse { + message: String, +} + +async fn cancel_task( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Path(task_id): Path, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?; + + let status: Option = sqlx::query_scalar("SELECT status::text FROM tasks WHERE id = $1") + .bind(task_id) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))?; + + let Some(status) = status else { + return Err(AppError::new(ErrorCode::NotFound, "任务不存在")); + }; + + if matches!(status.as_str(), "completed" | "failed" | "cancelled") { + return Ok(Json(Envelope { + success: true, + data: MessageResponse { + message: "任务已结束,无需取消".to_string(), + }, + })); + } + + let mut tx = state + .db + .begin() + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?; + + sqlx::query( + r#" + UPDATE task_files + SET status = 'failed', + error_message = '任务已取消', + completed_at = NOW() + WHERE task_id = $1 AND status IN ('pending', 'processing') + "#, + ) + .bind(task_id) + .execute(&mut *tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "更新任务文件失败").with_source(err))?; + + let updated = sqlx::query( + r#" + UPDATE tasks + SET status = 'cancelled', + error_message = '管理员取消任务', + completed_at = NOW(), + completed_files = (SELECT COUNT(*) FROM task_files WHERE task_id = $1 AND status = 'completed'), + failed_files = (SELECT COUNT(*) FROM task_files WHERE task_id = $1 AND status = 'failed') + WHERE id = $1 AND status IN ('pending', 'processing') + "#, + ) + .bind(task_id) + .execute(&mut *tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "更新任务状态失败").with_source(err))?; + + if updated.rows_affected() == 0 { + return Err(AppError::new(ErrorCode::InvalidRequest, "任务状态无法取消")); + } + + tx.commit() + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?; + + Ok(Json(Envelope { + success: true, + data: MessageResponse { + message: "任务已取消".to_string(), + }, + })) +} + +#[derive(Debug, FromRow, Serialize)] +struct AdminSubscriptionRow { + id: Uuid, + status: String, + current_period_start: DateTime, + current_period_end: DateTime, + cancel_at_period_end: bool, + plan_name: String, + plan_code: String, + currency: String, + amount_cents: i32, + interval: String, + user_id: Uuid, + user_email: String, +} + +#[derive(Debug, Serialize)] +struct AdminSubscriptionsResponse { + subscriptions: Vec, + page: u32, + limit: u32, + total: i64, +} + +async fn list_subscriptions( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Query(query): Query, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?; + + let limit = query.limit.unwrap_or(20).clamp(1, 100); + let page = query.page.unwrap_or(1).max(1); + let offset = (page - 1) * limit; + + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM subscriptions") + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询订阅失败").with_source(err))?; + + let subscriptions = sqlx::query_as::<_, AdminSubscriptionRow>( + r#" + SELECT + s.id, + s.status::text AS status, + s.current_period_start, + s.current_period_end, + s.cancel_at_period_end, + p.name AS plan_name, + p.code AS plan_code, + p.currency, + p.amount_cents, + p.interval, + u.id AS user_id, + u.email AS user_email + FROM subscriptions s + JOIN plans p ON p.id = s.plan_id + JOIN users u ON u.id = s.user_id + ORDER BY s.created_at DESC + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询订阅失败").with_source(err))?; + + Ok(Json(Envelope { + success: true, + data: AdminSubscriptionsResponse { + subscriptions, + page, + limit, + total, + }, + })) +} + +#[derive(Debug, Deserialize)] +struct GrantCreditsRequest { + user_id: String, + units: i32, + note: Option, +} + +#[derive(Debug, Serialize)] +struct GrantCreditsResponse { + message: String, + period_start: DateTime, + period_end: DateTime, + used_units: i32, + bonus_units: i32, + total_units: i32, + remaining_units: i32, +} + +async fn grant_credits( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Json(req): Json, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?; + + if req.units <= 0 { + return Err(AppError::new(ErrorCode::InvalidRequest, "units 必须大于 0")); + } + + let user_id = resolve_user_id(&state, &req.user_id).await?; + + #[derive(Debug, FromRow)] + struct SubRow { + id: Uuid, + plan_id: Uuid, + current_period_start: DateTime, + current_period_end: DateTime, + } + + let sub = sqlx::query_as::<_, SubRow>( + r#" + SELECT id, plan_id, current_period_start, current_period_end + FROM subscriptions + WHERE user_id = $1 + ORDER BY current_period_end DESC + LIMIT 1 + "#, + ) + .bind(user_id) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询订阅失败").with_source(err))?; + + let (subscription_id, period_start, period_end, plan_id) = if let Some(sub) = sub { + (Some(sub.id), sub.current_period_start, sub.current_period_end, Some(sub.plan_id)) + } else { + let (start, end) = billing::current_month_period_utc8(Utc::now()); + (None, start, end, None) + }; + + let plan_units: i32 = if let Some(plan_id) = plan_id { + sqlx::query_scalar("SELECT included_units_per_period FROM plans WHERE id = $1") + .bind(plan_id) + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))? + } else { + sqlx::query_scalar("SELECT included_units_per_period FROM plans WHERE code = 'free'") + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))? + }; + + let mut tx = state + .db + .begin() + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?; + + sqlx::query( + r#" + INSERT INTO usage_periods (user_id, subscription_id, period_start, period_end) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, period_start, period_end) DO NOTHING + "#, + ) + .bind(user_id) + .bind(subscription_id) + .bind(period_start) + .bind(period_end) + .execute(&mut *tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "初始化用量周期失败").with_source(err))?; + + #[derive(Debug, FromRow)] + struct UsageRow { + used_units: i32, + bonus_units: i32, + } + + let usage = sqlx::query_as::<_, UsageRow>( + r#" + UPDATE usage_periods + SET bonus_units = bonus_units + $1, + updated_at = NOW() + WHERE user_id = $2 AND period_start = $3 AND period_end = $4 + RETURNING used_units, bonus_units + "#, + ) + .bind(req.units) + .bind(user_id) + .bind(period_start) + .bind(period_end) + .fetch_one(&mut *tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "更新用量失败").with_source(err))?; + + sqlx::query( + r#" + INSERT INTO audit_logs (user_id, action, resource_type, resource_id, details, ip_address) + VALUES ($1, 'billing_credit', 'user', $2, $3, $4::inet) + "#, + ) + .bind(admin_id) + .bind(user_id) + .bind(serde_json::json!({ "units": req.units, "note": req.note })) + .bind(ip.to_string()) + .execute(&mut *tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "写入审计日志失败").with_source(err))?; + + tx.commit() + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?; + + let total_units = plan_units + usage.bonus_units; + let remaining = (total_units - usage.used_units).max(0); + + Ok(Json(Envelope { + success: true, + data: GrantCreditsResponse { + message: "额度已增加".to_string(), + period_start, + period_end, + used_units: usage.used_units, + bonus_units: usage.bonus_units, + total_units, + remaining_units: remaining, + }, + })) +} + +#[derive(Debug, Deserialize)] +struct ManualSubscriptionRequest { + user_id: String, + plan_id: Uuid, + months: Option, + note: Option, +} + +#[derive(Debug, Serialize)] +struct ManualSubscriptionResponse { + message: String, + subscription_id: Uuid, + user_id: Uuid, + plan_id: Uuid, + plan_name: String, + period_start: DateTime, + period_end: DateTime, + status: String, +} + +async fn create_manual_subscription( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Json(req): Json, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?; + + let months = req.months.unwrap_or(1); + if months <= 0 || months > 24 { + return Err(AppError::new(ErrorCode::InvalidRequest, "months 需在 1-24 之间")); + } + + let user_id = resolve_user_id(&state, &req.user_id).await?; + + #[derive(Debug, FromRow)] + struct PlanRow { + id: Uuid, + name: String, + is_active: bool, + } + + let plan = sqlx::query_as::<_, PlanRow>( + r#" + SELECT id, name, is_active + FROM plans + WHERE id = $1 + "#, + ) + .bind(req.plan_id) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))? + .ok_or_else(|| AppError::new(ErrorCode::NotFound, "套餐不存在"))?; + + if !plan.is_active { + return Err(AppError::new(ErrorCode::Forbidden, "套餐不可用")); + } + + let period_start = Utc::now(); + let period_end = add_months_utc8(period_start, months)?; + + let mut tx = state + .db + .begin() + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?; + + let _ = sqlx::query( + r#" + UPDATE subscriptions + SET status = 'canceled', + cancel_at_period_end = false, + canceled_at = NOW(), + updated_at = NOW() + WHERE user_id = $1 AND status IN ('active', 'trialing', 'past_due') + "#, + ) + .bind(user_id) + .execute(&mut *tx) + .await; + + let subscription_id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO subscriptions ( + user_id, plan_id, status, + current_period_start, current_period_end, + cancel_at_period_end, provider, + created_at, updated_at + ) VALUES ( + $1, $2, 'active', + $3, $4, + false, 'manual', + NOW(), NOW() + ) + RETURNING id + "#, + ) + .bind(user_id) + .bind(plan.id) + .bind(period_start) + .bind(period_end) + .fetch_one(&mut *tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "创建订阅失败").with_source(err))?; + + sqlx::query( + r#" + INSERT INTO usage_periods (user_id, subscription_id, period_start, period_end) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, period_start, period_end) DO NOTHING + "#, + ) + .bind(user_id) + .bind(subscription_id) + .bind(period_start) + .bind(period_end) + .execute(&mut *tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "初始化用量周期失败").with_source(err))?; + + sqlx::query( + r#" + INSERT INTO audit_logs (user_id, action, resource_type, resource_id, details, ip_address) + VALUES ($1, 'manual_subscription', 'subscription', $2, $3, $4::inet) + "#, + ) + .bind(admin_id) + .bind(subscription_id) + .bind(serde_json::json!({ + "target_user_id": user_id, + "plan_id": plan.id, + "months": months, + "note": req.note, + })) + .bind(ip.to_string()) + .execute(&mut *tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "写入审计日志失败").with_source(err))?; + + tx.commit() + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?; + + Ok(Json(Envelope { + success: true, + data: ManualSubscriptionResponse { + message: "套餐已开通".to_string(), + subscription_id, + user_id, + plan_id: plan.id, + plan_name: plan.name, + period_start, + period_end, + status: "active".to_string(), + }, + })) +} + +fn add_months_utc8(start: DateTime, months: i32) -> Result, AppError> { + let tz = FixedOffset::east_opt(8 * 3600).unwrap(); + let local = start.with_timezone(&tz); + let total_months = local.year() * 12 + (local.month() as i32 - 1) + months; + let new_year = total_months / 12; + let new_month = (total_months % 12) + 1; + let max_day = days_in_month(tz, new_year, new_month as u32); + let day = local.day().min(max_day); + + let next = tz + .with_ymd_and_hms( + new_year, + new_month as u32, + day, + local.hour(), + local.minute(), + local.second(), + ) + .single() + .ok_or_else(|| AppError::new(ErrorCode::Internal, "计算订阅周期失败"))?; + + Ok(next.with_timezone(&Utc)) +} + +fn days_in_month(tz: FixedOffset, year: i32, month: u32) -> u32 { + let (next_year, next_month) = if month == 12 { + (year + 1, 1) + } else { + (year, month + 1) + }; + let first_next = tz.with_ymd_and_hms(next_year, next_month, 1, 0, 0, 0).single().unwrap(); + let last = first_next - Duration::days(1); + last.day() +} + +#[derive(Debug, FromRow, Serialize)] +struct AdminPlanRow { + id: Uuid, + code: String, + name: String, + currency: String, + amount_cents: i32, + interval: String, + included_units_per_period: i32, + max_file_size_mb: i32, + max_files_per_batch: i32, + retention_days: i32, + stripe_product_id: Option, + stripe_price_id: Option, + is_active: bool, +} + +#[derive(Debug, Serialize)] +struct AdminPlansResponse { + plans: Vec, +} + +async fn list_plans( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?; + + let plans = sqlx::query_as::<_, AdminPlanRow>( + r#" + SELECT + id, + code, + name, + currency, + amount_cents, + interval, + included_units_per_period, + max_file_size_mb, + max_files_per_batch, + retention_days, + stripe_product_id, + stripe_price_id, + is_active + FROM plans + ORDER BY amount_cents ASC + "#, + ) + .fetch_all(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))?; + + Ok(Json(Envelope { + success: true, + data: AdminPlansResponse { plans }, + })) +} + +#[derive(Debug, Deserialize)] +struct UpdatePlanRequest { + stripe_product_id: Option, + stripe_price_id: Option, + is_active: Option, +} + +async fn update_plan( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Path(plan_id): Path, + Json(req): Json, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?; + + if req.stripe_product_id.is_none() && req.stripe_price_id.is_none() && req.is_active.is_none() { + return Err(AppError::new(ErrorCode::InvalidRequest, "未提供更新字段")); + } + + let row = sqlx::query_as::<_, AdminPlanRow>( + r#" + UPDATE plans + SET stripe_product_id = COALESCE($2, stripe_product_id), + stripe_price_id = COALESCE($3, stripe_price_id), + is_active = COALESCE($4, is_active), + updated_at = NOW() + WHERE id = $1 + RETURNING + id, + code, + name, + currency, + amount_cents, + interval, + included_units_per_period, + max_file_size_mb, + max_files_per_batch, + retention_days, + stripe_product_id, + stripe_price_id, + is_active + "#, + ) + .bind(plan_id) + .bind(req.stripe_product_id.filter(|v| !v.trim().is_empty())) + .bind(req.stripe_price_id.filter(|v| !v.trim().is_empty())) + .bind(req.is_active) + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "更新套餐失败").with_source(err))?; + + Ok(Json(Envelope { + success: true, + data: row, + })) +} + +#[derive(Debug, Serialize)] +struct StripeConfigView { + secret_key_configured: bool, + webhook_secret_configured: bool, + secret_key_prefix: Option, +} + +async fn get_stripe_config( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?; + + let stored = settings::load_system_config::(&state, "stripe").await?; + let (secret_key_configured, webhook_secret_configured, secret_key_prefix) = if let Some(cfg) = stored { + ( + cfg.secret_key_encrypted.as_ref().is_some(), + cfg.webhook_secret_encrypted.as_ref().is_some(), + cfg.secret_key_prefix, + ) + } else { + let env_secret = state + .config + .stripe_secret_key + .as_ref() + .filter(|v| !v.trim().is_empty()); + let env_webhook = state + .config + .stripe_webhook_secret + .as_ref() + .filter(|v| !v.trim().is_empty()); + ( + env_secret.is_some(), + env_webhook.is_some(), + env_secret.map(|value| mask_secret(value)), + ) + }; + + Ok(Json(Envelope { + success: true, + data: StripeConfigView { + secret_key_configured, + webhook_secret_configured, + secret_key_prefix, + }, + })) +} + +#[derive(Debug, Deserialize)] +struct StripeConfigRequest { + secret_key: Option, + webhook_secret: Option, +} + +async fn update_stripe_config( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Json(req): Json, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?; + + let mut stored = settings::load_system_config::(&state, "stripe") + .await? + .unwrap_or(StripeConfigStored { + secret_key_encrypted: None, + webhook_secret_encrypted: None, + secret_key_prefix: None, + }); + + if let Some(secret_key) = req.secret_key.as_ref().map(|v| v.trim().to_string()) { + if secret_key.is_empty() { + stored.secret_key_encrypted = None; + stored.secret_key_prefix = None; + } else { + stored.secret_key_encrypted = Some(settings::encrypt_secret(&state, &secret_key)?); + stored.secret_key_prefix = Some(mask_secret(&secret_key)); + } + } + + if let Some(webhook_secret) = req.webhook_secret.as_ref().map(|v| v.trim().to_string()) { + if webhook_secret.is_empty() { + stored.webhook_secret_encrypted = None; + } else { + stored.webhook_secret_encrypted = Some(settings::encrypt_secret(&state, &webhook_secret)?); + } + } + + settings::upsert_system_config( + &state, + "stripe", + serde_json::to_value(&stored).map_err(|err| { + AppError::new(ErrorCode::Internal, "序列化 Stripe 配置失败").with_source(err) + })?, + Some("Stripe 支付配置"), + Some(admin_id), + ) + .await?; + + Ok(Json(Envelope { + success: true, + data: StripeConfigView { + secret_key_configured: stored.secret_key_encrypted.is_some(), + webhook_secret_configured: stored.webhook_secret_encrypted.is_some(), + secret_key_prefix: stored.secret_key_prefix, + }, + })) +} + +#[derive(Debug, Serialize)] +struct MailConfigView { + enabled: bool, + provider: String, + from: String, + from_name: String, + custom_smtp: Option, + password_configured: bool, + log_links_when_disabled: bool, +} + +async fn get_mail_config( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?; + + let stored = settings::load_system_config::(&state, "mail").await?; + if let Some(cfg) = stored { + return Ok(Json(Envelope { + success: true, + data: MailConfigView { + enabled: cfg.enabled, + provider: cfg.provider, + from: cfg.from, + from_name: cfg.from_name, + custom_smtp: cfg.custom_smtp, + password_configured: cfg.password_encrypted.is_some(), + log_links_when_disabled: cfg.log_links_when_disabled.unwrap_or(false), + }, + })); + } + + let custom_smtp = if state.config.mail_provider.eq_ignore_ascii_case("custom") { + Some(MailCustomSmtp { + host: state.config.mail_smtp_host.clone().unwrap_or_default(), + port: state.config.mail_smtp_port.unwrap_or(465), + encryption: state + .config + .mail_smtp_encryption + .clone() + .unwrap_or_else(|| "ssl".to_string()), + }) + } else { + None + }; + + Ok(Json(Envelope { + success: true, + data: MailConfigView { + enabled: state.config.mail_enabled, + provider: state.config.mail_provider.clone(), + from: state.config.mail_from.clone(), + from_name: state.config.mail_from_name.clone(), + custom_smtp, + password_configured: !state.config.mail_password.trim().is_empty(), + log_links_when_disabled: state.config.mail_log_links_when_disabled, + }, + })) +} + +#[derive(Debug, Deserialize)] +struct MailConfigRequest { + enabled: bool, + provider: String, + from: String, + from_name: String, + password: Option, + custom_smtp: Option, + log_links_when_disabled: Option, +} + +async fn update_mail_config( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Json(req): Json, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?; + + if req.provider.trim().is_empty() || req.from.trim().is_empty() { + return Err(AppError::new(ErrorCode::InvalidRequest, "邮件配置不能为空")); + } + + if req.provider.eq_ignore_ascii_case("custom") { + let custom = req.custom_smtp.as_ref().ok_or_else(|| { + AppError::new(ErrorCode::InvalidRequest, "自定义 SMTP 需要填写 host/port/encryption") + })?; + if custom.host.trim().is_empty() { + return Err(AppError::new(ErrorCode::InvalidRequest, "SMTP host 不能为空")); + } + } + + let mut stored = settings::load_system_config::(&state, "mail") + .await? + .unwrap_or(MailConfigStored { + enabled: req.enabled, + provider: req.provider.trim().to_string(), + from: req.from.trim().to_string(), + from_name: req.from_name.trim().to_string(), + password_encrypted: None, + custom_smtp: req.custom_smtp.clone(), + log_links_when_disabled: req.log_links_when_disabled, + }); + + stored.enabled = req.enabled; + stored.provider = req.provider.trim().to_string(); + stored.from = req.from.trim().to_string(); + stored.from_name = req.from_name.trim().to_string(); + stored.custom_smtp = req.custom_smtp.clone(); + stored.log_links_when_disabled = req.log_links_when_disabled; + + if let Some(password) = req.password.as_ref().map(|v| v.trim().to_string()) { + if password.is_empty() { + stored.password_encrypted = None; + } else { + stored.password_encrypted = Some(settings::encrypt_secret(&state, &password)?); + } + } + + if stored.enabled && stored.password_encrypted.is_none() { + return Err(AppError::new( + ErrorCode::InvalidRequest, + "邮件服务已启用但未配置授权码/密码", + )); + } + + settings::upsert_system_config( + &state, + "mail", + serde_json::to_value(&stored) + .map_err(|err| AppError::new(ErrorCode::Internal, "序列化邮件配置失败").with_source(err))?, + Some("邮件服务配置"), + Some(admin_id), + ) + .await?; + + Ok(Json(Envelope { + success: true, + data: MailConfigView { + enabled: stored.enabled, + provider: stored.provider, + from: stored.from, + from_name: stored.from_name, + custom_smtp: stored.custom_smtp, + password_configured: stored.password_encrypted.is_some(), + log_links_when_disabled: stored.log_links_when_disabled.unwrap_or(false), + }, + })) +} + +#[derive(Debug, Deserialize)] +struct MailTestRequest { + to: Option, +} + +async fn test_mail( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Json(req): Json, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?; + + let to = if let Some(to) = req.to.as_ref().map(|v| v.trim().to_string()) { + if to.is_empty() { + None + } else { + Some(to) + } + } else { + None + }; + + let recipient = if let Some(to) = to { + to + } else { + let email: String = sqlx::query_scalar("SELECT email FROM users WHERE id = $1") + .bind(admin_id) + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询管理员邮箱失败").with_source(err))?; + email + }; + + mail::send_test_email(&state, &recipient) + .await + .map_err(|err| AppError::new(ErrorCode::MailSendFailed, "测试邮件发送失败").with_source(err))?; + + Ok(Json(Envelope { + success: true, + data: MessageResponse { + message: format!("测试邮件已发送至 {recipient}"), + }, + })) +} + +fn mask_secret(secret: &str) -> String { + let trimmed = secret.trim(); + if trimmed.len() <= 8 { + return trimmed.to_string(); + } + format!("{}...", &trimmed[..8]) +} + +#[derive(Debug, FromRow, Serialize)] +struct ConfigRow { + key: String, + value: serde_json::Value, + description: Option, + updated_at: DateTime, + updated_by: Option, +} + +#[derive(Debug, Serialize)] +struct ConfigResponse { + configs: Vec, +} + +async fn get_config( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?; + + let configs = sqlx::query_as::<_, ConfigRow>( + r#" + SELECT key, value, description, updated_at, updated_by + FROM system_config + ORDER BY key ASC + "#, + ) + .fetch_all(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询配置失败").with_source(err))?; + + Ok(Json(Envelope { + success: true, + data: ConfigResponse { configs }, + })) +} + +#[derive(Debug, Deserialize)] +struct UpdateConfigRequest { + key: String, + value: serde_json::Value, + description: Option, +} + +async fn update_config( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Json(req): Json, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?; + + let key = req.key.trim(); + if key.is_empty() { + return Err(AppError::new(ErrorCode::InvalidRequest, "key 不能为空")); + } + + let row = sqlx::query_as::<_, ConfigRow>( + r#" + INSERT INTO system_config (key, value, description, updated_at, updated_by) + VALUES ($1, $2, $3, NOW(), $4) + ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value, + description = COALESCE(EXCLUDED.description, system_config.description), + updated_at = NOW(), + updated_by = $4 + RETURNING key, value, description, updated_at, updated_by + "#, + ) + .bind(key) + .bind(req.value) + .bind(req.description) + .bind(admin_id) + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "更新配置失败").with_source(err))?; + + Ok(Json(Envelope { + success: true, + data: row, + })) +} diff --git a/src/api/auth.rs b/src/api/auth.rs new file mode 100644 index 0000000..a34e9f8 --- /dev/null +++ b/src/api/auth.rs @@ -0,0 +1,596 @@ +use crate::auth; +use crate::api::envelope::Envelope; +use crate::error::{AppError, ErrorCode}; +use crate::services::mail; +use crate::state::AppState; + +use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; +use axum::{ + extract::State, + http::HeaderMap, + routing::post, + Json, Router, +}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{DateTime, Duration, Utc}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +pub fn router() -> Router { + Router::new() + .route("/register", post(register)) + .route("/login", post(login)) + .route("/send-verification", post(send_verification)) + .route("/verify-email", post(verify_email)) + .route("/forgot-password", post(forgot_password)) + .route("/reset-password", post(reset_password)) +} + +#[derive(Debug, Deserialize)] +struct RegisterRequest { + email: String, + password: String, + username: String, +} + +#[derive(Debug, Serialize)] +struct RegisterResponse { + user: UserView, + token: String, + message: String, +} + +#[derive(Debug, Deserialize)] +struct LoginRequest { + email: String, + password: String, +} + +#[derive(Debug, Serialize)] +struct LoginResponse { + token: String, + expires_at: DateTime, + user: UserView, +} + +#[derive(Debug, Serialize)] +struct UserView { + id: Uuid, + email: String, + username: String, + role: String, + email_verified: bool, +} + +#[derive(Debug, sqlx::FromRow)] +struct UserRow { + id: Uuid, + email: String, + username: String, + password_hash: String, + role: String, + is_active: bool, + email_verified_at: Option>, +} + +async fn register( + State(state): State, + Json(req): Json, +) -> Result>, AppError> { + validate_email(&req.email)?; + validate_username(&req.username)?; + validate_password(&req.password)?; + + let password_hash = hash_password(&req.password)?; + + let user = sqlx::query_as::<_, UserRow>( + r#" + INSERT INTO users (email, username, password_hash) + VALUES ($1, $2, $3) + RETURNING + id, + email, + username, + password_hash, + role::text AS role, + is_active, + email_verified_at + "#, + ) + .bind(req.email.to_lowercase()) + .bind(&req.username) + .bind(password_hash) + .fetch_one(&state.db) + .await + .map_err(map_unique_violation)?; + + let (token, _expires_at) = + auth::issue_jwt(&state.config.jwt_secret, state.config.jwt_expiry_hours, user.id, &user.role)?; + + let verification_token = generate_token(); + let token_hash = sha256_hex(&verification_token); + let expires_at_db = Utc::now() + Duration::hours(24); + + sqlx::query( + r#" + INSERT INTO email_verifications (user_id, token_hash, expires_at) + VALUES ($1, $2, $3) + "#, + ) + .bind(user.id) + .bind(token_hash) + .bind(expires_at_db) + .execute(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "创建邮箱验证记录失败").with_source(err))?; + + let verification_url = format!( + "{}/verify-email?token={}", + state.config.public_base_url, verification_token + ); + + mail::send_verification_email(&state, &user.email, &user.username, &verification_url) + .await + .map_err(|err| AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err))?; + + let body = RegisterResponse { + user: UserView { + id: user.id, + email: user.email, + username: user.username, + role: user.role, + email_verified: user.email_verified_at.is_some(), + }, + token, + message: "注册成功,验证邮件已发送至您的邮箱".to_string(), + }; + + Ok(Json(Envelope { + success: true, + data: body, + })) +} + +async fn login( + State(state): State, + Json(req): Json, +) -> Result>, AppError> { + let identity = req.email.trim(); + if identity.is_empty() { + return Err(AppError::new(ErrorCode::InvalidRequest, "邮箱或用户名不能为空")); + } + + let user = if identity.contains('@') { + validate_email(identity)?; + sqlx::query_as::<_, UserRow>( + r#" + SELECT + id, + email, + username, + password_hash, + role::text AS role, + is_active, + email_verified_at + FROM users + WHERE email = $1 + "#, + ) + .bind(identity.to_lowercase()) + .fetch_optional(&state.db) + .await + } else { + validate_username(identity)?; + sqlx::query_as::<_, UserRow>( + r#" + SELECT + id, + email, + username, + password_hash, + role::text AS role, + is_active, + email_verified_at + FROM users + WHERE username = $1 + "#, + ) + .bind(identity) + .fetch_optional(&state.db) + .await + } + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))? + .ok_or_else(|| AppError::new(ErrorCode::Unauthorized, "账号或密码错误"))?; + + if !user.is_active { + return Err(AppError::new(ErrorCode::Forbidden, "账号已被禁用")); + } + + verify_password(&req.password, &user.password_hash)?; + + let (token, expires_at) = + auth::issue_jwt(&state.config.jwt_secret, state.config.jwt_expiry_hours, user.id, &user.role)?; + + Ok(Json(Envelope { + success: true, + data: LoginResponse { + token, + expires_at, + user: UserView { + id: user.id, + email: user.email, + username: user.username, + role: user.role, + email_verified: user.email_verified_at.is_some(), + }, + }, + })) +} + +#[derive(Debug, Serialize)] +struct MessageResponse { + message: String, +} + +async fn send_verification( + State(state): State, + headers: HeaderMap, +) -> Result>, AppError> { + let claims = auth::require_jwt(&state.config.jwt_secret, &headers)?; + + // Rate limit: 1 per minute per user + let key = format!("rate:send_verification:{}:{}", claims.sub, Utc::now().format("%Y%m%d%H%M")); + let mut redis = state.redis.clone(); + let count: i64 = redis::cmd("INCR") + .arg(&key) + .query_async(&mut redis) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "Redis 限流失败").with_source(err))?; + if count == 1 { + let _: () = redis::cmd("EXPIRE") + .arg(&key) + .arg(60) + .query_async(&mut redis) + .await + .unwrap_or(()); + } + if count > 1 { + return Err(AppError::new(ErrorCode::RateLimited, "发送过于频繁,请稍后再试")); + } + + let user = sqlx::query_as::<_, UserRow>( + r#" + SELECT + id, + email, + username, + password_hash, + role::text AS role, + is_active, + email_verified_at + FROM users + WHERE id = $1 + "#, + ) + .bind(claims.sub) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))? + .ok_or_else(|| AppError::new(ErrorCode::Unauthorized, "用户不存在或未登录"))?; + + if user.email_verified_at.is_some() { + return Ok(Json(Envelope { + success: true, + data: MessageResponse { + message: "邮箱已验证,无需重复验证".to_string(), + }, + })); + } + + let verification_token = generate_token(); + let token_hash = sha256_hex(&verification_token); + let expires_at_db = Utc::now() + Duration::hours(24); + + sqlx::query( + r#" + INSERT INTO email_verifications (user_id, token_hash, expires_at) + VALUES ($1, $2, $3) + "#, + ) + .bind(user.id) + .bind(token_hash) + .bind(expires_at_db) + .execute(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "创建邮箱验证记录失败").with_source(err))?; + + let verification_url = format!( + "{}/verify-email?token={}", + state.config.public_base_url, verification_token + ); + + mail::send_verification_email(&state, &user.email, &user.username, &verification_url) + .await + .map_err(|err| AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err))?; + + Ok(Json(Envelope { + success: true, + data: MessageResponse { + message: "验证邮件已发送,请查收".to_string(), + }, + })) +} + +#[derive(Debug, Deserialize)] +struct VerifyEmailRequest { + token: String, +} + +async fn verify_email( + State(state): State, + Json(req): Json, +) -> Result>, AppError> { + if req.token.trim().is_empty() { + return Err(AppError::new(ErrorCode::InvalidRequest, "token 不能为空")); + } + + let token_hash = sha256_hex(&req.token); + let now = Utc::now(); + + let updated = sqlx::query( + r#" + WITH v AS ( + SELECT user_id + FROM email_verifications + WHERE token_hash = $1 + AND verified_at IS NULL + AND expires_at > $2 + LIMIT 1 + ), + u AS ( + UPDATE users + SET email_verified_at = $2 + WHERE id = (SELECT user_id FROM v) + AND email_verified_at IS NULL + RETURNING id + ) + UPDATE email_verifications + SET verified_at = $2 + WHERE token_hash = $1 + AND verified_at IS NULL + AND expires_at > $2 + AND user_id IN (SELECT id FROM u) + "#, + ) + .bind(token_hash) + .bind(now) + .execute(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "邮箱验证失败").with_source(err))?; + + if updated.rows_affected() == 0 { + return Err(AppError::new(ErrorCode::InvalidToken, "Token 无效或已过期")); + } + + Ok(Json(Envelope { + success: true, + data: MessageResponse { + message: "邮箱验证成功".to_string(), + }, + })) +} + +#[derive(Debug, Deserialize)] +struct ForgotPasswordRequest { + email: String, +} + +async fn forgot_password( + State(state): State, + Json(req): Json, +) -> Result>, AppError> { + validate_email(&req.email)?; + + let user = sqlx::query_as::<_, UserRow>( + r#" + SELECT + id, + email, + username, + password_hash, + role::text AS role, + is_active, + email_verified_at + FROM users + WHERE email = $1 + "#, + ) + .bind(req.email.to_lowercase()) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?; + + if let Some(user) = user { + let reset_token = generate_token(); + let token_hash = sha256_hex(&reset_token); + let expires_at_db = Utc::now() + Duration::hours(1); + + let _ = sqlx::query( + r#" + INSERT INTO password_resets (user_id, token_hash, expires_at) + VALUES ($1, $2, $3) + "#, + ) + .bind(user.id) + .bind(token_hash) + .bind(expires_at_db) + .execute(&state.db) + .await; + + let reset_url = format!( + "{}/reset-password?token={}", + state.config.public_base_url, reset_token + ); + + let _ = mail::send_password_reset_email(&state, &user.email, &user.username, &reset_url).await; + } + + Ok(Json(Envelope { + success: true, + data: MessageResponse { + message: "如果该邮箱已注册,您将收到重置邮件".to_string(), + }, + })) +} + +#[derive(Debug, Deserialize)] +struct ResetPasswordRequest { + token: String, + new_password: String, +} + +async fn reset_password( + State(state): State, + Json(req): Json, +) -> Result>, AppError> { + if req.token.trim().is_empty() { + return Err(AppError::new(ErrorCode::InvalidRequest, "token 不能为空")); + } + validate_password(&req.new_password)?; + + let token_hash = sha256_hex(&req.token); + let now = Utc::now(); + + let user_id: Option = sqlx::query_scalar( + r#" + SELECT user_id + FROM password_resets + WHERE token_hash = $1 + AND used_at IS NULL + AND expires_at > $2 + "#, + ) + .bind(&token_hash) + .bind(now) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "重置密码失败").with_source(err))?; + + let Some(user_id) = user_id else { + return Err(AppError::new(ErrorCode::InvalidToken, "Token 无效或已过期")); + }; + + let password_hash = hash_password(&req.new_password)?; + + let mut tx = state + .db + .begin() + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?; + + sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2") + .bind(password_hash) + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "更新密码失败").with_source(err))?; + + sqlx::query("UPDATE password_resets SET used_at = $2 WHERE token_hash = $1 AND used_at IS NULL") + .bind(token_hash) + .bind(now) + .execute(&mut *tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "更新重置记录失败").with_source(err))?; + + tx.commit() + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?; + + Ok(Json(Envelope { + success: true, + data: MessageResponse { + message: "密码重置成功".to_string(), + }, + })) +} + +fn validate_email(email: &str) -> Result<(), AppError> { + if email.trim().is_empty() || !email.contains('@') { + return Err(AppError::new(ErrorCode::InvalidRequest, "邮箱格式不正确")); + } + if email.len() > 255 { + return Err(AppError::new(ErrorCode::InvalidRequest, "邮箱过长")); + } + Ok(()) +} + +fn validate_username(username: &str) -> Result<(), AppError> { + if username.trim().is_empty() { + return Err(AppError::new(ErrorCode::InvalidRequest, "用户名不能为空")); + } + if username.len() > 50 { + return Err(AppError::new(ErrorCode::InvalidRequest, "用户名过长")); + } + Ok(()) +} + +fn validate_password(password: &str) -> Result<(), AppError> { + if password.len() < 8 { + return Err(AppError::new(ErrorCode::InvalidRequest, "密码至少 8 位")); + } + if password.len() > 128 { + return Err(AppError::new(ErrorCode::InvalidRequest, "密码过长")); + } + Ok(()) +} + +fn hash_password(password: &str) -> Result { + let salt = argon2::password_hash::SaltString::generate(&mut rand::rngs::OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map_err(|err| AppError::new(ErrorCode::Internal, "密码哈希失败").with_source(err))? + .to_string() + .pipe(Ok) +} + +fn verify_password(password: &str, password_hash: &str) -> Result<(), AppError> { + let parsed = PasswordHash::new(password_hash) + .map_err(|err| AppError::new(ErrorCode::Internal, "密码哈希格式错误").with_source(err))?; + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .map_err(|_| AppError::new(ErrorCode::Unauthorized, "账号或密码错误"))?; + Ok(()) +} + +fn generate_token() -> String { + let mut bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +fn sha256_hex(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + hex::encode(hasher.finalize()) +} + +fn map_unique_violation(err: sqlx::Error) -> AppError { + if let sqlx::Error::Database(db_err) = &err { + if let Some(code) = db_err.code() { + if code == "23505" { + return AppError::new(ErrorCode::InvalidRequest, "邮箱或用户名已存在"); + } + } + } + AppError::new(ErrorCode::Internal, "数据库操作失败").with_source(err) +} + +trait Pipe: Sized { + fn pipe(self, f: impl FnOnce(Self) -> T) -> T { + f(self) + } +} + +impl Pipe for T {} diff --git a/src/api/billing.rs b/src/api/billing.rs new file mode 100644 index 0000000..19e42d4 --- /dev/null +++ b/src/api/billing.rs @@ -0,0 +1,738 @@ +use crate::api::context; +use crate::api::envelope::Envelope; +use crate::error::{AppError, ErrorCode}; +use crate::services::billing; +use crate::services::idempotency; +use crate::services::settings; +use crate::state::AppState; + +use axum::extract::{ConnectInfo, State}; +use axum::http::HeaderMap; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use std::net::SocketAddr; +use uuid::Uuid; + +pub fn router() -> Router { + Router::new() + .route("/billing/plans", get(list_plans)) + .route("/billing/subscription", get(get_subscription)) + .route("/billing/usage", get(get_usage)) + .route("/billing/invoices", get(list_invoices)) + .route("/billing/checkout", post(create_checkout)) + .route("/billing/portal", post(create_portal)) +} + +#[derive(Debug, FromRow, Serialize)] +struct PlanView { + id: Uuid, + code: String, + name: String, + currency: String, + amount_cents: i32, + interval: String, + included_units_per_period: i32, + max_file_size_mb: i32, + max_files_per_batch: i32, + retention_days: i32, + features: serde_json::Value, +} + +#[derive(Debug, Serialize)] +struct PlansResponse { + plans: Vec, +} + +async fn list_plans(State(state): State) -> Result>, AppError> { + let plans = sqlx::query_as::<_, PlanView>( + r#" + SELECT + id, code, name, currency, amount_cents, interval, + included_units_per_period, max_file_size_mb, max_files_per_batch, retention_days, + features + FROM plans + WHERE is_active = true + ORDER BY amount_cents ASC + "#, + ) + .fetch_all(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))?; + + Ok(Json(Envelope { + success: true, + data: PlansResponse { plans }, + })) +} + +#[derive(Debug, Serialize)] +struct SubscriptionPlanView { + id: Uuid, + code: String, + name: String, + currency: String, + amount_cents: i32, + interval: String, + included_units_per_period: i32, + max_file_size_mb: i32, + max_files_per_batch: i32, + retention_days: i32, + features: serde_json::Value, +} + +#[derive(Debug, Serialize)] +struct SubscriptionView { + status: String, + current_period_start: DateTime, + current_period_end: DateTime, + cancel_at_period_end: bool, + plan: SubscriptionPlanView, +} + +#[derive(Debug, Serialize)] +struct SubscriptionResponse { + subscription: SubscriptionView, +} + +async fn get_subscription( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?; + + let user_id = match principal { + context::Principal::User { user_id, .. } => user_id, + _ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")), + }; + + #[derive(Debug, FromRow)] + struct SubRow { + status: String, + current_period_start: DateTime, + current_period_end: DateTime, + cancel_at_period_end: bool, + plan_id: Uuid, + plan_code: String, + plan_name: String, + currency: String, + amount_cents: i32, + interval: String, + included_units_per_period: i32, + max_file_size_mb: i32, + max_files_per_batch: i32, + retention_days: i32, + features: serde_json::Value, + } + + let sub = sqlx::query_as::<_, SubRow>( + r#" + SELECT + s.status::text AS status, + s.current_period_start, + s.current_period_end, + s.cancel_at_period_end, + p.id AS plan_id, + p.code AS plan_code, + p.name AS plan_name, + p.currency, + p.amount_cents, + p.interval, + p.included_units_per_period, + p.max_file_size_mb, + p.max_files_per_batch, + p.retention_days, + p.features + FROM subscriptions s + JOIN plans p ON p.id = s.plan_id + WHERE s.user_id = $1 + AND s.status IN ('active', 'trialing', 'past_due', 'canceled', 'incomplete') + ORDER BY s.current_period_end DESC + LIMIT 1 + "#, + ) + .bind(user_id) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询订阅失败").with_source(err))?; + + let (status, period_start, period_end, cancel_at_period_end, plan) = if let Some(sub) = sub { + ( + sub.status, + sub.current_period_start, + sub.current_period_end, + sub.cancel_at_period_end, + SubscriptionPlanView { + id: sub.plan_id, + code: sub.plan_code, + name: sub.plan_name, + currency: sub.currency, + amount_cents: sub.amount_cents, + interval: sub.interval, + included_units_per_period: sub.included_units_per_period, + max_file_size_mb: sub.max_file_size_mb, + max_files_per_batch: sub.max_files_per_batch, + retention_days: sub.retention_days, + features: sub.features, + }, + ) + } else { + let plan: PlanView = sqlx::query_as::<_, PlanView>( + r#" + SELECT + id, code, name, currency, amount_cents, interval, + included_units_per_period, max_file_size_mb, max_files_per_batch, retention_days, + features + FROM plans + WHERE code = 'free' + LIMIT 1 + "#, + ) + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "未找到 Free 套餐").with_source(err))?; + + let (start, end) = billing::current_month_period_utc8(Utc::now()); + ( + "free".to_string(), + start, + end, + false, + SubscriptionPlanView { + id: plan.id, + code: plan.code, + name: plan.name, + currency: plan.currency, + amount_cents: plan.amount_cents, + interval: plan.interval, + included_units_per_period: plan.included_units_per_period, + max_file_size_mb: plan.max_file_size_mb, + max_files_per_batch: plan.max_files_per_batch, + retention_days: plan.retention_days, + features: plan.features, + }, + ) + }; + + Ok(Json(Envelope { + success: true, + data: SubscriptionResponse { + subscription: SubscriptionView { + status, + current_period_start: period_start, + current_period_end: period_end, + cancel_at_period_end, + plan, + }, + }, + })) +} + +#[derive(Debug, Serialize)] +struct UsageResponse { + period_start: DateTime, + period_end: DateTime, + used_units: i32, + included_units: i32, + bonus_units: i32, + total_units: i32, + remaining_units: i32, +} + +async fn get_usage( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?; + + let user_id = match principal { + context::Principal::User { user_id, .. } => user_id, + _ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")), + }; + + let billing = billing::get_user_billing(&state, user_id).await?; + + #[derive(Debug, FromRow)] + struct UsageRow { + used_units: i32, + bonus_units: i32, + } + + let usage = sqlx::query_as::<_, UsageRow>( + r#" + SELECT used_units, bonus_units + FROM usage_periods + WHERE user_id = $1 AND period_start = $2 AND period_end = $3 + "#, + ) + .bind(user_id) + .bind(billing.period_start) + .bind(billing.period_end) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用量失败").with_source(err))? + .unwrap_or(UsageRow { + used_units: 0, + bonus_units: 0, + }); + + let included = billing.plan.included_units_per_period; + let total = included + usage.bonus_units; + let remaining = (total - usage.used_units).max(0); + + Ok(Json(Envelope { + success: true, + data: UsageResponse { + period_start: billing.period_start, + period_end: billing.period_end, + used_units: usage.used_units, + included_units: included, + bonus_units: usage.bonus_units, + total_units: total, + remaining_units: remaining, + }, + })) +} + +#[derive(Debug, Deserialize)] +struct PagingQuery { + page: Option, + limit: Option, +} + +#[derive(Debug, FromRow, Serialize)] +struct InvoiceView { + invoice_number: String, + status: String, + currency: String, + total_amount_cents: i32, + period_start: Option>, + period_end: Option>, + hosted_invoice_url: Option, + pdf_url: Option, + paid_at: Option>, + created_at: DateTime, +} + +#[derive(Debug, Serialize)] +struct InvoicesResponse { + invoices: Vec, + page: u32, + limit: u32, +} + +async fn list_invoices( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + axum::extract::Query(query): axum::extract::Query, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?; + + let user_id = match principal { + context::Principal::User { user_id, .. } => user_id, + _ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")), + }; + + let limit = query.limit.unwrap_or(20).clamp(1, 100); + let page = query.page.unwrap_or(1).max(1); + let offset = (page - 1) * limit; + + let invoices = sqlx::query_as::<_, InvoiceView>( + r#" + SELECT + invoice_number, + status::text AS status, + currency, + total_amount_cents, + period_start, + period_end, + hosted_invoice_url, + pdf_url, + paid_at, + created_at + FROM invoices + WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(user_id) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询发票失败").with_source(err))?; + + Ok(Json(Envelope { + success: true, + data: InvoicesResponse { + invoices, + page, + limit, + }, + })) +} + +#[derive(Debug, Deserialize)] +struct CheckoutRequest { + plan_id: Uuid, +} + +#[derive(Debug, Serialize, Deserialize)] +struct CheckoutResponse { + checkout_url: String, +} + +async fn create_checkout( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Json(req): Json, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?; + + let user_id = match principal { + context::Principal::User { user_id, .. } => user_id, + _ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")), + }; + + let idempotency_key = headers + .get("idempotency-key") + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string); + + let request_hash = idempotency_key.as_ref().map(|_| { + let plan = req.plan_id.to_string(); + idempotency::sha256_hex(&[b"billing_checkout", plan.as_bytes()]) + }); + + let mut idem_acquired = false; + if let (Some(idem), Some(request_hash)) = (idempotency_key.as_deref(), request_hash.as_deref()) { + match idempotency::begin( + &state, + idempotency::Scope::User(user_id), + idem, + request_hash, + state.config.idempotency_ttl_hours as i64, + ) + .await? + { + idempotency::BeginResult::Replay { response_body, .. } => { + let resp: CheckoutResponse = serde_json::from_value(response_body).map_err(|err| { + AppError::new(ErrorCode::Internal, "幂等结果解析失败").with_source(err) + })?; + return Ok(Json(Envelope { success: true, data: resp })); + } + idempotency::BeginResult::InProgress => { + if let Some((_status, body)) = idempotency::wait_for_replay( + &state, + idempotency::Scope::User(user_id), + idem, + request_hash, + 10_000, + ) + .await? + { + let resp: CheckoutResponse = serde_json::from_value(body).map_err(|err| { + AppError::new(ErrorCode::Internal, "幂等结果解析失败").with_source(err) + })?; + return Ok(Json(Envelope { success: true, data: resp })); + } + return Err(AppError::new( + ErrorCode::InvalidRequest, + "请求正在处理中,请稍后重试", + )); + } + idempotency::BeginResult::Acquired { .. } => { + idem_acquired = true; + } + } + } + + let session_result: Result = (async { + let stripe_secret = settings::get_stripe_secret(&state) + .await + .map_err(|err| err.with_source("stripe secret not configured"))?; + + #[derive(Debug, FromRow)] + struct PlanStripeRow { + stripe_price_id: Option, + amount_cents: i32, + is_active: bool, + } + + let plan = sqlx::query_as::<_, PlanStripeRow>( + r#" + SELECT stripe_price_id, amount_cents, is_active + FROM plans + WHERE id = $1 + "#, + ) + .bind(req.plan_id) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))? + .ok_or_else(|| AppError::new(ErrorCode::NotFound, "套餐不存在"))?; + + if !plan.is_active { + return Err(AppError::new(ErrorCode::Forbidden, "套餐不可用")); + } + let Some(price_id) = plan.stripe_price_id.filter(|v| !v.trim().is_empty()) else { + return Err(AppError::new(ErrorCode::InvalidRequest, "该套餐不可订阅")); + }; + if plan.amount_cents <= 0 { + return Err(AppError::new(ErrorCode::InvalidRequest, "该套餐不可订阅")); + } + + #[derive(Debug, FromRow)] + struct UserStripeRow { + email: String, + billing_customer_id: Option, + } + + let user = sqlx::query_as::<_, UserStripeRow>( + "SELECT email, billing_customer_id FROM users WHERE id = $1", + ) + .bind(user_id) + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?; + + let customer_id = if let Some(cus) = user.billing_customer_id { + cus + } else { + let cus = stripe_create_customer(&stripe_secret, &user.email, user_id).await?; + let _ = sqlx::query("UPDATE users SET billing_customer_id = $2 WHERE id = $1") + .bind(user_id) + .bind(&cus) + .execute(&state.db) + .await; + cus + }; + + let success_url = + format!("{}/dashboard/billing?checkout=success", state.config.public_base_url); + let cancel_url = format!("{}/pricing?checkout=cancel", state.config.public_base_url); + + stripe_create_checkout_session( + &stripe_secret, + &customer_id, + &price_id, + &success_url, + &cancel_url, + user_id, + ) + .await + }) + .await; + + match session_result { + Ok(session) => { + if let (Some(idem), Some(request_hash)) = + (idempotency_key.as_deref(), request_hash.as_deref()) + { + if idem_acquired { + let _ = idempotency::complete( + &state, + idempotency::Scope::User(user_id), + idem, + request_hash, + 200, + serde_json::to_value(&CheckoutResponse { + checkout_url: session.clone(), + }) + .unwrap_or(serde_json::Value::Null), + ) + .await; + } + } + + Ok(Json(Envelope { + success: true, + data: CheckoutResponse { + checkout_url: session, + }, + })) + } + Err(err) => { + if let (Some(idem), Some(request_hash)) = + (idempotency_key.as_deref(), request_hash.as_deref()) + { + if idem_acquired { + let _ = idempotency::abort( + &state, + idempotency::Scope::User(user_id), + idem, + request_hash, + ) + .await; + } + } + Err(err) + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct PortalResponse { + url: String, +} + +async fn create_portal( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?; + + let user_id = match principal { + context::Principal::User { user_id, .. } => user_id, + _ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")), + }; + + let stripe_secret = settings::get_stripe_secret(&state) + .await + .map_err(|err| err.with_source("stripe secret not configured"))?; + + let customer_id: Option = + sqlx::query_scalar("SELECT billing_customer_id FROM users WHERE id = $1") + .bind(user_id) + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?; + + let Some(customer_id) = customer_id.filter(|v| !v.trim().is_empty()) else { + return Err(AppError::new(ErrorCode::InvalidRequest, "未找到 Stripe Customer")); + }; + + let return_url = format!("{}/dashboard/billing", state.config.public_base_url); + let url = stripe_create_portal_session(&stripe_secret, &customer_id, &return_url).await?; + + Ok(Json(Envelope { + success: true, + data: PortalResponse { url }, + })) +} + +async fn stripe_create_customer(secret: &str, email: &str, user_id: Uuid) -> Result { + let resp: serde_json::Value = stripe_post_form( + secret, + "/v1/customers", + vec![ + ("email".to_string(), email.to_string()), + ("metadata[user_id]".to_string(), user_id.to_string()), + ], + ) + .await?; + + let id = resp + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppError::new(ErrorCode::Internal, "Stripe customer 创建失败"))?; + + Ok(id.to_string()) +} + +async fn stripe_create_checkout_session( + secret: &str, + customer_id: &str, + price_id: &str, + success_url: &str, + cancel_url: &str, + user_id: Uuid, +) -> Result { + let resp: serde_json::Value = stripe_post_form( + secret, + "/v1/checkout/sessions", + vec![ + ("mode".to_string(), "subscription".to_string()), + ("customer".to_string(), customer_id.to_string()), + ("line_items[0][price]".to_string(), price_id.to_string()), + ("line_items[0][quantity]".to_string(), "1".to_string()), + ("success_url".to_string(), success_url.to_string()), + ("cancel_url".to_string(), cancel_url.to_string()), + ("allow_promotion_codes".to_string(), "true".to_string()), + ("client_reference_id".to_string(), user_id.to_string()), + ("metadata[user_id]".to_string(), user_id.to_string()), + ], + ) + .await?; + + let url = resp + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppError::new(ErrorCode::Internal, "Stripe checkout 创建失败"))?; + + Ok(url.to_string()) +} + +async fn stripe_create_portal_session( + secret: &str, + customer_id: &str, + return_url: &str, +) -> Result { + let resp: serde_json::Value = stripe_post_form( + secret, + "/v1/billing_portal/sessions", + vec![ + ("customer".to_string(), customer_id.to_string()), + ("return_url".to_string(), return_url.to_string()), + ], + ) + .await?; + + let url = resp + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppError::new(ErrorCode::Internal, "Stripe portal 创建失败"))?; + + Ok(url.to_string()) +} + +async fn stripe_post_form( + secret: &str, + path: &str, + form: Vec<(String, String)>, +) -> Result { + let url = format!("https://api.stripe.com{path}"); + let client = reqwest::Client::new(); + + let resp = client + .post(url) + .bearer_auth(secret) + .form(&form) + .send() + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "Stripe 请求失败").with_source(err))?; + + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "Stripe 响应读取失败").with_source(err))?; + + if !status.is_success() { + tracing::error!(status = %status, body = %body, "Stripe API error"); + return Err(AppError::new(ErrorCode::Internal, "Stripe API 调用失败")); + } + + serde_json::from_str(&body) + .map_err(|err| AppError::new(ErrorCode::Internal, "Stripe 响应解析失败").with_source(err)) +} diff --git a/src/api/compress.rs b/src/api/compress.rs new file mode 100644 index 0000000..fdaddc0 --- /dev/null +++ b/src/api/compress.rs @@ -0,0 +1,1218 @@ +use crate::api::context; +use crate::api::envelope::Envelope; +use crate::error::{AppError, ErrorCode}; +use crate::services::billing; +use crate::services::billing::{BillingContext, Plan}; +use crate::services::compress; +use crate::services::compress::{CompressionLevel, ImageFmt}; +use crate::services::idempotency; +use crate::services::quota; +use crate::state::AppState; + +use axum::extract::{ConnectInfo, Multipart, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::IntoResponse; +use axum::routing::post; +use axum::{Json, Router}; +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sqlx::FromRow; +use std::net::{IpAddr, SocketAddr}; +use tokio::io::AsyncWriteExt; +use uuid::Uuid; + +pub fn router() -> Router { + Router::new() + .route("/compress", post(compress_json)) + .route("/compress/direct", post(compress_direct)) +} + +#[derive(Debug, Serialize, Deserialize)] +struct BillingView { + units_charged: i32, +} + +#[derive(Debug, Serialize, Deserialize)] +struct CompressResponse { + task_id: Uuid, + file_id: Uuid, + format_in: String, + format_out: String, + original_size: u64, + compressed_size: u64, + saved_bytes: u64, + saved_percent: f64, + download_url: String, + expires_at: DateTime, + billing: BillingView, +} + +#[derive(Debug)] +struct CompressRequest { + file_name: String, + file_bytes: Vec, + level: CompressionLevel, + compression_rate: Option, + output_format: Option, + max_width: Option, + max_height: Option, + preserve_metadata: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +struct DirectIdempotencyData { + file_id: Uuid, + format_out: String, + original_size: u64, + compressed_size: u64, + saved_bytes: u64, + saved_percent: f64, + #[serde(default = "default_units_charged")] + units_charged: i32, +} + +fn default_units_charged() -> i32 { + 1 +} + +async fn compress_json( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + mut multipart: Multipart, +) -> Result<(axum_extra::extract::cookie::CookieJar, Json>), AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (jar, principal) = context::authenticate(&state, jar, &headers, ip).await?; + + let req = parse_single_file_request(&mut multipart).await?; + + let format_in = compress::detect_format(&req.file_bytes)?; + if let Some(format_out) = req.output_format { + if format_out != format_in { + return Err(AppError::new( + ErrorCode::InvalidRequest, + "当前仅支持保持原图片格式", + )); + } + } + let format_out = format_in; + let effective_level = req + .compression_rate + .map(compress::rate_to_level) + .unwrap_or(req.level); + + let idempotency_key = headers + .get("idempotency-key") + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string); + let idempotency_scope = match &principal { + context::Principal::User { user_id, .. } => Some(idempotency::Scope::User(*user_id)), + context::Principal::ApiKey { api_key_id, .. } => Some(idempotency::Scope::ApiKey(*api_key_id)), + _ => None, + }; + + let mw = req.max_width.map(|v| v.to_string()).unwrap_or_default(); + let mh = req.max_height.map(|v| v.to_string()).unwrap_or_default(); + let preserve = if req.preserve_metadata { "1" } else { "0" }; + let request_hash = if idempotency_key.is_some() && idempotency_scope.is_some() { + let mut h = Sha256::new(); + h.update(b"compress_v1"); + h.update(&req.file_bytes); + h.update(req.file_name.as_bytes()); + h.update(effective_level.as_str().as_bytes()); + h.update(format_out.as_str().as_bytes()); + let rate_key = req + .compression_rate + .map(|v| v.to_string()) + .unwrap_or_default(); + h.update(rate_key.as_bytes()); + h.update(mw.as_bytes()); + h.update(mh.as_bytes()); + h.update(preserve.as_bytes()); + Some(hex::encode(h.finalize())) + } else { + None + }; + + let (retention, quota_ctx) = match &principal { + context::Principal::Anonymous { session_id } => { + enforce_file_limits_anonymous(&state, &req.file_bytes)?; + ( + Duration::hours(state.config.anon_retention_hours as i64), + QuotaContext::Anonymous { + session_id: session_id.clone(), + ip, + }, + ) + } + context::Principal::User { + user_id, + email_verified, + .. + } => { + if !email_verified { + return Err(AppError::new(ErrorCode::EmailNotVerified, "请先验证邮箱")); + } + let billing = billing::get_user_billing(&state, *user_id).await?; + enforce_file_limits_plan(&billing.plan, &req.file_bytes)?; + ( + Duration::days(billing.plan.retention_days as i64), + QuotaContext::User(billing), + ) + } + context::Principal::ApiKey { + user_id, + api_key_id, + email_verified, + .. + } => { + if !email_verified { + return Err(AppError::new(ErrorCode::EmailNotVerified, "请先验证邮箱")); + } + let billing = billing::get_user_billing(&state, *user_id).await?; + if !billing.plan.feature_api_enabled { + return Err(AppError::new(ErrorCode::Forbidden, "当前套餐未开通 API")); + } + enforce_file_limits_plan(&billing.plan, &req.file_bytes)?; + ( + Duration::days(billing.plan.retention_days as i64), + QuotaContext::ApiKey(billing, *api_key_id), + ) + } + }; + + let mut idem_acquired = false; + if let (Some(scope), Some(idem_key), Some(request_hash)) = ( + idempotency_scope, + idempotency_key.as_deref(), + request_hash.as_deref(), + ) { + match idempotency::begin( + &state, + scope, + idem_key, + request_hash, + state.config.idempotency_ttl_hours as i64, + ) + .await? + { + idempotency::BeginResult::Replay { response_body, .. } => { + let resp: CompressResponse = serde_json::from_value(response_body).map_err(|err| { + AppError::new(ErrorCode::Internal, "幂等结果解析失败").with_source(err) + })?; + return Ok((jar, Json(Envelope { success: true, data: resp }))); + } + idempotency::BeginResult::InProgress => { + if let Some((_status, body)) = idempotency::wait_for_replay( + &state, + scope, + idem_key, + request_hash, + 10_000, + ) + .await? + { + let resp: CompressResponse = serde_json::from_value(body).map_err(|err| { + AppError::new(ErrorCode::Internal, "幂等结果解析失败").with_source(err) + })?; + return Ok((jar, Json(Envelope { success: true, data: resp }))); + } + return Err(AppError::new( + ErrorCode::InvalidRequest, + "请求正在处理中,请稍后重试", + )); + } + idempotency::BeginResult::Acquired { .. } => { + idem_acquired = true; + } + } + } + + let op: Result = (async { + match "a_ctx { + QuotaContext::User(billing) => ensure_quota_available(&state, billing, 1).await?, + QuotaContext::ApiKey(billing, _) => ensure_quota_available(&state, billing, 1).await?, + QuotaContext::Anonymous { .. } => {} + } + + let compressed = compress::compress_image_bytes( + &state, + &req.file_bytes, + format_in, + format_out, + effective_level, + req.compression_rate, + req.max_width, + req.max_height, + req.preserve_metadata, + ) + .await?; + + let original_size = req.file_bytes.len() as u64; + let compressed_size = compressed.len() as u64; + let saved_bytes = original_size.saturating_sub(compressed_size); + let saved_percent = if original_size == 0 { + 0.0 + } else { + (saved_bytes as f64) * 100.0 / (original_size as f64) + }; + let charge_units = compressed_size < original_size; + + if charge_units { + if let QuotaContext::Anonymous { session_id, ip } = "a_ctx { + quota::consume_anonymous_units(&state, session_id, *ip, 1).await?; + } + } + + if state.config.storage_type.to_ascii_lowercase() != "local" { + return Err(AppError::new( + ErrorCode::StorageUnavailable, + "当前仅支持本地存储(STORAGE_TYPE=local)", + )); + } + + tokio::fs::create_dir_all(&state.config.storage_path) + .await + .map_err(|err| { + AppError::new(ErrorCode::StorageUnavailable, "创建存储目录失败").with_source(err) + })?; + + let task_id = Uuid::new_v4(); + let file_id = Uuid::new_v4(); + let file_path = format!( + "{}/{}.{}", + state.config.storage_path, + file_id, + format_out.extension() + ); + + write_file(&file_path, &compressed).await?; + + let expires_at = Utc::now() + retention; + + if let Err(err) = record_task_and_metering( + &state, + &principal, + ip, + task_id, + file_id, + &file_path, + &req.file_name, + req.max_width, + req.max_height, + effective_level, + req.compression_rate, + format_in, + format_out, + original_size, + compressed_size, + saved_percent, + expires_at, + "a_ctx, + charge_units, + ) + .await + { + let _ = tokio::fs::remove_file(&file_path).await; + return Err(err); + } + + Ok(CompressResponse { + task_id, + file_id, + format_in: format_in.as_str().to_string(), + format_out: format_out.as_str().to_string(), + original_size, + compressed_size, + saved_bytes, + saved_percent, + download_url: format!("/downloads/{file_id}"), + expires_at, + billing: BillingView { + units_charged: if charge_units { 1 } else { 0 }, + }, + }) + }) + .await; + + match op { + Ok(resp) => { + if let (Some(scope), Some(idem_key), Some(request_hash)) = ( + idempotency_scope, + idempotency_key.as_deref(), + request_hash.as_deref(), + ) { + if idem_acquired { + let _ = idempotency::complete( + &state, + scope, + idem_key, + request_hash, + 200, + serde_json::to_value(&resp).unwrap_or(serde_json::Value::Null), + ) + .await; + } + } + Ok((jar, Json(Envelope { success: true, data: resp }))) + } + Err(err) => { + if let (Some(scope), Some(idem_key), Some(request_hash)) = ( + idempotency_scope, + idempotency_key.as_deref(), + request_hash.as_deref(), + ) { + if idem_acquired { + let _ = idempotency::abort(&state, scope, idem_key, request_hash).await; + } + } + Err(err) + } + } +} + +async fn compress_direct( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + mut multipart: Multipart, +) -> Result<(axum_extra::extract::cookie::CookieJar, axum::response::Response), AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (jar, principal) = context::authenticate(&state, jar, &headers, ip).await?; + + if matches!(principal, context::Principal::Anonymous { .. }) { + return Err(AppError::new( + ErrorCode::Unauthorized, + "对外 API 不支持匿名调用", + )); + } + + let req = parse_single_file_request(&mut multipart).await?; + + let email_verified = match &principal { + context::Principal::User { email_verified, .. } => *email_verified, + context::Principal::ApiKey { email_verified, .. } => *email_verified, + context::Principal::Anonymous { .. } => false, + }; + if !email_verified { + return Err(AppError::new(ErrorCode::EmailNotVerified, "请先验证邮箱")); + } + + let format_in = compress::detect_format(&req.file_bytes)?; + if let Some(format_out) = req.output_format { + if format_out != format_in { + return Err(AppError::new( + ErrorCode::InvalidRequest, + "当前仅支持保持原图片格式", + )); + } + } + let format_out = format_in; + let effective_level = req + .compression_rate + .map(compress::rate_to_level) + .unwrap_or(req.level); + + let idempotency_key = headers + .get("idempotency-key") + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string); + let idempotency_scope = match &principal { + context::Principal::User { user_id, .. } => Some(idempotency::Scope::User(*user_id)), + context::Principal::ApiKey { api_key_id, .. } => Some(idempotency::Scope::ApiKey(*api_key_id)), + context::Principal::Anonymous { .. } => None, + }; + + let mw = req.max_width.map(|v| v.to_string()).unwrap_or_default(); + let mh = req.max_height.map(|v| v.to_string()).unwrap_or_default(); + let preserve = if req.preserve_metadata { "1" } else { "0" }; + let request_hash = if idempotency_key.is_some() && idempotency_scope.is_some() { + let mut h = Sha256::new(); + h.update(b"compress_direct_v1"); + h.update(&req.file_bytes); + h.update(req.file_name.as_bytes()); + h.update(effective_level.as_str().as_bytes()); + h.update(format_out.as_str().as_bytes()); + let rate_key = req + .compression_rate + .map(|v| v.to_string()) + .unwrap_or_default(); + h.update(rate_key.as_bytes()); + h.update(mw.as_bytes()); + h.update(mh.as_bytes()); + h.update(preserve.as_bytes()); + Some(hex::encode(h.finalize())) + } else { + None + }; + + let (retention, quota_ctx) = match &principal { + context::Principal::User { user_id, .. } => { + let billing = billing::get_user_billing(&state, *user_id).await?; + enforce_file_limits_plan(&billing.plan, &req.file_bytes)?; + ( + Duration::days(billing.plan.retention_days as i64), + QuotaContext::User(billing), + ) + } + context::Principal::ApiKey { + user_id, + api_key_id, + .. + } => { + let billing = billing::get_user_billing(&state, *user_id).await?; + if !billing.plan.feature_api_enabled { + return Err(AppError::new(ErrorCode::Forbidden, "当前套餐未开通 API")); + } + enforce_file_limits_plan(&billing.plan, &req.file_bytes)?; + ( + Duration::days(billing.plan.retention_days as i64), + QuotaContext::ApiKey(billing, *api_key_id), + ) + } + context::Principal::Anonymous { .. } => unreachable!(), + }; + + let mut idem_acquired = false; + if let (Some(scope), Some(idem_key), Some(request_hash)) = ( + idempotency_scope, + idempotency_key.as_deref(), + request_hash.as_deref(), + ) { + match idempotency::begin( + &state, + scope, + idem_key, + request_hash, + state.config.idempotency_ttl_hours as i64, + ) + .await? + { + idempotency::BeginResult::Replay { response_body, .. } => { + let data: DirectIdempotencyData = + serde_json::from_value(response_body).map_err(|err| { + AppError::new(ErrorCode::Internal, "幂等结果解析失败").with_source(err) + })?; + let (bytes, fmt) = load_direct_replay_bytes(&state, &principal, data.file_id).await?; + + let mut resp_headers = HeaderMap::new(); + resp_headers.insert( + axum::http::header::CONTENT_TYPE, + fmt.content_type().parse().unwrap(), + ); + resp_headers.insert( + "ImageForge-Original-Size", + data.original_size.to_string().parse().unwrap(), + ); + resp_headers.insert( + "ImageForge-Compressed-Size", + data.compressed_size.to_string().parse().unwrap(), + ); + resp_headers.insert( + "ImageForge-Saved-Bytes", + data.saved_bytes.to_string().parse().unwrap(), + ); + resp_headers.insert( + "ImageForge-Saved-Percent", + format!("{:.2}", data.saved_percent).parse().unwrap(), + ); + resp_headers.insert( + "ImageForge-Units-Charged", + data.units_charged.to_string().parse().unwrap(), + ); + + let response = (StatusCode::OK, resp_headers, bytes).into_response(); + return Ok((jar, response)); + } + idempotency::BeginResult::InProgress => { + if let Some((_status, body)) = idempotency::wait_for_replay( + &state, + scope, + idem_key, + request_hash, + 10_000, + ) + .await? + { + let data: DirectIdempotencyData = + serde_json::from_value(body).map_err(|err| { + AppError::new(ErrorCode::Internal, "幂等结果解析失败").with_source(err) + })?; + let (bytes, fmt) = + load_direct_replay_bytes(&state, &principal, data.file_id).await?; + + let mut resp_headers = HeaderMap::new(); + resp_headers.insert( + axum::http::header::CONTENT_TYPE, + fmt.content_type().parse().unwrap(), + ); + resp_headers.insert( + "ImageForge-Original-Size", + data.original_size.to_string().parse().unwrap(), + ); + resp_headers.insert( + "ImageForge-Compressed-Size", + data.compressed_size.to_string().parse().unwrap(), + ); + resp_headers.insert( + "ImageForge-Saved-Bytes", + data.saved_bytes.to_string().parse().unwrap(), + ); + resp_headers.insert( + "ImageForge-Saved-Percent", + format!("{:.2}", data.saved_percent).parse().unwrap(), + ); + resp_headers.insert( + "ImageForge-Units-Charged", + data.units_charged.to_string().parse().unwrap(), + ); + + let response = (StatusCode::OK, resp_headers, bytes).into_response(); + return Ok((jar, response)); + } + return Err(AppError::new( + ErrorCode::InvalidRequest, + "请求正在处理中,请稍后重试", + )); + } + idempotency::BeginResult::Acquired { .. } => { + idem_acquired = true; + } + } + } + + let op: Result<(axum::response::Response, DirectIdempotencyData), AppError> = (async { + match "a_ctx { + QuotaContext::User(billing) => ensure_quota_available(&state, billing, 1).await?, + QuotaContext::ApiKey(billing, _) => ensure_quota_available(&state, billing, 1).await?, + QuotaContext::Anonymous { .. } => {} + } + + let compressed = compress::compress_image_bytes( + &state, + &req.file_bytes, + format_in, + format_out, + effective_level, + req.compression_rate, + req.max_width, + req.max_height, + req.preserve_metadata, + ) + .await?; + + let original_size = req.file_bytes.len() as u64; + let compressed_size = compressed.len() as u64; + let saved_bytes = original_size.saturating_sub(compressed_size); + let saved_percent = if original_size == 0 { + 0.0 + } else { + (saved_bytes as f64) * 100.0 / (original_size as f64) + }; + let charge_units = compressed_size < original_size; + + if state.config.storage_type.to_ascii_lowercase() != "local" { + return Err(AppError::new( + ErrorCode::StorageUnavailable, + "当前仅支持本地存储(STORAGE_TYPE=local)", + )); + } + tokio::fs::create_dir_all(&state.config.storage_path) + .await + .map_err(|err| { + AppError::new(ErrorCode::StorageUnavailable, "创建存储目录失败").with_source(err) + })?; + + let task_id = Uuid::new_v4(); + let file_id = Uuid::new_v4(); + let file_path = format!( + "{}/{}.{}", + state.config.storage_path, + file_id, + format_out.extension() + ); + write_file(&file_path, &compressed).await?; + + let expires_at = Utc::now() + retention; + + if let Err(err) = record_task_and_metering( + &state, + &principal, + ip, + task_id, + file_id, + &file_path, + &req.file_name, + req.max_width, + req.max_height, + effective_level, + req.compression_rate, + format_in, + format_out, + original_size, + compressed_size, + saved_percent, + expires_at, + "a_ctx, + charge_units, + ) + .await + { + let _ = tokio::fs::remove_file(&file_path).await; + return Err(err); + } + + let mut resp_headers = HeaderMap::new(); + resp_headers.insert( + axum::http::header::CONTENT_TYPE, + format_out.content_type().parse().unwrap(), + ); + resp_headers.insert( + "ImageForge-Original-Size", + original_size.to_string().parse().unwrap(), + ); + resp_headers.insert( + "ImageForge-Compressed-Size", + compressed_size.to_string().parse().unwrap(), + ); + resp_headers.insert("ImageForge-Saved-Bytes", saved_bytes.to_string().parse().unwrap()); + resp_headers.insert( + "ImageForge-Saved-Percent", + format!("{saved_percent:.2}").parse().unwrap(), + ); + resp_headers.insert( + "ImageForge-Units-Charged", + if charge_units { "1" } else { "0" }.parse().unwrap(), + ); + + let response = (StatusCode::OK, resp_headers, compressed).into_response(); + Ok(( + response, + DirectIdempotencyData { + file_id, + format_out: format_out.as_str().to_string(), + original_size, + compressed_size, + saved_bytes, + saved_percent, + units_charged: if charge_units { 1 } else { 0 }, + }, + )) + }) + .await; + + match op { + Ok((response, idem_data)) => { + if let (Some(scope), Some(idem_key), Some(request_hash)) = ( + idempotency_scope, + idempotency_key.as_deref(), + request_hash.as_deref(), + ) { + if idem_acquired { + let _ = idempotency::complete( + &state, + scope, + idem_key, + request_hash, + 200, + serde_json::to_value(&idem_data).unwrap_or(serde_json::Value::Null), + ) + .await; + } + } + Ok((jar, response)) + } + Err(err) => { + if let (Some(scope), Some(idem_key), Some(request_hash)) = ( + idempotency_scope, + idempotency_key.as_deref(), + request_hash.as_deref(), + ) { + if idem_acquired { + let _ = idempotency::abort(&state, scope, idem_key, request_hash).await; + } + } + Err(err) + } + } +} + +async fn write_file(path: &str, bytes: &[u8]) -> Result<(), AppError> { + let mut file = tokio::fs::File::create(path) + .await + .map_err(|err| AppError::new(ErrorCode::StorageUnavailable, "写入文件失败").with_source(err))?; + file.write_all(bytes) + .await + .map_err(|err| AppError::new(ErrorCode::StorageUnavailable, "写入文件失败").with_source(err))?; + Ok(()) +} + +#[derive(Debug, FromRow)] +struct DirectReplayRow { + storage_path: Option, + output_format: String, + file_status: String, + expires_at: DateTime, +} + +async fn load_direct_replay_bytes( + state: &AppState, + principal: &context::Principal, + file_id: Uuid, +) -> Result<(Vec, ImageFmt), AppError> { + let row = match principal { + context::Principal::User { user_id, .. } => { + sqlx::query_as::<_, DirectReplayRow>( + r#" + SELECT + f.storage_path, + f.output_format, + f.status::text AS file_status, + t.expires_at + FROM task_files f + JOIN tasks t ON t.id = f.task_id + WHERE f.id = $1 AND t.user_id = $2 + "#, + ) + .bind(file_id) + .bind(user_id) + .fetch_optional(&state.db) + .await + } + context::Principal::ApiKey { api_key_id, .. } => { + sqlx::query_as::<_, DirectReplayRow>( + r#" + SELECT + f.storage_path, + f.output_format, + f.status::text AS file_status, + t.expires_at + FROM task_files f + JOIN tasks t ON t.id = f.task_id + WHERE f.id = $1 AND t.api_key_id = $2 + "#, + ) + .bind(file_id) + .bind(api_key_id) + .fetch_optional(&state.db) + .await + } + context::Principal::Anonymous { .. } => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")), + } + .map_err(|err| AppError::new(ErrorCode::Internal, "查询文件失败").with_source(err))? + .ok_or_else(|| AppError::new(ErrorCode::NotFound, "文件不存在"))?; + + if row.expires_at <= Utc::now() { + return Err(AppError::new(ErrorCode::NotFound, "文件已过期或不存在")); + } + if row.file_status != "completed" { + return Err(AppError::new(ErrorCode::NotFound, "文件不存在")); + } + let Some(path) = row.storage_path else { + return Err(AppError::new(ErrorCode::NotFound, "文件不存在")); + }; + + let bytes = tokio::fs::read(&path) + .await + .map_err(|err| AppError::new(ErrorCode::StorageUnavailable, "读取文件失败").with_source(err))?; + + let fmt = compress::parse_output_format(&row.output_format)?; + Ok((bytes, fmt)) +} + +async fn parse_single_file_request(multipart: &mut Multipart) -> Result { + let mut file_bytes: Option> = None; + let mut file_name: Option = None; + let mut level = CompressionLevel::Medium; + let mut output_format: Option = None; + let mut compression_rate: Option = None; + let mut max_width: Option = None; + let mut max_height: Option = None; + let mut preserve_metadata = false; + + while let Some(field) = multipart + .next_field() + .await + .map_err(|err| AppError::new(ErrorCode::InvalidRequest, "读取上传内容失败").with_source(err))? + { + let name = field.name().unwrap_or("").to_string(); + if name == "file" { + file_name = Some( + field + .file_name() + .unwrap_or("upload") + .to_string(), + ); + let bytes = field + .bytes() + .await + .map_err(|err| AppError::new(ErrorCode::InvalidRequest, "读取文件失败").with_source(err))?; + file_bytes = Some(bytes.to_vec()); + continue; + } + + let text = field + .text() + .await + .map_err(|err| AppError::new(ErrorCode::InvalidRequest, "读取字段失败").with_source(err))?; + + match name.as_str() { + "level" => { + level = compress::parse_level(&text)?; + } + "output_format" => { + let v = text.trim(); + if !v.is_empty() { + output_format = Some(compress::parse_output_format(v)?); + } + } + "compression_rate" | "quality" => { + let v = text.trim(); + if !v.is_empty() { + compression_rate = Some(compress::parse_compression_rate(v)?); + } + } + "max_width" => { + let v = text.trim(); + if !v.is_empty() { + max_width = Some( + v.parse::() + .map_err(|_| AppError::new(ErrorCode::InvalidRequest, "max_width 格式错误"))?, + ); + } + } + "max_height" => { + let v = text.trim(); + if !v.is_empty() { + max_height = Some( + v.parse::() + .map_err(|_| AppError::new(ErrorCode::InvalidRequest, "max_height 格式错误"))?, + ); + } + } + "preserve_metadata" => { + preserve_metadata = matches!( + text.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "y" | "on" + ); + } + _ => {} + } + } + + let file_bytes = file_bytes.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "缺少 file"))?; + let file_name = file_name.unwrap_or_else(|| "upload".to_string()); + + Ok(CompressRequest { + file_name, + file_bytes, + level, + compression_rate, + output_format, + max_width, + max_height, + preserve_metadata, + }) +} + +fn enforce_file_limits_anonymous(state: &AppState, bytes: &[u8]) -> Result<(), AppError> { + let max = state.config.anon_max_file_size_mb * 1024 * 1024; + if bytes.len() as u64 > max { + return Err(AppError::new( + ErrorCode::FileTooLarge, + format!("匿名试用单文件最大 {} MB", state.config.anon_max_file_size_mb), + )); + } + Ok(()) +} + +fn enforce_file_limits_plan(plan: &Plan, bytes: &[u8]) -> Result<(), AppError> { + let max = (plan.max_file_size_mb as u64) * 1024 * 1024; + if bytes.len() as u64 > max { + return Err(AppError::new( + ErrorCode::FileTooLarge, + format!("当前套餐单文件最大 {} MB", plan.max_file_size_mb), + )); + } + Ok(()) +} + +#[derive(Debug, Clone)] +enum QuotaContext { + Anonymous { session_id: String, ip: IpAddr }, + User(BillingContext), + ApiKey(BillingContext, Uuid), +} + +async fn ensure_quota_available( + state: &AppState, + ctx: &BillingContext, + needed_units: i32, +) -> Result<(), AppError> { + if needed_units <= 0 { + return Ok(()); + } + + #[derive(Debug, FromRow)] + struct UsageRow { + used_units: i32, + bonus_units: i32, + } + + let usage = sqlx::query_as::<_, UsageRow>( + r#" + SELECT used_units, bonus_units + FROM usage_periods + WHERE user_id = $1 AND period_start = $2 AND period_end = $3 + "#, + ) + .bind(ctx.user_id) + .bind(ctx.period_start) + .bind(ctx.period_end) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用量失败").with_source(err))? + .unwrap_or(UsageRow { + used_units: 0, + bonus_units: 0, + }); + + let total_units = ctx.plan.included_units_per_period + usage.bonus_units; + let remaining = total_units - usage.used_units; + if remaining < needed_units { + return Err(AppError::new(ErrorCode::QuotaExceeded, "当期配额已用完")); + } + + Ok(()) +} + +async fn record_task_and_metering( + state: &AppState, + principal: &context::Principal, + client_ip: IpAddr, + task_id: Uuid, + file_id: Uuid, + file_path: &str, + original_name: &str, + max_width: Option, + max_height: Option, + level: CompressionLevel, + compression_rate: Option, + format_in: ImageFmt, + format_out: ImageFmt, + original_size: u64, + compressed_size: u64, + saved_percent: f64, + expires_at: DateTime, + quota_ctx: &QuotaContext, + charge_units: bool, +) -> Result<(), AppError> { + let (user_id, session_id, api_key_id, source) = match principal { + context::Principal::Anonymous { session_id } => (None, Some(session_id.clone()), None, "web"), + context::Principal::User { user_id, .. } => (Some(*user_id), None, None, "web"), + context::Principal::ApiKey { user_id, api_key_id, .. } => (Some(*user_id), None, Some(*api_key_id), "api"), + }; + + let mut tx = state + .db + .begin() + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?; + + sqlx::query( + r#" + INSERT INTO tasks ( + id, user_id, session_id, api_key_id, client_ip, source, status, + compression_level, output_format, max_width, max_height, preserve_metadata, + compression_rate, + total_files, completed_files, failed_files, + total_original_size, total_compressed_size, + started_at, completed_at, expires_at + ) VALUES ( + $1, $2, $3, $4, $5::inet, $6::task_source, 'completed', + $7::compression_level, $8, $9, $10, $11, $12, + 1, 1, 0, + $13, $14, + NOW(), NOW(), $15 + ) + "#, + ) + .bind(task_id) + .bind(user_id) + .bind(session_id) + .bind(api_key_id) + .bind(client_ip.to_string()) + .bind(source) + .bind(level.as_str()) + .bind(format_out.as_str()) + .bind(max_width.map(|v| v as i32)) + .bind(max_height.map(|v| v as i32)) + .bind(false) + .bind(compression_rate.map(|v| v as i16)) + .bind(original_size as i64) + .bind(compressed_size as i64) + .bind(expires_at) + .execute(&mut *tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "创建任务失败").with_source(err))?; + + sqlx::query( + r#" + INSERT INTO task_files ( + id, task_id, + original_name, original_format, output_format, + original_size, compressed_size, saved_percent, + storage_path, status, completed_at + ) VALUES ( + $1, $2, + $3, $4, $5, + $6, $7, $8, + $9, 'completed', NOW() + ) + "#, + ) + .bind(file_id) + .bind(task_id) + .bind(original_name) + .bind(format_in.as_str()) + .bind(format_out.as_str()) + .bind(original_size as i64) + .bind(compressed_size as i64) + .bind(saved_percent) + .bind(file_path) + .execute(&mut *tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "创建文件记录失败").with_source(err))?; + + match quota_ctx { + QuotaContext::Anonymous { .. } => {} + QuotaContext::User(billing) => { + if charge_units { + charge_one_unit( + &mut tx, + billing, + None, + task_id, + file_id, + format_in, + format_out, + original_size, + compressed_size, + ) + .await?; + } + } + QuotaContext::ApiKey(billing, api_key_id) => { + if charge_units { + charge_one_unit( + &mut tx, + billing, + Some(*api_key_id), + task_id, + file_id, + format_in, + format_out, + original_size, + compressed_size, + ) + .await?; + } + } + } + + tx.commit() + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?; + + Ok(()) +} + +async fn charge_one_unit( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + billing: &BillingContext, + api_key_id: Option, + task_id: Uuid, + task_file_id: Uuid, + format_in: ImageFmt, + format_out: ImageFmt, + bytes_in: u64, + bytes_out: u64, +) -> Result<(), AppError> { + sqlx::query( + r#" + INSERT INTO usage_periods (user_id, subscription_id, period_start, period_end) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, period_start, period_end) DO NOTHING + "#, + ) + .bind(billing.user_id) + .bind(billing.subscription_id) + .bind(billing.period_start) + .bind(billing.period_end) + .execute(&mut **tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "初始化用量周期失败").with_source(err))?; + + let updated: Option = sqlx::query_scalar( + r#" + UPDATE usage_periods + SET used_units = used_units + 1, + bytes_in = bytes_in + $1, + bytes_out = bytes_out + $2, + updated_at = NOW() + WHERE user_id = $3 + AND period_start = $4 + AND period_end = $5 + AND used_units + 1 <= $6 + bonus_units + RETURNING used_units + "#, + ) + .bind(bytes_in as i64) + .bind(bytes_out as i64) + .bind(billing.user_id) + .bind(billing.period_start) + .bind(billing.period_end) + .bind(billing.plan.included_units_per_period) + .fetch_optional(&mut **tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "扣减配额失败").with_source(err))?; + + if updated.is_none() { + return Err(AppError::new(ErrorCode::QuotaExceeded, "当期配额已用完")); + } + + sqlx::query( + r#" + INSERT INTO usage_events ( + user_id, api_key_id, source, + task_id, task_file_id, + units, bytes_in, bytes_out, format_in, format_out + ) VALUES ( + $1, $2, $3::task_source, + $4, $5, + 1, $6, $7, $8, $9 + ) + "#, + ) + .bind(billing.user_id) + .bind(api_key_id) + .bind(if api_key_id.is_some() { "api" } else { "web" }) + .bind(task_id) + .bind(task_file_id) + .bind(bytes_in as i64) + .bind(bytes_out as i64) + .bind(format_in.as_str()) + .bind(format_out.as_str()) + .execute(&mut **tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "写入用量明细失败").with_source(err))?; + + Ok(()) +} diff --git a/src/api/context.rs b/src/api/context.rs new file mode 100644 index 0000000..c54a392 --- /dev/null +++ b/src/api/context.rs @@ -0,0 +1,229 @@ +use crate::auth; +use crate::error::{AppError, ErrorCode}; +use crate::state::AppState; + +use axum::http::HeaderMap; +use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{DateTime, Utc}; +use hmac::{Hmac, Mac}; +use rand::RngCore; +use serde::Serialize; +use sha2::Sha256; +use sqlx::FromRow; +use std::net::IpAddr; +use time::Duration as TimeDuration; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Principal { + Anonymous { session_id: String }, + User { user_id: Uuid, role: String, email_verified: bool }, + ApiKey { + user_id: Uuid, + api_key_id: Uuid, + role: String, + email_verified: bool, + }, +} + +pub fn client_ip(headers: &HeaderMap, connect_ip: IpAddr) -> IpAddr { + if let Some(ip) = parse_forwarded_for(headers) { + return ip; + } + if let Some(ip) = headers + .get("x-real-ip") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()) + { + return ip; + } + connect_ip +} + +fn parse_forwarded_for(headers: &HeaderMap) -> Option { + let value = headers.get("x-forwarded-for")?.to_str().ok()?; + value + .split(',') + .next() + .map(str::trim) + .filter(|s| !s.is_empty()) + .and_then(|s| s.parse::().ok()) +} + +pub async fn authenticate( + state: &AppState, + jar: CookieJar, + headers: &HeaderMap, + ip: IpAddr, +) -> Result<(CookieJar, Principal), AppError> { + if let Some(principal) = try_jwt(state, headers).await? { + return Ok((jar, principal)); + } + if let Some(principal) = try_api_key(state, headers, ip).await? { + return Ok((jar, principal)); + } + + if !state.config.allow_anonymous_upload { + return Err(AppError::new(ErrorCode::Unauthorized, "未登录")); + } + + let (jar, session_id) = ensure_session_cookie(jar); + Ok((jar, Principal::Anonymous { session_id })) +} + +async fn try_jwt(state: &AppState, headers: &HeaderMap) -> Result, AppError> { + let auth_header = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if !auth_header.starts_with("Bearer ") { + return Ok(None); + } + + let claims = auth::require_jwt(&state.config.jwt_secret, headers)?; + + #[derive(Debug, FromRow)] + struct UserAuthRow { + id: Uuid, + role: String, + is_active: bool, + email_verified_at: Option>, + } + + let user = sqlx::query_as::<_, UserAuthRow>( + r#" + SELECT id, role::text AS role, is_active, email_verified_at + FROM users + WHERE id = $1 + "#, + ) + .bind(claims.sub) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))? + .ok_or_else(|| AppError::new(ErrorCode::Unauthorized, "用户不存在或未登录"))?; + + if !user.is_active { + return Err(AppError::new(ErrorCode::Forbidden, "账号已被禁用")); + } + + Ok(Some(Principal::User { + user_id: user.id, + role: user.role, + email_verified: user.email_verified_at.is_some(), + })) +} + +async fn try_api_key( + state: &AppState, + headers: &HeaderMap, + ip: IpAddr, +) -> Result, AppError> { + let key = headers + .get("x-api-key") + .or_else(|| headers.get("X-API-Key")) + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|v| !v.is_empty()); + + let Some(full_key) = key else { + return Ok(None); + }; + + let key_prefix = full_key + .get(0..16) + .ok_or_else(|| AppError::new(ErrorCode::Unauthorized, "API Key 格式错误"))?; + + #[derive(Debug, FromRow)] + struct ApiKeyAuthRow { + id: Uuid, + user_id: Uuid, + key_hash: String, + is_active: bool, + user_role: String, + user_is_active: bool, + email_verified_at: Option>, + } + + let row = sqlx::query_as::<_, ApiKeyAuthRow>( + r#" + SELECT + k.id, + k.user_id, + k.key_hash, + k.is_active, + u.role::text AS user_role, + u.is_active AS user_is_active, + u.email_verified_at + FROM api_keys k + JOIN users u ON u.id = k.user_id + WHERE k.key_prefix = $1 + "#, + ) + .bind(key_prefix) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询 API Key 失败").with_source(err))? + .ok_or_else(|| AppError::new(ErrorCode::Unauthorized, "API Key 无效"))?; + + if !row.user_is_active || !row.is_active { + return Err(AppError::new(ErrorCode::Forbidden, "API Key 已禁用")); + } + + let expected = api_key_hash(full_key, &state.config.api_key_pepper)?; + if expected != row.key_hash { + return Err(AppError::new(ErrorCode::Unauthorized, "API Key 无效")); + } + + let _ = sqlx::query("UPDATE api_keys SET last_used_at = NOW(), last_used_ip = $2 WHERE id = $1") + .bind(row.id) + .bind(ip.to_string()) + .execute(&state.db) + .await; + + Ok(Some(Principal::ApiKey { + user_id: row.user_id, + api_key_id: row.id, + role: row.user_role, + email_verified: row.email_verified_at.is_some(), + })) +} + +pub fn ensure_session_cookie(jar: CookieJar) -> (CookieJar, String) { + if let Some(cookie) = jar.get("if_session") { + let session_id = cookie.value().trim().to_string(); + if !session_id.is_empty() { + return (jar, session_id); + } + } + + let session_id = generate_session_id(); + let cookie = Cookie::build(("if_session", session_id.clone())) + .path("/") + .http_only(true) + .same_site(SameSite::Lax) + .max_age(TimeDuration::days(7)) + .build(); + + (jar.add(cookie), session_id) +} + +fn generate_session_id() -> String { + let mut bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +pub fn api_key_hash(full_key: &str, pepper: &str) -> Result { + type HmacSha256 = Hmac; + + let mut mac = HmacSha256::new_from_slice(pepper.as_bytes()) + .map_err(|err| AppError::new(ErrorCode::Internal, "API Key pepper 错误").with_source(err))?; + mac.update(full_key.as_bytes()); + let result = mac.finalize().into_bytes(); + Ok(hex::encode(result)) +} + diff --git a/src/api/downloads.rs b/src/api/downloads.rs new file mode 100644 index 0000000..fc5d821 --- /dev/null +++ b/src/api/downloads.rs @@ -0,0 +1,343 @@ +use crate::api::context; +use crate::error::{AppError, ErrorCode}; +use crate::state::AppState; + +use axum::extract::{ConnectInfo, Path, State}; +use axum::http::{header, HeaderMap}; +use axum::body::Body; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use axum::Router; +use chrono::{DateTime, Utc}; +use sqlx::FromRow; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::path::PathBuf; +use tokio_util::io::ReaderStream; +use uuid::Uuid; + +pub fn router() -> Router { + Router::new() + .route("/tasks/{task_id}", get(download_task_zip)) + .route("/{file_id}", get(download_file)) +} + +#[derive(Debug, FromRow)] +struct DownloadRow { + storage_path: Option, + output_format: String, + original_name: String, + file_status: String, + task_user_id: Option, + task_session_id: Option, + expires_at: DateTime, +} + +async fn download_file( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Path(file_id): Path, +) -> Result<(axum_extra::extract::cookie::CookieJar, Response), AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (jar, principal) = context::authenticate(&state, jar, &headers, ip).await?; + + let row = sqlx::query_as::<_, DownloadRow>( + r#" + SELECT + f.storage_path, + f.output_format, + f.original_name, + f.status::text AS file_status, + t.user_id AS task_user_id, + t.session_id AS task_session_id, + t.expires_at + FROM task_files f + JOIN tasks t ON t.id = f.task_id + WHERE f.id = $1 + "#, + ) + .bind(file_id) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询文件失败").with_source(err))? + .ok_or_else(|| AppError::new(ErrorCode::NotFound, "文件不存在"))?; + + if row.expires_at <= Utc::now() { + return Err(AppError::new(ErrorCode::NotFound, "文件已过期或不存在")); + } + + if row.file_status != "completed" { + return Err(AppError::new(ErrorCode::NotFound, "文件不存在")); + } + + authorize_download(&principal, &row)?; + + let Some(path) = &row.storage_path else { + return Err(AppError::new(ErrorCode::NotFound, "文件不存在")); + }; + + let bytes = tokio::fs::read(path) + .await + .map_err(|err| AppError::new(ErrorCode::StorageUnavailable, "读取文件失败").with_source(err))?; + + let mut resp_headers = HeaderMap::new(); + resp_headers.insert( + header::CONTENT_TYPE, + content_type(&row.output_format).parse().unwrap(), + ); + resp_headers.insert( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{}\"", sanitize_filename(&row.original_name)) + .parse() + .unwrap(), + ); + + Ok((jar, (resp_headers, bytes).into_response())) +} + +fn authorize_download(principal: &context::Principal, row: &DownloadRow) -> Result<(), AppError> { + if let Some(user_id) = row.task_user_id { + match principal { + context::Principal::User { user_id: me, .. } if *me == user_id => Ok(()), + context::Principal::ApiKey { user_id: me, .. } if *me == user_id => Ok(()), + _ => Err(AppError::new(ErrorCode::Forbidden, "无权限下载该文件")), + } + } else { + let expected = row.task_session_id.as_deref().unwrap_or(""); + match principal { + context::Principal::Anonymous { session_id } if session_id == expected => Ok(()), + _ => Err(AppError::new(ErrorCode::Forbidden, "无权限下载该文件")), + } + } +} + +fn content_type(format: &str) -> &'static str { + match format.trim().to_ascii_lowercase().as_str() { + "png" => "image/png", + "jpeg" | "jpg" => "image/jpeg", + "webp" => "image/webp", + "avif" => "image/avif", + _ => "application/octet-stream", + } +} + +fn sanitize_filename(name: &str) -> String { + let mut out = name.trim().to_string(); + if out.is_empty() { + out = "download".to_string(); + } + out = out.replace(['\r', '\n', '"', '\\'], "_"); + if out.len() > 120 { + out.truncate(120); + } + out +} + +#[derive(Debug, FromRow)] +struct TaskZipRow { + user_id: Option, + session_id: Option, + status: String, + completed_at: Option>, + expires_at: DateTime, +} + +#[derive(Debug, FromRow)] +struct TaskZipFileRow { + id: Uuid, + storage_path: Option, + original_name: String, + output_format: String, +} + +async fn download_task_zip( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Path(task_id): Path, +) -> Result<(axum_extra::extract::cookie::CookieJar, Response), AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (jar, principal) = context::authenticate(&state, jar, &headers, ip).await?; + + let task = sqlx::query_as::<_, TaskZipRow>( + r#" + SELECT + user_id, + session_id, + status::text AS status, + completed_at, + expires_at + FROM tasks + WHERE id = $1 + "#, + ) + .bind(task_id) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))? + .ok_or_else(|| AppError::new(ErrorCode::NotFound, "任务不存在"))?; + + if task.expires_at <= Utc::now() { + return Err(AppError::new(ErrorCode::NotFound, "任务已过期或不存在")); + } + if task.completed_at.is_none() || matches!(task.status.as_str(), "pending" | "processing") { + return Err(AppError::new(ErrorCode::InvalidRequest, "任务尚未完成")); + } + + if let Some(user_id) = task.user_id { + match principal { + context::Principal::User { user_id: me, .. } if me == user_id => {} + context::Principal::ApiKey { user_id: me, .. } if me == user_id => {} + _ => return Err(AppError::new(ErrorCode::Forbidden, "无权限下载该任务")), + } + } else { + let expected = task.session_id.as_deref().unwrap_or(""); + match principal { + context::Principal::Anonymous { session_id } if session_id == expected => {} + _ => return Err(AppError::new(ErrorCode::Forbidden, "无权限下载该任务")), + } + } + + if state.config.storage_type.to_ascii_lowercase() != "local" { + return Err(AppError::new( + ErrorCode::StorageUnavailable, + "当前仅支持本地存储(STORAGE_TYPE=local)", + )); + } + + let zip_dir = format!("{}/zips", state.config.storage_path); + tokio::fs::create_dir_all(&zip_dir) + .await + .map_err(|err| AppError::new(ErrorCode::StorageUnavailable, "创建存储目录失败").with_source(err))?; + let zip_path = PathBuf::from(format!("{zip_dir}/{task_id}.zip")); + + if tokio::fs::try_exists(&zip_path).await.unwrap_or(false) { + return stream_zip(jar, zip_path, task_id).await; + } + + let rows = sqlx::query_as::<_, TaskZipFileRow>( + r#" + SELECT id, storage_path, original_name, output_format + FROM task_files + WHERE task_id = $1 AND status = 'completed' + ORDER BY created_at ASC + "#, + ) + .bind(task_id) + .fetch_all(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询任务文件失败").with_source(err))?; + + if rows.is_empty() { + return Err(AppError::new(ErrorCode::NotFound, "没有可打包的文件")); + } + + let mut used_names: HashMap = HashMap::new(); + let mut entries: Vec<(String, String)> = Vec::new(); + for row in rows { + let Some(path) = row.storage_path else { continue }; + let name = build_zip_entry_name(&row.original_name, &row.output_format, &mut used_names); + entries.push((name, path)); + } + if entries.is_empty() { + return Err(AppError::new(ErrorCode::NotFound, "没有可打包的文件")); + } + + let zip_path_cloned = zip_path.clone(); + let task_id_str = task_id.to_string(); + tokio::task::spawn_blocking(move || generate_zip_file(&zip_path_cloned, &task_id_str, &entries)) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "生成 ZIP 失败").with_source(err))? + .map_err(|err| AppError::new(ErrorCode::Internal, "生成 ZIP 失败").with_source(err))?; + + stream_zip(jar, zip_path, task_id).await +} + +async fn stream_zip( + jar: axum_extra::extract::cookie::CookieJar, + zip_path: PathBuf, + task_id: Uuid, +) -> Result<(axum_extra::extract::cookie::CookieJar, Response), AppError> { + let file = tokio::fs::File::open(&zip_path) + .await + .map_err(|err| AppError::new(ErrorCode::StorageUnavailable, "读取 ZIP 失败").with_source(err))?; + + let stream = ReaderStream::new(file); + let body = Body::from_stream(stream); + + let mut resp_headers = HeaderMap::new(); + resp_headers.insert(header::CONTENT_TYPE, "application/zip".parse().unwrap()); + resp_headers.insert( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"task_{task_id}.zip\"") + .parse() + .unwrap(), + ); + + Ok((jar, (resp_headers, body).into_response())) +} + +fn build_zip_entry_name( + original_name: &str, + output_format: &str, + used: &mut HashMap, +) -> String { + let mut base = sanitize_zip_name(original_name); + if let Some((head, _ext)) = base.rsplit_once('.') { + base = head.to_string(); + } + + let ext = match output_format.trim().to_ascii_lowercase().as_str() { + "jpeg" | "jpg" => "jpg", + "png" => "png", + "webp" => "webp", + "avif" => "avif", + _ => "bin", + }; + + let base = if base.is_empty() { "file".to_string() } else { base }; + let candidate = format!("{base}.{ext}"); + let counter = used.entry(candidate.clone()).or_insert(0); + if *counter == 0 { + *counter = 1; + return candidate; + } + + let name = format!("{base} ({counter}).{ext}"); + *counter += 1; + name +} + +fn sanitize_zip_name(name: &str) -> String { + let mut out = name.trim().to_string(); + out = out.replace(['\r', '\n', '"', '\\', '/', ':'], "_"); + if out.len() > 120 { + out.truncate(120); + } + out +} + +fn generate_zip_file(zip_path: &PathBuf, task_id: &str, entries: &[(String, String)]) -> Result<(), String> { + let tmp = PathBuf::from(format!("{}.tmp", zip_path.to_string_lossy())); + + let file = std::fs::File::create(&tmp).map_err(|e| format!("create zip: {e}"))?; + let mut zip = zip::ZipWriter::new(file); + let options = zip::write::FileOptions::<()>::default() + .compression_method(zip::CompressionMethod::Stored); + + for (name, path) in entries { + zip.start_file(name, options) + .map_err(|e| format!("zip start_file: {e}"))?; + let mut f = std::fs::File::open(path).map_err(|e| format!("open file: {e}"))?; + std::io::copy(&mut f, &mut zip).map_err(|e| format!("copy: {e}"))?; + } + + zip.finish().map_err(|e| format!("finish: {e}"))?; + + std::fs::rename(&tmp, zip_path).map_err(|e| format!("rename: {e}"))?; + tracing::info!(task_id = %task_id, path = %zip_path.to_string_lossy(), "ZIP generated"); + Ok(()) +} diff --git a/src/api/envelope.rs b/src/api/envelope.rs new file mode 100644 index 0000000..67cb974 --- /dev/null +++ b/src/api/envelope.rs @@ -0,0 +1,8 @@ +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub struct Envelope { + pub success: bool, + pub data: T, +} + diff --git a/src/api/health.rs b/src/api/health.rs new file mode 100644 index 0000000..67e569c --- /dev/null +++ b/src/api/health.rs @@ -0,0 +1,39 @@ +use crate::state::AppState; + +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; +use serde::Serialize; + +#[derive(Debug, Serialize)] +struct HealthResponse { + status: &'static str, + database: &'static str, + redis: &'static str, +} + +pub async fn health(State(state): State) -> impl IntoResponse { + let database_ok = sqlx::query("SELECT 1") + .execute(&state.db) + .await + .is_ok(); + + let mut redis_conn = state.redis.clone(); + let redis_ok = redis::cmd("PING") + .query_async::<_, String>(&mut redis_conn) + .await + .is_ok(); + + let status = if database_ok && redis_ok { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }; + + let body = HealthResponse { + status: if status == StatusCode::OK { "healthy" } else { "unhealthy" }, + database: if database_ok { "connected" } else { "unavailable" }, + redis: if redis_ok { "connected" } else { "unavailable" }, + }; + + (status, Json(body)) +} + diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..7e2a7d0 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,66 @@ +mod auth; +mod context; +mod envelope; +mod compress; +mod downloads; +mod billing; +mod webhooks; +mod user; +mod tasks; +mod admin; +mod health; +mod response; + +use crate::error::{AppError, ErrorCode}; +use crate::state::AppState; + +use axum::extract::DefaultBodyLimit; +use axum::Router; +use std::net::SocketAddr; +use tower_http::services::{ServeDir, ServeFile}; +use tower_http::trace::TraceLayer; + +pub async fn run(state: AppState) -> Result<(), AppError> { + let addr = format!("{}:{}", state.config.host, state.config.port); + + if let Err(err) = crate::services::bootstrap::ensure_schema(&state).await { + tracing::error!(error = %err, "数据库结构初始化失败"); + } + if let Err(err) = crate::services::bootstrap::ensure_admin_user(&state).await { + tracing::error!(error = %err, "管理员账号初始化失败"); + } + + let static_service = ServeDir::new("static").not_found_service(ServeFile::new("static/index.html")); + + let v1 = v1_router().layer(DefaultBodyLimit::max(100 * 1024 * 1024)); + + let app = Router::new() + .route("/health", axum::routing::get(health::health)) + .nest("/downloads", downloads::router()) + .nest("/api/v1", v1) + .fallback_service(static_service) + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let listener = tokio::net::TcpListener::bind(&addr) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "监听端口失败").with_source(err))?; + + tracing::info!(addr = %addr, "API server listening"); + + axum::serve(listener, app.into_make_service_with_connect_info::()) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "HTTP 服务异常退出").with_source(err)) +} + +fn v1_router() -> Router { + Router::new() + .nest("/auth", auth::router()) + .merge(compress::router()) + .merge(tasks::router()) + .merge(billing::router()) + .merge(webhooks::router()) + .merge(user::router()) + .merge(admin::router()) + .fallback(response::not_found) +} diff --git a/src/api/response.rs b/src/api/response.rs new file mode 100644 index 0000000..572e741 --- /dev/null +++ b/src/api/response.rs @@ -0,0 +1,6 @@ +use crate::error::{AppError, ErrorCode}; + +pub async fn not_found() -> AppError { + AppError::new(ErrorCode::NotFound, "接口不存在") +} + diff --git a/src/api/tasks.rs b/src/api/tasks.rs new file mode 100644 index 0000000..f1a2a0b --- /dev/null +++ b/src/api/tasks.rs @@ -0,0 +1,987 @@ +use crate::api::context; +use crate::api::envelope::Envelope; +use crate::error::{AppError, ErrorCode}; +use crate::services::billing; +use crate::services::billing::{BillingContext, Plan}; +use crate::services::compress; +use crate::services::compress::{CompressionLevel, ImageFmt}; +use crate::services::idempotency; +use crate::state::AppState; + +use axum::extract::{ConnectInfo, Multipart, Path, State}; +use axum::http::HeaderMap; +use axum::routing::{delete, get, post}; +use axum::{Json, Router}; +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sqlx::FromRow; +use std::net::{IpAddr, SocketAddr}; +use tokio::io::AsyncWriteExt; +use uuid::Uuid; + +pub fn router() -> Router { + Router::new() + .route("/compress/batch", post(create_batch_task)) + .route("/compress/tasks/{task_id}", get(get_task)) + .route("/compress/tasks/{task_id}/cancel", post(cancel_task)) + .route("/compress/tasks/{task_id}", delete(delete_task)) +} + +#[derive(Debug, Serialize, Deserialize)] +struct BatchCreateResponse { + task_id: Uuid, + total_files: i32, + status: String, + status_url: String, +} + +#[derive(Debug)] +struct BatchFileInput { + file_id: Uuid, + original_name: String, + original_format: ImageFmt, + output_format: ImageFmt, + original_size: u64, + storage_path: String, +} + +#[derive(Debug)] +struct BatchOptions { + level: CompressionLevel, + compression_rate: Option, + output_format: Option, + max_width: Option, + max_height: Option, + preserve_metadata: bool, +} + +async fn create_batch_task( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + mut multipart: Multipart, +) -> Result<(axum_extra::extract::cookie::CookieJar, Json>), AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (jar, principal) = context::authenticate(&state, jar, &headers, ip).await?; + + if state.config.storage_type.to_ascii_lowercase() != "local" { + return Err(AppError::new( + ErrorCode::StorageUnavailable, + "当前仅支持本地存储(STORAGE_TYPE=local)", + )); + } + + let idempotency_key = headers + .get("idempotency-key") + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string); + let idempotency_scope = match &principal { + context::Principal::User { user_id, .. } => Some(idempotency::Scope::User(*user_id)), + context::Principal::ApiKey { api_key_id, .. } => Some(idempotency::Scope::ApiKey(*api_key_id)), + _ => None, + }; + + let task_id = Uuid::new_v4(); + let (files, opts, request_hash) = parse_batch_request(&state, task_id, &mut multipart).await?; + + if files.is_empty() { + cleanup_file_paths(&files).await; + return Err(AppError::new(ErrorCode::InvalidRequest, "缺少 files[]")); + } + + let mut idem_acquired = false; + if let (Some(scope), Some(idem_key)) = (idempotency_scope, idempotency_key.as_deref()) { + match idempotency::begin( + &state, + scope, + idem_key, + &request_hash, + state.config.idempotency_ttl_hours as i64, + ) + .await? + { + idempotency::BeginResult::Replay { response_body, .. } => { + cleanup_file_paths(&files).await; + let resp: BatchCreateResponse = + serde_json::from_value(response_body).map_err(|err| { + AppError::new(ErrorCode::Internal, "幂等结果解析失败").with_source(err) + })?; + return Ok((jar, Json(Envelope { success: true, data: resp }))); + } + idempotency::BeginResult::InProgress => { + cleanup_file_paths(&files).await; + if let Some((_status, body)) = idempotency::wait_for_replay( + &state, + scope, + idem_key, + &request_hash, + 10_000, + ) + .await? + { + let resp: BatchCreateResponse = + serde_json::from_value(body).map_err(|err| { + AppError::new(ErrorCode::Internal, "幂等结果解析失败").with_source(err) + })?; + return Ok((jar, Json(Envelope { success: true, data: resp }))); + } + return Err(AppError::new( + ErrorCode::InvalidRequest, + "请求正在处理中,请稍后重试", + )); + } + idempotency::BeginResult::Acquired { .. } => { + idem_acquired = true; + } + } + } + + let create_result: Result = (async { + let (retention, task_owner, source) = match &principal { + context::Principal::Anonymous { session_id } => { + enforce_batch_limits_anonymous(&state, &files)?; + let remaining = anonymous_remaining_units(&state, session_id, ip).await?; + if remaining < files.len() as i64 { + return Err(AppError::new( + ErrorCode::QuotaExceeded, + "匿名试用次数已用完(每日 10 次)", + )); + } + Ok(( + Duration::hours(state.config.anon_retention_hours as i64), + TaskOwner::Anonymous { + session_id: session_id.clone(), + }, + "web", + )) + } + context::Principal::User { + user_id, + email_verified, + .. + } => { + if !email_verified { + return Err(AppError::new(ErrorCode::EmailNotVerified, "请先验证邮箱")); + } + let billing = billing::get_user_billing(&state, *user_id).await?; + enforce_batch_limits_plan(&billing.plan, &files)?; + ensure_quota_available(&state, &billing, files.len() as i32).await?; + Ok(( + Duration::days(billing.plan.retention_days as i64), + TaskOwner::User { user_id: *user_id }, + "web", + )) + } + context::Principal::ApiKey { + user_id, + api_key_id, + email_verified, + .. + } => { + if !email_verified { + return Err(AppError::new(ErrorCode::EmailNotVerified, "请先验证邮箱")); + } + let billing = billing::get_user_billing(&state, *user_id).await?; + if !billing.plan.feature_api_enabled { + return Err(AppError::new(ErrorCode::Forbidden, "当前套餐未开通 API")); + } + enforce_batch_limits_plan(&billing.plan, &files)?; + ensure_quota_available(&state, &billing, files.len() as i32).await?; + Ok(( + Duration::days(billing.plan.retention_days as i64), + TaskOwner::ApiKey { + user_id: *user_id, + api_key_id: *api_key_id, + }, + "api", + )) + } + }?; + + tokio::fs::create_dir_all(&state.config.storage_path) + .await + .map_err(|err| { + AppError::new(ErrorCode::StorageUnavailable, "创建存储目录失败").with_source(err) + })?; + + let expires_at = Utc::now() + retention; + let (user_id, session_id, api_key_id) = match &task_owner { + TaskOwner::Anonymous { session_id } => (None, Some(session_id.clone()), None), + TaskOwner::User { user_id } => (Some(*user_id), None, None), + TaskOwner::ApiKey { user_id, api_key_id } => (Some(*user_id), None, Some(*api_key_id)), + }; + + let total_original_size: i64 = files.iter().map(|f| f.original_size as i64).sum(); + + let mut tx = state + .db + .begin() + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?; + + sqlx::query( + r#" + INSERT INTO tasks ( + id, user_id, session_id, api_key_id, client_ip, source, status, + compression_level, output_format, max_width, max_height, preserve_metadata, + compression_rate, + total_files, completed_files, failed_files, + total_original_size, total_compressed_size, + expires_at + ) VALUES ( + $1, $2, $3, $4, $5::inet, $6::task_source, 'pending', + $7::compression_level, $8, $9, $10, $11, $12, + $13, 0, 0, + $14, 0, + $15 + ) + "#, + ) + .bind(task_id) + .bind(user_id) + .bind(session_id) + .bind(api_key_id) + .bind(ip.to_string()) + .bind(source) + .bind(opts.level.as_str()) + .bind(opts.output_format.map(|f| f.as_str())) + .bind(opts.max_width.map(|v| v as i32)) + .bind(opts.max_height.map(|v| v as i32)) + .bind(false) + .bind(opts.compression_rate.map(|v| v as i16)) + .bind(files.len() as i32) + .bind(total_original_size) + .bind(expires_at) + .execute(&mut *tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "创建任务失败").with_source(err))?; + + for file in &files { + sqlx::query( + r#" + INSERT INTO task_files ( + id, task_id, + original_name, original_format, output_format, + original_size, + storage_path, status + ) VALUES ( + $1, $2, + $3, $4, $5, + $6, + $7, 'pending' + ) + "#, + ) + .bind(file.file_id) + .bind(task_id) + .bind(&file.original_name) + .bind(file.original_format.as_str()) + .bind(file.output_format.as_str()) + .bind(file.original_size as i64) + .bind(&file.storage_path) + .execute(&mut *tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "创建文件记录失败").with_source(err))?; + } + + tx.commit() + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?; + + if let Err(err) = enqueue_task(&state, task_id).await { + let _ = sqlx::query("UPDATE tasks SET status = 'failed', error_message = $2 WHERE id = $1") + .bind(task_id) + .bind("队列提交失败") + .execute(&state.db) + .await; + return Err(err); + } + + Ok(BatchCreateResponse { + task_id, + total_files: files.len() as i32, + status: "pending".to_string(), + status_url: format!("/api/v1/compress/tasks/{task_id}"), + }) + }) + .await; + + match create_result { + Ok(resp) => { + if let (Some(scope), Some(idem_key)) = (idempotency_scope, idempotency_key.as_deref()) { + if idem_acquired { + let _ = idempotency::complete( + &state, + scope, + idem_key, + &request_hash, + 200, + serde_json::to_value(&resp).unwrap_or(serde_json::Value::Null), + ) + .await; + } + } + Ok((jar, Json(Envelope { success: true, data: resp }))) + } + Err(err) => { + if let (Some(scope), Some(idem_key)) = (idempotency_scope, idempotency_key.as_deref()) { + if idem_acquired { + let _ = idempotency::abort(&state, scope, idem_key, &request_hash).await; + } + } + cleanup_file_paths(&files).await; + Err(err) + } + } +} + +#[derive(Debug)] +enum TaskOwner { + Anonymous { session_id: String }, + User { user_id: Uuid }, + ApiKey { user_id: Uuid, api_key_id: Uuid }, +} + +async fn enqueue_task(state: &AppState, task_id: Uuid) -> Result<(), AppError> { + let mut conn = state.redis.clone(); + let now = Utc::now().to_rfc3339(); + redis::cmd("XADD") + .arg("stream:compress_jobs") + .arg("*") + .arg("task_id") + .arg(task_id.to_string()) + .arg("created_at") + .arg(now) + .query_async::<_, redis::Value>(&mut conn) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "写入队列失败").with_source(err))?; + Ok(()) +} + +async fn parse_batch_request( + state: &AppState, + task_id: Uuid, + multipart: &mut Multipart, +) -> Result<(Vec, BatchOptions, String), AppError> { + let mut files: Vec = Vec::new(); + let mut file_digests: Vec = Vec::new(); + let mut opts = BatchOptions { + level: CompressionLevel::Medium, + compression_rate: None, + output_format: None, + max_width: None, + max_height: None, + preserve_metadata: false, + }; + + let base_dir = format!("{}/orig/{task_id}", state.config.storage_path); + tokio::fs::create_dir_all(&base_dir) + .await + .map_err(|err| AppError::new(ErrorCode::StorageUnavailable, "创建存储目录失败").with_source(err))?; + + loop { + let next = multipart.next_field().await.map_err(|err| { + AppError::new(ErrorCode::InvalidRequest, "读取上传内容失败").with_source(err) + }); + + let field = match next { + Ok(v) => v, + Err(err) => { + cleanup_file_paths(&files).await; + return Err(err); + } + }; + + let Some(field) = field else { break }; + + let name = field.name().unwrap_or("").to_string(); + if name == "files" || name == "files[]" { + let file_id = Uuid::new_v4(); + let original_name = field.file_name().unwrap_or("upload").to_string(); + let bytes = match field.bytes().await { + Ok(v) => v, + Err(err) => { + cleanup_file_paths(&files).await; + return Err( + AppError::new(ErrorCode::InvalidRequest, "读取文件失败").with_source(err) + ); + } + }; + + let original_size = bytes.len() as u64; + let file_digest = { + let mut h = Sha256::new(); + h.update(&bytes); + h.update(original_name.as_bytes()); + hex::encode(h.finalize()) + }; + let original_format = match compress::detect_format(&bytes) { + Ok(v) => v, + Err(err) => { + cleanup_file_paths(&files).await; + return Err(err); + } + }; + + let output_format = opts.output_format.unwrap_or(original_format); + let path = format!("{base_dir}/{file_id}.{}", original_format.extension()); + + let mut f = match tokio::fs::File::create(&path).await { + Ok(v) => v, + Err(err) => { + cleanup_file_paths(&files).await; + return Err( + AppError::new(ErrorCode::StorageUnavailable, "写入文件失败").with_source(err), + ); + } + }; + if let Err(err) = f.write_all(&bytes).await { + let _ = tokio::fs::remove_file(&path).await; + cleanup_file_paths(&files).await; + return Err( + AppError::new(ErrorCode::StorageUnavailable, "写入文件失败").with_source(err), + ); + } + + files.push(BatchFileInput { + file_id, + original_name, + original_format, + output_format, + original_size, + storage_path: path, + }); + file_digests.push(file_digest); + continue; + } + + let text = match field.text().await { + Ok(v) => v, + Err(err) => { + cleanup_file_paths(&files).await; + return Err( + AppError::new(ErrorCode::InvalidRequest, "读取字段失败").with_source(err), + ); + } + }; + match name.as_str() { + "level" => { + opts.level = match compress::parse_level(&text) { + Ok(v) => v, + Err(err) => { + cleanup_file_paths(&files).await; + return Err(err); + } + } + } + "output_format" => { + let v = text.trim(); + if !v.is_empty() { + opts.output_format = Some(match compress::parse_output_format(v) { + Ok(v) => v, + Err(err) => { + cleanup_file_paths(&files).await; + return Err(err); + } + }); + } + } + "compression_rate" | "quality" => { + let v = text.trim(); + if !v.is_empty() { + opts.compression_rate = Some(match compress::parse_compression_rate(v) { + Ok(v) => v, + Err(err) => { + cleanup_file_paths(&files).await; + return Err(err); + } + }); + } + } + "max_width" => { + let v = text.trim(); + if !v.is_empty() { + opts.max_width = Some(match v.parse::() { + Ok(n) => n, + Err(_) => { + cleanup_file_paths(&files).await; + return Err(AppError::new(ErrorCode::InvalidRequest, "max_width 格式错误")); + } + }); + } + } + "max_height" => { + let v = text.trim(); + if !v.is_empty() { + opts.max_height = Some(match v.parse::() { + Ok(n) => n, + Err(_) => { + cleanup_file_paths(&files).await; + return Err(AppError::new(ErrorCode::InvalidRequest, "max_height 格式错误")); + } + }); + } + } + "preserve_metadata" => { + opts.preserve_metadata = matches!( + text.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "y" | "on" + ); + } + _ => {} + } + } + + if let Some(rate) = opts.compression_rate { + opts.level = compress::rate_to_level(rate); + } + + if opts.output_format.is_some() { + cleanup_file_paths(&files).await; + return Err(AppError::new( + ErrorCode::InvalidRequest, + "当前仅支持保持原图片格式", + )); + } + + let mw = opts.max_width.map(|v| v.to_string()).unwrap_or_default(); + let mh = opts.max_height.map(|v| v.to_string()).unwrap_or_default(); + let out_fmt = opts.output_format.map(|f| f.as_str()).unwrap_or(""); + let rate_key = opts + .compression_rate + .map(|v| v.to_string()) + .unwrap_or_default(); + let preserve = if opts.preserve_metadata { "1" } else { "0" }; + + let mut h = Sha256::new(); + h.update(b"compress_batch_v1"); + h.update(opts.level.as_str().as_bytes()); + h.update(out_fmt.as_bytes()); + h.update(rate_key.as_bytes()); + h.update(mw.as_bytes()); + h.update(mh.as_bytes()); + h.update(preserve.as_bytes()); + for d in &file_digests { + h.update(d.as_bytes()); + } + let request_hash = hex::encode(h.finalize()); + + Ok((files, opts, request_hash)) +} + +fn enforce_batch_limits_anonymous(state: &AppState, files: &[BatchFileInput]) -> Result<(), AppError> { + let max_files = state.config.anon_max_files_per_batch as usize; + if files.len() > max_files { + return Err(AppError::new( + ErrorCode::InvalidRequest, + format!("匿名试用单次最多 {} 个文件", max_files), + )); + } + + let max_bytes = state.config.anon_max_file_size_mb * 1024 * 1024; + for f in files { + if f.original_size > max_bytes { + return Err(AppError::new( + ErrorCode::FileTooLarge, + format!("匿名试用单文件最大 {} MB", state.config.anon_max_file_size_mb), + )); + } + } + + Ok(()) +} + +fn enforce_batch_limits_plan(plan: &Plan, files: &[BatchFileInput]) -> Result<(), AppError> { + let max_files = plan.max_files_per_batch as usize; + if files.len() > max_files { + return Err(AppError::new( + ErrorCode::InvalidRequest, + format!("当前套餐单次最多 {} 个文件", plan.max_files_per_batch), + )); + } + + let max_bytes = (plan.max_file_size_mb as u64) * 1024 * 1024; + for f in files { + if f.original_size > max_bytes { + return Err(AppError::new( + ErrorCode::FileTooLarge, + format!("当前套餐单文件最大 {} MB", plan.max_file_size_mb), + )); + } + } + + Ok(()) +} + +async fn ensure_quota_available( + state: &AppState, + ctx: &BillingContext, + needed_units: i32, +) -> Result<(), AppError> { + if needed_units <= 0 { + return Ok(()); + } + + #[derive(Debug, FromRow)] + struct UsageRow { + used_units: i32, + bonus_units: i32, + } + + let usage = sqlx::query_as::<_, UsageRow>( + r#" + SELECT used_units, bonus_units + FROM usage_periods + WHERE user_id = $1 AND period_start = $2 AND period_end = $3 + "#, + ) + .bind(ctx.user_id) + .bind(ctx.period_start) + .bind(ctx.period_end) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用量失败").with_source(err))? + .unwrap_or(UsageRow { + used_units: 0, + bonus_units: 0, + }); + + let total_units = ctx.plan.included_units_per_period + usage.bonus_units; + let remaining = total_units - usage.used_units; + if remaining < needed_units { + return Err(AppError::new(ErrorCode::QuotaExceeded, "当期配额已用完")); + } + + Ok(()) +} + +async fn anonymous_remaining_units( + state: &AppState, + session_id: &str, + ip: IpAddr, +) -> Result { + let date = utc8_date(); + let session_key = format!("anon_quota:{session_id}:{date}"); + let ip_key = format!("anon_quota_ip:{ip}:{date}"); + + let mut conn = state.redis.clone(); + let v1: Option = redis::cmd("GET") + .arg(session_key) + .query_async(&mut conn) + .await + .unwrap_or(None); + let v2: Option = redis::cmd("GET") + .arg(ip_key) + .query_async(&mut conn) + .await + .unwrap_or(None); + + let limit = state.config.anon_daily_units as i64; + Ok(std::cmp::min(limit - v1.unwrap_or(0), limit - v2.unwrap_or(0))) +} + +fn utc8_date() -> String { + let now = Utc::now() + Duration::hours(8); + now.format("%Y-%m-%d").to_string() +} + +#[derive(Debug, FromRow)] +struct TaskRow { + id: Uuid, + status: String, + total_files: i32, + completed_files: i32, + failed_files: i32, + created_at: DateTime, + completed_at: Option>, + expires_at: DateTime, + user_id: Option, + session_id: Option, +} + +#[derive(Debug, FromRow)] +struct TaskFileRow { + id: Uuid, + original_name: String, + original_size: i64, + compressed_size: Option, + saved_percent: Option, + status: String, +} + +#[derive(Debug, Serialize)] +struct TaskFileView { + file_id: Uuid, + original_name: String, + original_size: i64, + compressed_size: Option, + saved_percent: Option, + status: String, + download_url: Option, +} + +#[derive(Debug, Serialize)] +struct TaskView { + task_id: Uuid, + status: String, + progress: i32, + total_files: i32, + completed_files: i32, + failed_files: i32, + files: Vec, + download_all_url: String, + created_at: DateTime, + completed_at: Option>, + expires_at: DateTime, +} + +async fn get_task( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Path(task_id): Path, +) -> Result<(axum_extra::extract::cookie::CookieJar, Json>), AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (jar, principal) = context::authenticate(&state, jar, &headers, ip).await?; + + let task = sqlx::query_as::<_, TaskRow>( + r#" + SELECT + id, + status::text AS status, + total_files, + completed_files, + failed_files, + created_at, + completed_at, + expires_at, + user_id, + session_id + FROM tasks + WHERE id = $1 + "#, + ) + .bind(task_id) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))? + .ok_or_else(|| AppError::new(ErrorCode::NotFound, "任务不存在"))?; + + if task.expires_at <= Utc::now() { + return Err(AppError::new(ErrorCode::NotFound, "任务已过期或不存在")); + } + + authorize_task(&principal, task.user_id, task.session_id.as_deref().unwrap_or(""))?; + + let files = sqlx::query_as::<_, TaskFileRow>( + r#" + SELECT + id, + original_name, + original_size, + compressed_size, + saved_percent::float8 AS saved_percent, + status::text AS status + FROM task_files + WHERE task_id = $1 + ORDER BY created_at ASC + "#, + ) + .bind(task_id) + .fetch_all(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询任务文件失败").with_source(err))?; + + let file_views = files + .into_iter() + .map(|f| TaskFileView { + file_id: f.id, + original_name: f.original_name, + original_size: f.original_size, + compressed_size: f.compressed_size, + saved_percent: f.saved_percent, + status: f.status.clone(), + download_url: if f.status == "completed" { + Some(format!("/downloads/{}", f.id)) + } else { + None + }, + }) + .collect::>(); + + let processed = task.completed_files + task.failed_files; + let progress = if task.total_files <= 0 { + 0 + } else { + ((processed as f64) * 100.0 / (task.total_files as f64)) + .round() + .clamp(0.0, 100.0) as i32 + }; + + Ok(( + jar, + Json(Envelope { + success: true, + data: TaskView { + task_id, + status: task.status, + progress, + total_files: task.total_files, + completed_files: task.completed_files, + failed_files: task.failed_files, + files: file_views, + download_all_url: format!("/downloads/tasks/{task_id}"), + created_at: task.created_at, + completed_at: task.completed_at, + expires_at: task.expires_at, + }, + }), + )) +} + +async fn cancel_task( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Path(task_id): Path, +) -> Result<(axum_extra::extract::cookie::CookieJar, Json>), AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (jar, principal) = context::authenticate(&state, jar, &headers, ip).await?; + + let task = sqlx::query_as::<_, TaskRow>( + "SELECT id, status::text AS status, total_files, completed_files, failed_files, created_at, completed_at, expires_at, user_id, session_id FROM tasks WHERE id = $1", + ) + .bind(task_id) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))? + .ok_or_else(|| AppError::new(ErrorCode::NotFound, "任务不存在"))?; + + authorize_task(&principal, task.user_id, task.session_id.as_deref().unwrap_or(""))?; + + if matches!(task.status.as_str(), "completed" | "failed" | "cancelled") { + return Ok(( + jar, + Json(Envelope { + success: true, + data: serde_json::json!({ "message": "任务已结束" }), + }), + )); + } + + let updated = sqlx::query( + "UPDATE tasks SET status = 'cancelled', completed_at = NOW() WHERE id = $1 AND status IN ('pending', 'processing')", + ) + .bind(task_id) + .execute(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "取消任务失败").with_source(err))?; + + if updated.rows_affected() == 0 { + return Err(AppError::new(ErrorCode::InvalidRequest, "任务状态不可取消")); + } + + Ok(( + jar, + Json(Envelope { + success: true, + data: serde_json::json!({ "message": "已取消" }), + }), + )) +} + +async fn delete_task( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Path(task_id): Path, +) -> Result<(axum_extra::extract::cookie::CookieJar, Json>), AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (jar, principal) = context::authenticate(&state, jar, &headers, ip).await?; + + let task = sqlx::query_as::<_, TaskRow>( + "SELECT id, status::text AS status, total_files, completed_files, failed_files, created_at, completed_at, expires_at, user_id, session_id FROM tasks WHERE id = $1", + ) + .bind(task_id) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))? + .ok_or_else(|| AppError::new(ErrorCode::NotFound, "任务不存在"))?; + + authorize_task(&principal, task.user_id, task.session_id.as_deref().unwrap_or(""))?; + + if task.status == "processing" { + return Err(AppError::new( + ErrorCode::InvalidRequest, + "任务处理中,请先取消后再删除", + )); + } + + let paths: Vec> = + sqlx::query_scalar("SELECT storage_path FROM task_files WHERE task_id = $1") + .bind(task_id) + .fetch_all(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询文件失败").with_source(err))?; + + for p in paths.into_iter().flatten() { + let _ = tokio::fs::remove_file(p).await; + } + + if state.config.storage_type.to_ascii_lowercase() == "local" { + let zip_path = format!("{}/zips/{task_id}.zip", state.config.storage_path); + let _ = tokio::fs::remove_file(zip_path).await; + let orig_dir = format!("{}/orig/{task_id}", state.config.storage_path); + let _ = tokio::fs::remove_dir_all(orig_dir).await; + } + + let deleted = sqlx::query("DELETE FROM tasks WHERE id = $1") + .bind(task_id) + .execute(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "删除任务失败").with_source(err))?; + + if deleted.rows_affected() == 0 { + return Err(AppError::new(ErrorCode::NotFound, "任务不存在")); + } + + Ok(( + jar, + Json(Envelope { + success: true, + data: serde_json::json!({ "message": "已删除" }), + }), + )) +} + +fn authorize_task( + principal: &context::Principal, + user_id: Option, + session_id: &str, +) -> Result<(), AppError> { + if let Some(owner) = user_id { + match principal { + context::Principal::User { user_id: me, .. } if *me == owner => Ok(()), + context::Principal::ApiKey { user_id: me, .. } if *me == owner => Ok(()), + _ => Err(AppError::new(ErrorCode::Forbidden, "无权限访问该任务")), + } + } else { + match principal { + context::Principal::Anonymous { session_id: sid } if sid == session_id => Ok(()), + _ => Err(AppError::new(ErrorCode::Forbidden, "无权限访问该任务")), + } + } +} + +async fn cleanup_file_paths(files: &[BatchFileInput]) { + for f in files { + let _ = tokio::fs::remove_file(&f.storage_path).await; + } +} diff --git a/src/api/user.rs b/src/api/user.rs new file mode 100644 index 0000000..27e48f8 --- /dev/null +++ b/src/api/user.rs @@ -0,0 +1,912 @@ +use crate::api::context; +use crate::api::envelope::Envelope; +use crate::error::{AppError, ErrorCode}; +use crate::services::billing; +use crate::services::mail; +use crate::state::AppState; + +use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; +use axum::extract::{ConnectInfo, Path, Query, State}; +use axum::http::HeaderMap; +use axum::routing::{delete, get, post, put}; +use axum::{Json, Router}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{DateTime, Duration, Utc}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sqlx::FromRow; +use std::net::SocketAddr; +use uuid::Uuid; + +pub fn router() -> Router { + Router::new() + .route("/user/profile", get(get_profile)) + .route("/user/profile", put(update_profile)) + .route("/user/password", put(update_password)) + .route("/user/history", get(list_history)) + .route("/user/api-keys", get(list_api_keys)) + .route("/user/api-keys", post(create_api_key)) + .route("/user/api-keys/{key_id}/rotate", post(rotate_api_key)) + .route("/user/api-keys/{key_id}", delete(disable_api_key)) +} + +#[derive(Debug, FromRow, Serialize)] +struct ApiKeyView { + id: Uuid, + name: String, + key_prefix: String, + permissions: serde_json::Value, + rate_limit: i32, + is_active: bool, + last_used_at: Option>, + last_used_ip: Option, + created_at: chrono::DateTime, +} + +#[derive(Debug, Serialize)] +struct ApiKeyListResponse { + api_keys: Vec, +} + +#[derive(Debug, Serialize)] +struct UserView { + id: Uuid, + email: String, + username: String, + role: String, + email_verified: bool, +} + +#[derive(Debug, Serialize)] +struct MessageResponse { + message: String, +} + +async fn get_profile( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?; + + let user_id = match principal { + context::Principal::User { user_id, .. } => user_id, + _ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")), + }; + + #[derive(Debug, FromRow)] + struct UserRow { + id: Uuid, + email: String, + username: String, + role: String, + email_verified_at: Option>, + } + + let user = sqlx::query_as::<_, UserRow>( + r#" + SELECT id, email, username, role::text AS role, email_verified_at + FROM users + WHERE id = $1 + "#, + ) + .bind(user_id) + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?; + + Ok(Json(Envelope { + success: true, + data: UserView { + id: user.id, + email: user.email, + username: user.username, + role: user.role, + email_verified: user.email_verified_at.is_some(), + }, + })) +} + +#[derive(Debug, Deserialize)] +struct UpdateProfileRequest { + email: Option, + username: Option, +} + +#[derive(Debug, Serialize)] +struct UpdateProfileResponse { + user: UserView, + message: String, +} + +async fn update_profile( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Json(req): Json, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?; + + let user_id = match principal { + context::Principal::User { user_id, .. } => user_id, + _ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")), + }; + + if req.email.is_none() && req.username.is_none() { + return Err(AppError::new(ErrorCode::InvalidRequest, "未提供可更新字段")); + } + + #[derive(Debug, FromRow)] + struct UserRow { + id: Uuid, + email: String, + username: String, + role: String, + email_verified_at: Option>, + } + + let user = sqlx::query_as::<_, UserRow>( + r#" + SELECT id, email, username, role::text AS role, email_verified_at + FROM users + WHERE id = $1 + "#, + ) + .bind(user_id) + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?; + + let mut next_email = user.email.clone(); + let mut next_username = user.username.clone(); + let mut email_changed = false; + + if let Some(email) = req.email.as_ref() { + let email = email.trim().to_lowercase(); + validate_email(&email)?; + if email != user.email { + next_email = email; + email_changed = true; + } + } + + if let Some(username) = req.username.as_ref() { + let username = username.trim().to_string(); + validate_username(&username)?; + if username != user.username { + next_username = username; + } + } + + if next_email == user.email && next_username == user.username { + return Ok(Json(Envelope { + success: true, + data: UpdateProfileResponse { + user: UserView { + id: user.id, + email: user.email, + username: user.username, + role: user.role, + email_verified: user.email_verified_at.is_some(), + }, + message: "暂无更新".to_string(), + }, + })); + } + + let mut tx = state + .db + .begin() + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?; + + let email_verified_at = if email_changed { None } else { user.email_verified_at }; + + let updated = sqlx::query_as::<_, UserRow>( + r#" + UPDATE users + SET email = $2, + username = $3, + email_verified_at = $4, + updated_at = NOW() + WHERE id = $1 + RETURNING id, email, username, role::text AS role, email_verified_at + "#, + ) + .bind(user_id) + .bind(&next_email) + .bind(&next_username) + .bind(email_verified_at) + .fetch_one(&mut *tx) + .await + .map_err(map_unique_violation)?; + + let mut verification_link: Option = None; + if email_changed { + let token = generate_token(); + let token_hash = sha256_hex(&token); + let expires_at = Utc::now() + Duration::hours(24); + + sqlx::query( + r#" + INSERT INTO email_verifications (user_id, token_hash, expires_at) + VALUES ($1, $2, $3) + "#, + ) + .bind(user_id) + .bind(token_hash) + .bind(expires_at) + .execute(&mut *tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "创建邮箱验证记录失败").with_source(err))?; + + verification_link = Some(format!( + "{}/verify-email?token={}", + state.config.public_base_url, token + )); + } + + tx.commit() + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?; + + if let Some(link) = verification_link.as_deref() { + mail::send_verification_email(&state, &updated.email, &updated.username, link) + .await + .map_err(|err| AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err))?; + } + + let message = if email_changed { + "资料已更新,请验证新邮箱".to_string() + } else { + "资料已更新".to_string() + }; + + Ok(Json(Envelope { + success: true, + data: UpdateProfileResponse { + user: UserView { + id: updated.id, + email: updated.email, + username: updated.username, + role: updated.role, + email_verified: updated.email_verified_at.is_some(), + }, + message, + }, + })) +} + +#[derive(Debug, Deserialize)] +struct UpdatePasswordRequest { + current_password: String, + new_password: String, +} + +async fn update_password( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Json(req): Json, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?; + + let user_id = match principal { + context::Principal::User { user_id, .. } => user_id, + _ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")), + }; + + validate_password(&req.new_password)?; + + #[derive(Debug, FromRow)] + struct PasswordRow { + password_hash: String, + } + + let row = sqlx::query_as::<_, PasswordRow>("SELECT password_hash FROM users WHERE id = $1") + .bind(user_id) + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?; + + verify_password(&req.current_password, &row.password_hash)?; + + let new_hash = hash_password(&req.new_password)?; + sqlx::query("UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1") + .bind(user_id) + .bind(new_hash) + .execute(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "更新密码失败").with_source(err))?; + + Ok(Json(Envelope { + success: true, + data: MessageResponse { + message: "密码已更新,请重新登录以确保安全".to_string(), + }, + })) +} + +#[derive(Debug, Deserialize)] +struct HistoryQuery { + page: Option, + limit: Option, + status: Option, +} + +#[derive(Debug, Serialize)] +struct HistoryFileView { + file_id: Uuid, + original_name: String, + original_size: i64, + compressed_size: Option, + saved_percent: Option, + status: String, + output_format: String, + error_message: Option, + download_url: Option, +} + +#[derive(Debug, Serialize)] +struct HistoryTaskView { + task_id: Uuid, + status: String, + source: String, + progress: i32, + total_files: i32, + completed_files: i32, + failed_files: i32, + created_at: DateTime, + completed_at: Option>, + expires_at: DateTime, + download_all_url: Option, + files: Vec, +} + +#[derive(Debug, Serialize)] +struct HistoryResponse { + tasks: Vec, + page: u32, + limit: u32, + total: i64, +} + +async fn list_history( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Query(query): Query, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?; + + let user_id = match principal { + context::Principal::User { user_id, .. } => user_id, + _ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")), + }; + + let limit = query.limit.unwrap_or(20).clamp(1, 100); + let page = query.page.unwrap_or(1).max(1); + let offset = (page - 1) * limit; + let status = query.status.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + + let total: i64 = if let Some(status) = &status { + sqlx::query_scalar( + "SELECT COUNT(*) FROM tasks WHERE user_id = $1 AND status::text = $2", + ) + .bind(user_id) + .bind(status) + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询历史失败").with_source(err))? + } else { + sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE user_id = $1") + .bind(user_id) + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询历史失败").with_source(err))? + }; + + #[derive(Debug, FromRow)] + struct TaskRow { + id: Uuid, + status: String, + source: String, + total_files: i32, + completed_files: i32, + failed_files: i32, + created_at: DateTime, + completed_at: Option>, + expires_at: DateTime, + } + + let tasks: Vec = if let Some(status) = &status { + sqlx::query_as::<_, TaskRow>( + r#" + SELECT + id, + status::text AS status, + source::text AS source, + total_files, + completed_files, + failed_files, + created_at, + completed_at, + expires_at + FROM tasks + WHERE user_id = $1 AND status::text = $2 + ORDER BY created_at DESC + LIMIT $3 OFFSET $4 + "#, + ) + .bind(user_id) + .bind(status) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询历史失败").with_source(err))? + } else { + sqlx::query_as::<_, TaskRow>( + r#" + SELECT + id, + status::text AS status, + source::text AS source, + total_files, + completed_files, + failed_files, + created_at, + completed_at, + expires_at + FROM tasks + WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(user_id) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询历史失败").with_source(err))? + }; + + #[derive(Debug, FromRow)] + struct FileRow { + id: Uuid, + original_name: String, + original_size: i64, + compressed_size: Option, + saved_percent: Option, + status: String, + output_format: String, + error_message: Option, + storage_path: Option, + } + + let now = Utc::now(); + let mut result_tasks = Vec::with_capacity(tasks.len()); + for task in tasks { + let files: Vec = sqlx::query_as::<_, FileRow>( + r#" + SELECT + id, + original_name, + original_size, + compressed_size, + saved_percent::float8 AS saved_percent, + status::text AS status, + output_format, + error_message, + storage_path + FROM task_files + WHERE task_id = $1 + ORDER BY created_at ASC + "#, + ) + .bind(task.id) + .fetch_all(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询任务文件失败").with_source(err))?; + + let file_views = files + .into_iter() + .map(|file| HistoryFileView { + file_id: file.id, + original_name: file.original_name, + original_size: file.original_size, + compressed_size: file.compressed_size, + saved_percent: file.saved_percent, + status: file.status.clone(), + output_format: file.output_format, + error_message: file.error_message, + download_url: if file.status == "completed" && file.storage_path.is_some() && task.expires_at > now { + Some(format!("/downloads/{}", file.id)) + } else { + None + }, + }) + .collect::>(); + + let progress = if task.total_files > 0 { + ((task.completed_files + task.failed_files) * 100 / task.total_files).clamp(0, 100) + } else { + 0 + }; + + let download_all_url = if task.status == "completed" && task.expires_at > now { + Some(format!("/downloads/tasks/{}", task.id)) + } else { + None + }; + + result_tasks.push(HistoryTaskView { + task_id: task.id, + status: task.status, + source: task.source, + progress, + total_files: task.total_files, + completed_files: task.completed_files, + failed_files: task.failed_files, + created_at: task.created_at, + completed_at: task.completed_at, + expires_at: task.expires_at, + download_all_url, + files: file_views, + }); + } + + Ok(Json(Envelope { + success: true, + data: HistoryResponse { + tasks: result_tasks, + page, + limit, + total, + }, + })) +} + +async fn list_api_keys( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?; + + let (user_id, _email_verified) = match principal { + context::Principal::User { + user_id, + email_verified, + .. + } => (user_id, email_verified), + _ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")), + }; + + let rows = sqlx::query_as::<_, ApiKeyView>( + r#" + SELECT + id, + name, + key_prefix, + permissions, + rate_limit, + is_active, + last_used_at, + last_used_ip::text AS last_used_ip, + created_at + FROM api_keys + WHERE user_id = $1 + ORDER BY created_at DESC + "#, + ) + .bind(user_id) + .fetch_all(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询 API Key 失败").with_source(err))?; + + Ok(Json(Envelope { + success: true, + data: ApiKeyListResponse { api_keys: rows }, + })) +} + +#[derive(Debug, Deserialize)] +struct CreateApiKeyRequest { + name: String, + permissions: Option>, +} + +#[derive(Debug, Serialize)] +struct CreateApiKeyResponse { + id: Uuid, + name: String, + key_prefix: String, + key: String, + message: String, +} + +async fn create_api_key( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Json(req): Json, +) -> Result>, AppError> { + if req.name.trim().is_empty() || req.name.len() > 100 { + return Err(AppError::new(ErrorCode::InvalidRequest, "name 不合法")); + } + + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?; + + let (user_id, email_verified) = match principal { + context::Principal::User { + user_id, + email_verified, + .. + } => (user_id, email_verified), + _ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")), + }; + + if !email_verified { + return Err(AppError::new(ErrorCode::EmailNotVerified, "请先验证邮箱")); + } + + let billing = billing::get_user_billing(&state, user_id).await?; + if !billing.plan.feature_api_enabled { + return Err(AppError::new(ErrorCode::Forbidden, "当前套餐未开通 API Key")); + } + + let permissions = normalize_permissions(req.permissions)?; + + let (full_key, key_prefix) = generate_api_key(); + let key_hash = context::api_key_hash(&full_key, &state.config.api_key_pepper)?; + + let row_id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO api_keys (user_id, name, key_prefix, key_hash, permissions, rate_limit) + VALUES ($1, $2, $3, $4, $5, 100) + RETURNING id + "#, + ) + .bind(user_id) + .bind(req.name.trim()) + .bind(&key_prefix) + .bind(key_hash) + .bind(&permissions) + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "创建 API Key 失败").with_source(err))?; + + Ok(Json(Envelope { + success: true, + data: CreateApiKeyResponse { + id: row_id, + name: req.name.trim().to_string(), + key_prefix, + key: full_key, + message: "请保存此 Key,它只会显示一次".to_string(), + }, + })) +} + +async fn disable_api_key( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Path(key_id): Path, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?; + + let user_id = match principal { + context::Principal::User { user_id, .. } => user_id, + _ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")), + }; + + let result = sqlx::query("UPDATE api_keys SET is_active = false WHERE id = $1 AND user_id = $2") + .bind(key_id) + .bind(user_id) + .execute(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "更新 API Key 失败").with_source(err))?; + + if result.rows_affected() == 0 { + return Err(AppError::new(ErrorCode::NotFound, "API Key 不存在")); + } + + Ok(Json(Envelope { + success: true, + data: serde_json::json!({ "message": "已禁用" }), + })) +} + +async fn rotate_api_key( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Path(key_id): Path, +) -> Result>, AppError> { + let ip = context::client_ip(&headers, addr.ip()); + let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?; + + let (user_id, email_verified) = match principal { + context::Principal::User { + user_id, + email_verified, + .. + } => (user_id, email_verified), + _ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")), + }; + + if !email_verified { + return Err(AppError::new(ErrorCode::EmailNotVerified, "请先验证邮箱")); + } + + let (full_key, key_prefix) = generate_api_key(); + let key_hash = context::api_key_hash(&full_key, &state.config.api_key_pepper)?; + + #[derive(Debug, FromRow)] + struct RotateRow { + id: Uuid, + name: String, + } + + let row = sqlx::query_as::<_, RotateRow>( + r#" + UPDATE api_keys + SET key_prefix = $1, + key_hash = $2, + is_active = true + WHERE id = $3 AND user_id = $4 + RETURNING id, name + "#, + ) + .bind(&key_prefix) + .bind(key_hash) + .bind(key_id) + .bind(user_id) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "更新 API Key 失败").with_source(err))? + .ok_or_else(|| AppError::new(ErrorCode::NotFound, "API Key 不存在"))?; + + Ok(Json(Envelope { + success: true, + data: CreateApiKeyResponse { + id: row.id, + name: row.name, + key_prefix, + key: full_key, + message: "请保存此 Key,它只会显示一次".to_string(), + }, + })) +} + +fn generate_api_key() -> (String, String) { + let mut prefix_bytes = [0u8; 4]; + rand::rngs::OsRng.fill_bytes(&mut prefix_bytes); + let prefix = hex::encode(prefix_bytes); + let key_prefix = format!("if_live_{prefix}"); + + let mut secret_bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut secret_bytes); + let secret = URL_SAFE_NO_PAD.encode(secret_bytes); + + let full = format!("{key_prefix}_{secret}"); + (full, key_prefix) +} + +fn normalize_permissions(input: Option>) -> Result { + let allowed = ["compress", "batch_compress", "read_stats", "billing_read", "webhook_manage"]; + + let mut perms = Vec::::new(); + if let Some(values) = input { + for value in values { + let v = value.trim().to_ascii_lowercase(); + if v.is_empty() { + continue; + } + if !allowed.contains(&v.as_str()) { + return Err(AppError::new( + ErrorCode::InvalidRequest, + format!("不支持的权限: {v}"), + )); + } + if !perms.contains(&v) { + perms.push(v); + } + } + } + + if perms.is_empty() { + perms.push("compress".to_string()); + } + + Ok(serde_json::json!(perms)) +} + +fn validate_email(email: &str) -> Result<(), AppError> { + if email.trim().is_empty() || !email.contains('@') { + return Err(AppError::new(ErrorCode::InvalidRequest, "邮箱格式不正确")); + } + if email.len() > 255 { + return Err(AppError::new(ErrorCode::InvalidRequest, "邮箱过长")); + } + Ok(()) +} + +fn validate_username(username: &str) -> Result<(), AppError> { + if username.trim().is_empty() { + return Err(AppError::new(ErrorCode::InvalidRequest, "用户名不能为空")); + } + if username.len() > 50 { + return Err(AppError::new(ErrorCode::InvalidRequest, "用户名过长")); + } + Ok(()) +} + +fn validate_password(password: &str) -> Result<(), AppError> { + if password.len() < 8 { + return Err(AppError::new(ErrorCode::InvalidRequest, "密码至少 8 位")); + } + if password.len() > 128 { + return Err(AppError::new(ErrorCode::InvalidRequest, "密码过长")); + } + Ok(()) +} + +fn hash_password(password: &str) -> Result { + let salt = argon2::password_hash::SaltString::generate(&mut rand::rngs::OsRng); + let hashed = Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map_err(|err| AppError::new(ErrorCode::Internal, "密码哈希失败").with_source(err))?; + Ok(hashed.to_string()) +} + +fn verify_password(password: &str, password_hash: &str) -> Result<(), AppError> { + let parsed = PasswordHash::new(password_hash) + .map_err(|err| AppError::new(ErrorCode::Internal, "密码哈希格式错误").with_source(err))?; + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .map_err(|_| AppError::new(ErrorCode::Unauthorized, "密码错误"))?; + Ok(()) +} + +fn generate_token() -> String { + let mut bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +fn sha256_hex(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + hex::encode(hasher.finalize()) +} + +fn map_unique_violation(err: sqlx::Error) -> AppError { + if let sqlx::Error::Database(db_err) = &err { + if let Some(code) = db_err.code() { + if code == "23505" { + return AppError::new(ErrorCode::InvalidRequest, "邮箱或用户名已存在"); + } + } + } + AppError::new(ErrorCode::Internal, "数据库操作失败").with_source(err) +} diff --git a/src/api/webhooks.rs b/src/api/webhooks.rs new file mode 100644 index 0000000..bb123bb --- /dev/null +++ b/src/api/webhooks.rs @@ -0,0 +1,520 @@ +use crate::api::envelope::Envelope; +use crate::error::{AppError, ErrorCode}; +use crate::services::settings; +use crate::state::AppState; + +use axum::body::Bytes; +use axum::extract::State; +use axum::http::HeaderMap; +use axum::routing::post; +use axum::{Json, Router}; +use chrono::{TimeZone, Utc}; +use hmac::{Hmac, Mac}; +use serde::Deserialize; +use sha2::Sha256; + +pub fn router() -> Router { + Router::new().route("/webhooks/stripe", post(stripe_webhook)) +} + +#[derive(Debug, Deserialize)] +struct StripeEvent { + id: String, + #[serde(rename = "type")] + type_: String, + data: StripeEventData, +} + +#[derive(Debug, Deserialize)] +struct StripeEventData { + object: serde_json::Value, +} + +async fn stripe_webhook( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result>, AppError> { + let secret = settings::get_stripe_webhook_secret(&state) + .await + .map_err(|err| err.with_source("stripe webhook secret not configured"))?; + + let sig = headers + .get("Stripe-Signature") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "缺少 Stripe-Signature"))?; + + verify_stripe_signature(&body, sig, &secret)?; + + let payload_str = std::str::from_utf8(&body) + .map_err(|_| AppError::new(ErrorCode::InvalidRequest, "Webhook payload 非 UTF-8"))?; + let event: StripeEvent = serde_json::from_str(payload_str) + .map_err(|err| AppError::new(ErrorCode::InvalidRequest, "Webhook JSON 解析失败").with_source(err))?; + + let inserted: Option = sqlx::query_scalar( + r#" + INSERT INTO webhook_events (provider, provider_event_id, event_type, payload) + VALUES ('stripe', $1, $2, $3) + ON CONFLICT (provider, provider_event_id) DO NOTHING + RETURNING provider_event_id + "#, + ) + .bind(&event.id) + .bind(&event.type_) + .bind(&event.data.object) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "Webhook 入库失败").with_source(err))?; + + if inserted.is_none() { + return Ok(Json(Envelope { + success: true, + data: serde_json::json!({ "status": "duplicate" }), + })); + } + + if let Err(err) = process_stripe_event(&state, &event).await { + let _ = sqlx::query( + "UPDATE webhook_events SET status = 'failed', error_message = $2, processed_at = NOW() WHERE provider = 'stripe' AND provider_event_id = $1", + ) + .bind(&event.id) + .bind(err.to_string()) + .execute(&state.db) + .await; + + return Err(err); + } + + let _ = sqlx::query( + "UPDATE webhook_events SET status = 'processed', processed_at = NOW() WHERE provider = 'stripe' AND provider_event_id = $1", + ) + .bind(&event.id) + .execute(&state.db) + .await; + + Ok(Json(Envelope { + success: true, + data: serde_json::json!({ "status": "ok" }), + })) +} + +fn verify_stripe_signature(payload: &[u8], sig_header: &str, secret: &str) -> Result<(), AppError> { + let mut timestamp: Option = None; + let mut signatures = Vec::::new(); + + for part in sig_header.split(',') { + let part = part.trim(); + if let Some(v) = part.strip_prefix("t=") { + timestamp = v.parse::().ok(); + } else if let Some(v) = part.strip_prefix("v1=") { + signatures.push(v.to_string()); + } + } + + let Some(ts) = timestamp else { + return Err(AppError::new(ErrorCode::InvalidRequest, "Stripe-Signature 缺少 t")); + }; + if signatures.is_empty() { + return Err(AppError::new(ErrorCode::InvalidRequest, "Stripe-Signature 缺少 v1")); + } + + // 5 minutes tolerance + let now = Utc::now().timestamp(); + if (now - ts).abs() > 300 { + return Err(AppError::new(ErrorCode::InvalidRequest, "Webhook 时间戳过期")); + } + + type HmacSha256 = Hmac; + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) + .map_err(|err| AppError::new(ErrorCode::Internal, "Webhook secret 错误").with_source(err))?; + mac.update(ts.to_string().as_bytes()); + mac.update(b"."); + mac.update(payload); + let expected = hex::encode(mac.finalize().into_bytes()); + + if signatures.iter().any(|sig| secure_eq(sig, &expected)) { + Ok(()) + } else { + Err(AppError::new(ErrorCode::InvalidRequest, "Webhook 验签失败")) + } +} + +fn secure_eq(a: &str, b: &str) -> bool { + if a.len() != b.len() { + return false; + } + let mut out = 0u8; + for (x, y) in a.as_bytes().iter().zip(b.as_bytes().iter()) { + out |= x ^ y; + } + out == 0 +} + +async fn process_stripe_event(state: &AppState, event: &StripeEvent) -> Result<(), AppError> { + match event.type_.as_str() { + "checkout.session.completed" => { + map_checkout_session_completed(state, &event.data.object).await + } + "customer.subscription.created" | "customer.subscription.updated" => { + upsert_subscription(state, &event.data.object).await + } + "customer.subscription.deleted" => cancel_subscription(state, &event.data.object).await, + "invoice.paid" | "invoice.payment_failed" => upsert_invoice(state, &event.data.object).await, + _ => Ok(()), + } +} + +async fn map_checkout_session_completed( + state: &AppState, + object: &serde_json::Value, +) -> Result<(), AppError> { + let customer_id = object + .get("customer") + .and_then(|v| v.as_str()) + .filter(|v| !v.trim().is_empty()); + + let user_id = object + .get("client_reference_id") + .and_then(|v| v.as_str()) + .and_then(|v| v.parse::().ok()) + .or_else(|| { + object + .pointer("/metadata/user_id") + .and_then(|v| v.as_str()) + .and_then(|v| v.parse::().ok()) + }); + + let Some(customer_id) = customer_id else { + tracing::warn!("checkout.session.completed missing customer"); + return Ok(()); + }; + let Some(user_id) = user_id else { + tracing::warn!(customer = %customer_id, "checkout.session.completed missing user_id"); + return Ok(()); + }; + + let updated = sqlx::query( + r#" + UPDATE users + SET billing_customer_id = $2, + updated_at = NOW() + WHERE id = $1 + AND (billing_customer_id IS NULL OR billing_customer_id = '') + "#, + ) + .bind(user_id) + .bind(customer_id) + .execute(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "更新 Stripe Customer 映射失败").with_source(err))?; + + if updated.rows_affected() == 0 { + let existing: Option = sqlx::query_scalar::<_, Option>( + "SELECT billing_customer_id FROM users WHERE id = $1", + ) + .bind(user_id) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))? + .flatten(); + + if let Some(existing) = existing.filter(|v| !v.trim().is_empty()) { + if existing != customer_id { + tracing::warn!( + user_id = %user_id, + existing_customer = %existing, + new_customer = %customer_id, + "user already mapped to different stripe customer" + ); + } + } else { + tracing::warn!(user_id = %user_id, "user not found for checkout.session.completed"); + } + } + + Ok(()) +} + +async fn upsert_subscription(state: &AppState, object: &serde_json::Value) -> Result<(), AppError> { + let provider_subscription_id = object + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "subscription.id 缺失"))?; + let provider_customer_id = object + .get("customer") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "subscription.customer 缺失"))?; + + let status = object + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("incomplete"); + let mapped_status = map_subscription_status(status); + + let cps = object + .get("current_period_start") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + let cpe = object + .get("current_period_end") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + let current_period_start = Utc.timestamp_opt(cps, 0).single().unwrap_or_else(Utc::now); + let current_period_end = Utc.timestamp_opt(cpe, 0).single().unwrap_or_else(Utc::now); + + let cancel_at_period_end = object + .get("cancel_at_period_end") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let price_id = object + .pointer("/items/data/0/price/id") + .and_then(|v| v.as_str()) + .or_else(|| object.pointer("/items/data/0/plan/id").and_then(|v| v.as_str())) + .ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "subscription.price 缺失"))?; + + let user_id: Option = + sqlx::query_scalar("SELECT id FROM users WHERE billing_customer_id = $1 LIMIT 1") + .bind(provider_customer_id) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?; + + let Some(user_id) = user_id else { + tracing::warn!(customer = %provider_customer_id, "stripe customer not mapped to user"); + return Ok(()); + }; + + let plan_id: Option = + sqlx::query_scalar("SELECT id FROM plans WHERE stripe_price_id = $1 LIMIT 1") + .bind(price_id) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))?; + + let Some(plan_id) = plan_id else { + tracing::warn!(price = %price_id, "stripe price not mapped to plan"); + return Ok(()); + }; + + let updated: Option = sqlx::query_scalar( + r#" + UPDATE subscriptions + SET user_id = $1, + plan_id = $2, + status = $3::subscription_status, + current_period_start = $4, + current_period_end = $5, + cancel_at_period_end = $6, + provider = 'stripe', + provider_customer_id = $7, + updated_at = NOW() + WHERE provider = 'stripe' AND provider_subscription_id = $8 + RETURNING id + "#, + ) + .bind(user_id) + .bind(plan_id) + .bind(mapped_status) + .bind(current_period_start) + .bind(current_period_end) + .bind(cancel_at_period_end) + .bind(provider_customer_id) + .bind(provider_subscription_id) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "更新订阅失败").with_source(err))?; + + if updated.is_none() { + let _ = sqlx::query( + r#" + INSERT INTO subscriptions ( + user_id, plan_id, status, + current_period_start, current_period_end, + cancel_at_period_end, + provider, provider_customer_id, provider_subscription_id + ) VALUES ( + $1, $2, $3::subscription_status, + $4, $5, + $6, + 'stripe', $7, $8 + ) + "#, + ) + .bind(user_id) + .bind(plan_id) + .bind(mapped_status) + .bind(current_period_start) + .bind(current_period_end) + .bind(cancel_at_period_end) + .bind(provider_customer_id) + .bind(provider_subscription_id) + .execute(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "创建订阅失败").with_source(err))?; + } + + Ok(()) +} + +async fn cancel_subscription(state: &AppState, object: &serde_json::Value) -> Result<(), AppError> { + let provider_subscription_id = object + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "subscription.id 缺失"))?; + + let _ = sqlx::query( + r#" + UPDATE subscriptions + SET status = 'canceled', + cancel_at_period_end = false, + canceled_at = NOW(), + updated_at = NOW() + WHERE provider = 'stripe' AND provider_subscription_id = $1 + "#, + ) + .bind(provider_subscription_id) + .execute(&state.db) + .await; + + Ok(()) +} + +async fn upsert_invoice(state: &AppState, object: &serde_json::Value) -> Result<(), AppError> { + let provider_invoice_id = object + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "invoice.id 缺失"))?; + let provider_customer_id = object + .get("customer") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "invoice.customer 缺失"))?; + + let user_id: Option = + sqlx::query_scalar("SELECT id FROM users WHERE billing_customer_id = $1 LIMIT 1") + .bind(provider_customer_id) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?; + + let Some(user_id) = user_id else { + return Ok(()); + }; + + let stripe_status = object.get("status").and_then(|v| v.as_str()).unwrap_or("open"); + let status = map_invoice_status(stripe_status); + + let invoice_number = object + .get("number") + .and_then(|v| v.as_str()) + .filter(|v| !v.trim().is_empty()) + .map(|v| v.to_string()) + .unwrap_or_else(|| format!("stripe_{provider_invoice_id}")); + + let currency = object + .get("currency") + .and_then(|v| v.as_str()) + .unwrap_or("cny") + .to_uppercase(); + let total_amount_cents = object.get("total").and_then(|v| v.as_i64()).unwrap_or(0) as i32; + + let hosted_invoice_url = object.get("hosted_invoice_url").and_then(|v| v.as_str()).map(|v| v.to_string()); + let pdf_url = object.get("invoice_pdf").and_then(|v| v.as_str()).map(|v| v.to_string()); + + let period_start = object.get("period_start").and_then(|v| v.as_i64()).and_then(|ts| Utc.timestamp_opt(ts, 0).single()); + let period_end = object.get("period_end").and_then(|v| v.as_i64()).and_then(|ts| Utc.timestamp_opt(ts, 0).single()); + + let paid_at = object + .pointer("/status_transitions/paid_at") + .and_then(|v| v.as_i64()) + .and_then(|ts| Utc.timestamp_opt(ts, 0).single()); + + let updated = sqlx::query( + r#" + UPDATE invoices + SET status = $1::invoice_status, + currency = $2, + total_amount_cents = $3, + hosted_invoice_url = $4, + pdf_url = $5, + period_start = $6, + period_end = $7, + paid_at = $8 + WHERE provider = 'stripe' AND provider_invoice_id = $9 + "#, + ) + .bind(status) + .bind(¤cy) + .bind(total_amount_cents) + .bind(hosted_invoice_url.as_deref()) + .bind(pdf_url.as_deref()) + .bind(period_start) + .bind(period_end) + .bind(paid_at) + .bind(provider_invoice_id) + .execute(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "更新发票失败").with_source(err))?; + + if updated.rows_affected() == 0 { + let invoice_number = truncate(invoice_number, 50); + let _ = sqlx::query( + r#" + INSERT INTO invoices ( + user_id, invoice_number, status, currency, total_amount_cents, + period_start, period_end, + provider, provider_invoice_id, hosted_invoice_url, pdf_url, + paid_at + ) VALUES ( + $1, $2, $3::invoice_status, $4, $5, + $6, $7, + 'stripe', $8, $9, $10, + $11 + ) + "#, + ) + .bind(user_id) + .bind(invoice_number) + .bind(status) + .bind(¤cy) + .bind(total_amount_cents) + .bind(period_start) + .bind(period_end) + .bind(provider_invoice_id) + .bind(hosted_invoice_url.as_deref()) + .bind(pdf_url.as_deref()) + .bind(paid_at) + .execute(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "创建发票失败").with_source(err))?; + } + + Ok(()) +} + +fn truncate(mut s: String, max: usize) -> String { + if s.len() > max { + s.truncate(max); + } + s +} + +fn map_subscription_status(status: &str) -> &'static str { + match status { + "trialing" => "trialing", + "active" => "active", + "past_due" => "past_due", + "canceled" => "canceled", + _ => "incomplete", + } +} + +fn map_invoice_status(status: &str) -> &'static str { + match status { + "draft" => "draft", + "paid" => "paid", + "void" => "void", + "uncollectible" => "uncollectible", + _ => "open", + } +} diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..d76480c --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,60 @@ +use crate::error::{AppError, ErrorCode}; + +use axum::http::HeaderMap; +use chrono::{DateTime, Duration, Utc}; +use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Claims { + pub sub: Uuid, + pub role: String, + pub exp: usize, +} + +pub fn issue_jwt( + jwt_secret: &str, + jwt_expiry_hours: i64, + user_id: Uuid, + role: &str, +) -> Result<(String, DateTime), AppError> { + let expires_at = Utc::now() + Duration::hours(jwt_expiry_hours); + let claims = Claims { + sub: user_id, + role: role.to_string(), + exp: expires_at.timestamp() as usize, + }; + + let token = jsonwebtoken::encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(jwt_secret.as_bytes()), + ) + .map_err(|err| AppError::new(ErrorCode::Internal, "生成 Token 失败").with_source(err))?; + + Ok((token, expires_at)) +} + +pub fn require_jwt(jwt_secret: &str, headers: &HeaderMap) -> Result { + let auth = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let token = auth.strip_prefix("Bearer ").ok_or_else(|| { + AppError::new(ErrorCode::Unauthorized, "缺少 Authorization: Bearer ") + })?; + + decode_jwt(jwt_secret, token) +} + +pub fn decode_jwt(jwt_secret: &str, token: &str) -> Result { + jsonwebtoken::decode::( + token, + &DecodingKey::from_secret(jwt_secret.as_bytes()), + &Validation::default(), + ) + .map(|data| data.claims) + .map_err(|_| AppError::new(ErrorCode::Unauthorized, "Token 无效或已过期")) +} + diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..d0bda07 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,161 @@ +use crate::error::{AppError, ErrorCode}; + +#[derive(Debug, Clone)] +pub struct Config { + pub role: String, + pub host: String, + pub port: u16, + pub public_base_url: String, + + pub database_url: String, + pub database_max_connections: u32, + + pub redis_url: String, + + pub jwt_secret: String, + pub jwt_expiry_hours: i64, + + pub api_key_pepper: String, + + pub billing_provider: String, + pub stripe_secret_key: Option, + pub stripe_webhook_secret: Option, + + pub storage_type: String, + pub storage_path: String, + pub signed_url_ttl_minutes: u64, + + pub allow_anonymous_upload: bool, + pub anon_max_file_size_mb: u64, + pub anon_max_files_per_batch: u32, + pub anon_daily_units: u32, + pub anon_retention_hours: u64, + + pub max_image_pixels: u64, + pub idempotency_ttl_hours: u64, + + pub mail_enabled: bool, + pub mail_log_links_when_disabled: bool, + pub mail_provider: String, + pub mail_from: String, + pub mail_password: String, + pub mail_from_name: String, + pub mail_smtp_host: Option, + pub mail_smtp_port: Option, + pub mail_smtp_encryption: Option, +} + +impl Config { + pub fn from_env() -> Result { + let role = env_string("IMAGEFORGE_ROLE").unwrap_or_else(|| "api".to_string()); + let host = env_string("HOST").unwrap_or_else(|| "0.0.0.0".to_string()); + let port = env_u16("PORT").unwrap_or(8080); + let public_base_url = + env_string("PUBLIC_BASE_URL").unwrap_or_else(|| "http://localhost:8080".to_string()); + + let database_url = env_string("DATABASE_URL") + .ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "缺少环境变量 DATABASE_URL"))?; + let database_max_connections = env_u32("DATABASE_MAX_CONNECTIONS").unwrap_or(10); + + let redis_url = env_string("REDIS_URL") + .ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "缺少环境变量 REDIS_URL"))?; + + let jwt_secret = env_string("JWT_SECRET") + .ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "缺少环境变量 JWT_SECRET"))?; + let jwt_expiry_hours = env_i64("JWT_EXPIRY_HOURS").unwrap_or(168); + + let api_key_pepper = env_string("API_KEY_PEPPER") + .ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "缺少环境变量 API_KEY_PEPPER"))?; + + let billing_provider = + env_string("BILLING_PROVIDER").unwrap_or_else(|| "stripe".to_string()); + let stripe_secret_key = env_string("STRIPE_SECRET_KEY"); + let stripe_webhook_secret = env_string("STRIPE_WEBHOOK_SECRET"); + + let storage_type = env_string("STORAGE_TYPE").unwrap_or_else(|| "local".to_string()); + let storage_path = env_string("STORAGE_PATH").unwrap_or_else(|| "./uploads".to_string()); + let signed_url_ttl_minutes = env_u64("SIGNED_URL_TTL_MINUTES").unwrap_or(60); + + let allow_anonymous_upload = env_bool("ALLOW_ANONYMOUS_UPLOAD").unwrap_or(true); + let anon_max_file_size_mb = env_u64("ANON_MAX_FILE_SIZE_MB").unwrap_or(5); + let anon_max_files_per_batch = env_u32("ANON_MAX_FILES_PER_BATCH").unwrap_or(5); + let anon_daily_units = env_u32("ANON_DAILY_UNITS").unwrap_or(10); + let anon_retention_hours = env_u64("ANON_RETENTION_HOURS").unwrap_or(24); + + let max_image_pixels = env_u64("MAX_IMAGE_PIXELS").unwrap_or(40_000_000); + let idempotency_ttl_hours = env_u64("IDEMPOTENCY_TTL_HOURS").unwrap_or(24); + + let mail_enabled = env_bool("MAIL_ENABLED").unwrap_or(false); + let mail_log_links_when_disabled = env_bool("MAIL_LOG_LINKS_WHEN_DISABLED").unwrap_or(false); + let mail_provider = env_string("MAIL_PROVIDER").unwrap_or_else(|| "qq".to_string()); + let mail_from = env_string("MAIL_FROM").unwrap_or_else(|| "noreply@example.com".to_string()); + let mail_password = env_string("MAIL_PASSWORD").unwrap_or_default(); + let mail_from_name = env_string("MAIL_FROM_NAME").unwrap_or_else(|| "ImageForge".to_string()); + let mail_smtp_host = env_string("MAIL_SMTP_HOST"); + let mail_smtp_port = env_u16("MAIL_SMTP_PORT"); + let mail_smtp_encryption = env_string("MAIL_SMTP_ENCRYPTION"); + + Ok(Self { + role, + host, + port, + public_base_url, + database_url, + database_max_connections, + redis_url, + jwt_secret, + jwt_expiry_hours, + api_key_pepper, + billing_provider, + stripe_secret_key, + stripe_webhook_secret, + storage_type, + storage_path, + signed_url_ttl_minutes, + allow_anonymous_upload, + anon_max_file_size_mb, + anon_max_files_per_batch, + anon_daily_units, + anon_retention_hours, + max_image_pixels, + idempotency_ttl_hours, + mail_enabled, + mail_log_links_when_disabled, + mail_provider, + mail_from, + mail_password, + mail_from_name, + mail_smtp_host, + mail_smtp_port, + mail_smtp_encryption, + }) + } +} + +fn env_string(key: &str) -> Option { + std::env::var(key).ok().filter(|value| !value.trim().is_empty()) +} + +fn env_u16(key: &str) -> Option { + env_string(key).and_then(|v| v.parse::().ok()) +} + +fn env_u32(key: &str) -> Option { + env_string(key).and_then(|v| v.parse::().ok()) +} + +fn env_i64(key: &str) -> Option { + env_string(key).and_then(|v| v.parse::().ok()) +} + +fn env_u64(key: &str) -> Option { + env_string(key).and_then(|v| v.parse::().ok()) +} + +fn env_bool(key: &str) -> Option { + env_string(key).and_then(|v| match v.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "y" | "on" => Some(true), + "0" | "false" | "no" | "n" | "off" => Some(false), + _ => None, + }) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..f2374eb --- /dev/null +++ b/src/error.rs @@ -0,0 +1,136 @@ +use axum::{http::StatusCode, response::IntoResponse, Json}; +use serde::Serialize; +use std::fmt::{Display, Formatter}; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ErrorCode { + InvalidRequest, + InvalidImage, + UnsupportedFormat, + TooManyPixels, + FileTooLarge, + InvalidToken, + Unauthorized, + Forbidden, + NotFound, + IdempotencyConflict, + RateLimited, + QuotaExceeded, + EmailNotVerified, + CompressionFailed, + StorageUnavailable, + MailSendFailed, + Internal, +} + +#[derive(Debug)] +pub struct AppError { + pub code: ErrorCode, + pub message: String, + pub source: Option, +} + +impl AppError { + pub fn new(code: ErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + source: None, + } + } + + pub fn with_source(mut self, err: impl std::fmt::Display) -> Self { + self.source = Some(err.to_string()); + self + } +} + +impl Display for AppError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.code.as_str(), self.message) + } +} + +impl std::error::Error for AppError {} + +impl ErrorCode { + pub fn as_str(&self) -> &'static str { + match self { + ErrorCode::InvalidRequest => "INVALID_REQUEST", + ErrorCode::InvalidImage => "INVALID_IMAGE", + ErrorCode::UnsupportedFormat => "UNSUPPORTED_FORMAT", + ErrorCode::TooManyPixels => "TOO_MANY_PIXELS", + ErrorCode::FileTooLarge => "FILE_TOO_LARGE", + ErrorCode::InvalidToken => "INVALID_TOKEN", + ErrorCode::Unauthorized => "UNAUTHORIZED", + ErrorCode::Forbidden => "FORBIDDEN", + ErrorCode::NotFound => "NOT_FOUND", + ErrorCode::IdempotencyConflict => "IDEMPOTENCY_CONFLICT", + ErrorCode::RateLimited => "RATE_LIMITED", + ErrorCode::QuotaExceeded => "QUOTA_EXCEEDED", + ErrorCode::EmailNotVerified => "EMAIL_NOT_VERIFIED", + ErrorCode::CompressionFailed => "COMPRESSION_FAILED", + ErrorCode::StorageUnavailable => "STORAGE_UNAVAILABLE", + ErrorCode::MailSendFailed => "MAIL_SEND_FAILED", + ErrorCode::Internal => "INTERNAL", + } + } +} + +#[derive(Debug, Serialize)] +struct ErrorEnvelope { + success: bool, + error: ErrorPayload, +} + +#[derive(Debug, Serialize)] +struct ErrorPayload { + code: ErrorCode, + message: String, + request_id: String, +} + +impl IntoResponse for AppError { + fn into_response(self) -> axum::response::Response { + let request_id = format!("req_{}", Uuid::new_v4()); + + if let Some(source) = &self.source { + tracing::error!(code = %self.code.as_str(), request_id = %request_id, message = %self.message, source = %source); + } else { + tracing::error!(code = %self.code.as_str(), request_id = %request_id, message = %self.message); + } + + let status = match self.code { + ErrorCode::InvalidRequest => StatusCode::BAD_REQUEST, + ErrorCode::InvalidImage => StatusCode::BAD_REQUEST, + ErrorCode::UnsupportedFormat => StatusCode::BAD_REQUEST, + ErrorCode::TooManyPixels => StatusCode::BAD_REQUEST, + ErrorCode::FileTooLarge => StatusCode::PAYLOAD_TOO_LARGE, + ErrorCode::InvalidToken => StatusCode::BAD_REQUEST, + ErrorCode::Unauthorized => StatusCode::UNAUTHORIZED, + ErrorCode::Forbidden => StatusCode::FORBIDDEN, + ErrorCode::NotFound => StatusCode::NOT_FOUND, + ErrorCode::IdempotencyConflict => StatusCode::CONFLICT, + ErrorCode::RateLimited => StatusCode::TOO_MANY_REQUESTS, + ErrorCode::QuotaExceeded => StatusCode::PAYMENT_REQUIRED, + ErrorCode::EmailNotVerified => StatusCode::FORBIDDEN, + ErrorCode::CompressionFailed => StatusCode::INTERNAL_SERVER_ERROR, + ErrorCode::StorageUnavailable => StatusCode::SERVICE_UNAVAILABLE, + ErrorCode::MailSendFailed => StatusCode::INTERNAL_SERVER_ERROR, + ErrorCode::Internal => StatusCode::INTERNAL_SERVER_ERROR, + }; + + let body = ErrorEnvelope { + success: false, + error: ErrorPayload { + code: self.code, + message: self.message, + request_id, + }, + }; + + (status, Json(body)).into_response() + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..6f5ea87 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,64 @@ +mod api; +mod auth; +mod config; +mod error; +mod services; +mod state; +mod worker; + +use crate::config::Config; +use crate::error::{AppError, ErrorCode}; +use crate::services::mail::Mailer; +use crate::state::AppState; + +use sqlx::postgres::PgPoolOptions; +use tracing::Level; + +#[tokio::main] +async fn main() -> Result<(), AppError> { + dotenvy::dotenv().ok(); + init_tracing(); + + let config = Config::from_env()?; + let mailer = Mailer::new(&config)?; + + let db = PgPoolOptions::new() + .max_connections(config.database_max_connections) + .connect(&config.database_url) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "数据库连接失败").with_source(err))?; + + let redis = redis::Client::open(config.redis_url.clone()) + .map_err(|err| AppError::new(ErrorCode::Internal, "Redis 配置错误").with_source(err))? + .get_connection_manager() + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "Redis 连接失败").with_source(err))?; + + let state = AppState { + config, + db, + redis, + mailer: std::sync::Arc::new(mailer), + }; + + match state.config.role.as_str() { + "api" => api::run(state).await, + "worker" => worker::run(state).await, + other => Err(AppError::new( + ErrorCode::InvalidRequest, + format!("未知 IMAGEFORGE_ROLE: {other}(仅支持 api/worker)"), + )), + } +} + +fn init_tracing() { + let env_filter = + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { + tracing_subscriber::EnvFilter::new("info,tower_http=info,imageforge=info") + }); + + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_max_level(Level::INFO) + .init(); +} diff --git a/src/services/billing.rs b/src/services/billing.rs new file mode 100644 index 0000000..9005501 --- /dev/null +++ b/src/services/billing.rs @@ -0,0 +1,135 @@ +use crate::error::{AppError, ErrorCode}; +use crate::state::AppState; + +use chrono::{DateTime, Datelike, TimeZone, Utc}; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Debug, Clone)] +pub struct Plan { + pub id: Uuid, + pub code: String, + pub included_units_per_period: i32, + pub max_file_size_mb: i32, + pub max_files_per_batch: i32, + pub retention_days: i32, + pub feature_api_enabled: bool, +} + +#[derive(Debug, Clone)] +pub struct BillingContext { + pub user_id: Uuid, + pub subscription_id: Option, + pub plan: Plan, + pub period_start: DateTime, + pub period_end: DateTime, +} + +#[derive(Debug, FromRow)] +struct SubscriptionRow { + id: Uuid, + status: String, + current_period_start: DateTime, + current_period_end: DateTime, + plan_id: Uuid, +} + +#[derive(Debug, FromRow)] +struct PlanRow { + id: Uuid, + code: String, + included_units_per_period: i32, + max_file_size_mb: i32, + max_files_per_batch: i32, + retention_days: i32, + features: serde_json::Value, +} + +pub async fn get_user_billing(state: &AppState, user_id: Uuid) -> Result { + let subscription = sqlx::query_as::<_, SubscriptionRow>( + r#" + SELECT id, status::text AS status, current_period_start, current_period_end, plan_id + FROM subscriptions + WHERE user_id = $1 + AND status IN ('active', 'trialing', 'past_due') + ORDER BY current_period_end DESC + LIMIT 1 + "#, + ) + .bind(user_id) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询订阅失败").with_source(err))?; + + let (subscription_id, period_start, period_end, plan_id) = if let Some(sub) = subscription { + if sub.status == "past_due" { + return Err(AppError::new( + ErrorCode::Forbidden, + "订阅欠费,请先完成支付", + )); + } + (Some(sub.id), sub.current_period_start, sub.current_period_end, sub.plan_id) + } else { + let plan_id: Uuid = sqlx::query_scalar("SELECT id FROM plans WHERE code = 'free' LIMIT 1") + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "未找到 Free 套餐").with_source(err))?; + let (start, end) = current_month_period_utc8(Utc::now()); + (None, start, end, plan_id) + }; + + let plan_row = sqlx::query_as::<_, PlanRow>( + r#" + SELECT id, code, included_units_per_period, max_file_size_mb, max_files_per_batch, retention_days, features + FROM plans + WHERE id = $1 + "#, + ) + .bind(plan_id) + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))?; + + let feature_api_enabled = plan_row + .features + .get("api") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + Ok(BillingContext { + user_id, + subscription_id, + plan: Plan { + id: plan_row.id, + code: plan_row.code, + included_units_per_period: plan_row.included_units_per_period, + max_file_size_mb: plan_row.max_file_size_mb, + max_files_per_batch: plan_row.max_files_per_batch, + retention_days: plan_row.retention_days, + feature_api_enabled, + }, + period_start, + period_end, + }) +} + +pub fn current_month_period_utc8(now_utc: DateTime) -> (DateTime, DateTime) { + let tz = chrono::FixedOffset::east_opt(8 * 3600).unwrap(); + let now = now_utc.with_timezone(&tz); + let year = now.year(); + let month = now.month(); + + let start = tz.with_ymd_and_hms(year, month, 1, 0, 0, 0).single().unwrap(); + + let (next_year, next_month) = if month == 12 { + (year + 1, 1) + } else { + (year, month + 1) + }; + let end = tz + .with_ymd_and_hms(next_year, next_month, 1, 0, 0, 0) + .single() + .unwrap(); + + (start.with_timezone(&Utc), end.with_timezone(&Utc)) +} diff --git a/src/services/bootstrap.rs b/src/services/bootstrap.rs new file mode 100644 index 0000000..a9fc2dc --- /dev/null +++ b/src/services/bootstrap.rs @@ -0,0 +1,204 @@ +use crate::error::{AppError, ErrorCode}; +use crate::state::AppState; + +use argon2::{Argon2, PasswordHasher}; +use chrono::Utc; +use sqlx::FromRow; +use tracing::{info, warn}; +use uuid::Uuid; + +#[derive(Debug, FromRow)] +struct AdminRow { + id: Uuid, + username: String, + role: String, +} + +pub async fn ensure_admin_user(state: &AppState) -> Result<(), AppError> { + let Some(admin_email) = env_string("ADMIN_EMAIL") else { + return Ok(()); + }; + let Some(admin_password) = env_string("ADMIN_PASSWORD") else { + return Ok(()); + }; + + let admin_email = admin_email.trim().to_lowercase(); + let admin_password = admin_password.trim().to_string(); + if admin_email.is_empty() || admin_password.is_empty() { + return Ok(()); + } + + let admin_username = env_string("ADMIN_USERNAME").unwrap_or_else(|| { + admin_email + .split('@') + .next() + .unwrap_or("admin") + .to_string() + }); + let admin_username = admin_username.trim().to_string(); + + validate_email(&admin_email)?; + validate_username(&admin_username)?; + validate_password(&admin_password)?; + + let existing = sqlx::query_as::<_, AdminRow>( + r#" + SELECT id, username, role::text AS role + FROM users + WHERE email = $1 + "#, + ) + .bind(&admin_email) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询管理员账号失败").with_source(err))?; + + let password_hash = hash_password(&admin_password)?; + + if let Some(row) = existing { + sqlx::query( + r#" + UPDATE users + SET password_hash = $1, + role = 'admin', + is_active = true, + email_verified_at = COALESCE(email_verified_at, NOW()), + updated_at = NOW() + WHERE id = $2 + "#, + ) + .bind(&password_hash) + .bind(row.id) + .execute(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "更新管理员账号失败").with_source(err))?; + + if row.username != admin_username { + let name_taken: bool = sqlx::query_scalar( + r#" + SELECT EXISTS( + SELECT 1 FROM users WHERE username = $1 AND id <> $2 + ) + "#, + ) + .bind(&admin_username) + .bind(row.id) + .fetch_one(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "校验管理员用户名失败").with_source(err))?; + + if name_taken { + warn!( + admin_email = %admin_email, + admin_username = %admin_username, + "管理员用户名已被占用,保留原用户名" + ); + } else { + sqlx::query( + r#" + UPDATE users + SET username = $1, updated_at = NOW() + WHERE id = $2 + "#, + ) + .bind(&admin_username) + .bind(row.id) + .execute(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "更新管理员用户名失败").with_source(err))?; + } + } + + if row.role != "admin" { + info!(admin_email = %admin_email, "管理员权限已启用"); + } + } else { + sqlx::query( + r#" + INSERT INTO users (email, username, password_hash, role, email_verified_at) + VALUES ($1, $2, $3, 'admin', $4) + "#, + ) + .bind(&admin_email) + .bind(&admin_username) + .bind(&password_hash) + .bind(Utc::now()) + .execute(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "创建管理员账号失败").with_source(err))?; + + info!( + admin_email = %admin_email, + admin_username = %admin_username, + "管理员账号已创建" + ); + } + + Ok(()) +} + +pub async fn ensure_schema(state: &AppState) -> Result<(), AppError> { + sqlx::query( + "ALTER TABLE tasks ADD COLUMN IF NOT EXISTS compression_rate SMALLINT", + ) + .execute(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "初始化数据库结构失败").with_source(err))?; + + sqlx::query( + "ALTER TABLE usage_periods ADD COLUMN IF NOT EXISTS bonus_units INTEGER NOT NULL DEFAULT 0", + ) + .execute(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "初始化数据库结构失败").with_source(err))?; + + let _ = sqlx::query( + "UPDATE usage_periods SET bonus_units = bonus_units + ABS(used_units), used_units = 0 WHERE used_units < 0", + ) + .execute(&state.db) + .await; + + Ok(()) +} + +fn validate_email(email: &str) -> Result<(), AppError> { + if email.trim().is_empty() || !email.contains('@') { + return Err(AppError::new(ErrorCode::InvalidRequest, "管理员邮箱格式不正确")); + } + if email.len() > 255 { + return Err(AppError::new(ErrorCode::InvalidRequest, "管理员邮箱过长")); + } + Ok(()) +} + +fn validate_username(username: &str) -> Result<(), AppError> { + if username.trim().is_empty() { + return Err(AppError::new(ErrorCode::InvalidRequest, "管理员用户名不能为空")); + } + if username.len() > 50 { + return Err(AppError::new(ErrorCode::InvalidRequest, "管理员用户名过长")); + } + Ok(()) +} + +fn validate_password(password: &str) -> Result<(), AppError> { + if password.len() < 8 { + return Err(AppError::new(ErrorCode::InvalidRequest, "管理员密码至少 8 位")); + } + if password.len() > 128 { + return Err(AppError::new(ErrorCode::InvalidRequest, "管理员密码过长")); + } + Ok(()) +} + +fn hash_password(password: &str) -> Result { + let salt = argon2::password_hash::SaltString::generate(&mut rand::rngs::OsRng); + let hashed = Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map_err(|err| AppError::new(ErrorCode::Internal, "密码哈希失败").with_source(err))?; + Ok(hashed.to_string()) +} + +fn env_string(key: &str) -> Option { + std::env::var(key).ok().filter(|value| !value.trim().is_empty()) +} diff --git a/src/services/compress.rs b/src/services/compress.rs new file mode 100644 index 0000000..457f627 --- /dev/null +++ b/src/services/compress.rs @@ -0,0 +1,533 @@ +use crate::error::{AppError, ErrorCode}; +use crate::state::AppState; + +use img_parts::{Bytes as ImgBytes, DynImage, ImageEXIF, ImageICC}; +use image::codecs::bmp::BmpEncoder; +use image::codecs::gif::{GifDecoder, GifEncoder}; +use image::codecs::ico::IcoEncoder; +use image::codecs::jpeg::JpegEncoder; +use image::codecs::png::PngEncoder; +use image::codecs::tiff::TiffEncoder; +use image::{DynamicImage, ExtendedColorType, ImageEncoder}; +use image::{AnimationDecoder, GenericImageView}; +use oxipng::StripChunks; +use rgb::FromSlice; +use std::io::Cursor; + +#[derive(Debug, Clone, Copy)] +pub enum CompressionLevel { + High, + Medium, + Low, +} + +impl CompressionLevel { + pub fn as_str(self) -> &'static str { + match self { + Self::High => "high", + Self::Medium => "medium", + Self::Low => "low", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImageFmt { + Png, + Jpeg, + Webp, + Avif, + Gif, + Bmp, + Tiff, + Ico, +} + +impl ImageFmt { + pub fn as_str(self) -> &'static str { + match self { + Self::Png => "png", + Self::Jpeg => "jpeg", + Self::Webp => "webp", + Self::Avif => "avif", + Self::Gif => "gif", + Self::Bmp => "bmp", + Self::Tiff => "tiff", + Self::Ico => "ico", + } + } + + pub fn extension(self) -> &'static str { + match self { + Self::Png => "png", + Self::Jpeg => "jpg", + Self::Webp => "webp", + Self::Avif => "avif", + Self::Gif => "gif", + Self::Bmp => "bmp", + Self::Tiff => "tiff", + Self::Ico => "ico", + } + } + + pub fn content_type(self) -> &'static str { + match self { + Self::Png => "image/png", + Self::Jpeg => "image/jpeg", + Self::Webp => "image/webp", + Self::Avif => "image/avif", + Self::Gif => "image/gif", + Self::Bmp => "image/bmp", + Self::Tiff => "image/tiff", + Self::Ico => "image/x-icon", + } + } +} + +pub fn parse_level(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "" | "medium" => Ok(CompressionLevel::Medium), + "high" => Ok(CompressionLevel::High), + "low" => Ok(CompressionLevel::Low), + _ => Err(AppError::new( + ErrorCode::InvalidRequest, + "level 仅支持 high/medium/low", + )), + } +} + +pub fn parse_compression_rate(value: &str) -> Result { + let rate: u8 = value + .trim() + .parse() + .map_err(|_| AppError::new(ErrorCode::InvalidRequest, "compression_rate 需为 1-100 的整数"))?; + if !(1..=100).contains(&rate) { + return Err(AppError::new( + ErrorCode::InvalidRequest, + "compression_rate 需在 1-100 之间", + )); + } + Ok(rate) +} + +pub fn rate_to_level(rate: u8) -> CompressionLevel { + match rate { + 1..=33 => CompressionLevel::Low, + 34..=66 => CompressionLevel::Medium, + _ => CompressionLevel::High, + } +} + +pub fn parse_output_format(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "png" => Ok(ImageFmt::Png), + "jpeg" | "jpg" => Ok(ImageFmt::Jpeg), + "webp" => Ok(ImageFmt::Webp), + "avif" => Ok(ImageFmt::Avif), + "gif" => Ok(ImageFmt::Gif), + "bmp" => Ok(ImageFmt::Bmp), + "tif" | "tiff" => Ok(ImageFmt::Tiff), + "ico" => Ok(ImageFmt::Ico), + _ => Err(AppError::new( + ErrorCode::InvalidRequest, + "output_format 仅支持 png/jpeg/webp/avif/gif/bmp/tiff/ico", + )), + } +} + +pub fn detect_format(bytes: &[u8]) -> Result { + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + return Ok(ImageFmt::Png); + } + if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xD8 { + return Ok(ImageFmt::Jpeg); + } + if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + return Ok(ImageFmt::Webp); + } + if bytes.len() >= 12 && &bytes[4..8] == b"ftyp" { + if bytes[8..12].eq_ignore_ascii_case(b"avif") + || bytes[8..12].eq_ignore_ascii_case(b"avis") + { + return Ok(ImageFmt::Avif); + } + } + if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + return Ok(ImageFmt::Gif); + } + if bytes.len() >= 2 && bytes[0] == 0x42 && bytes[1] == 0x4D { + return Ok(ImageFmt::Bmp); + } + if bytes.len() >= 4 { + if &bytes[0..4] == b"II*\x00" || &bytes[0..4] == b"MM\x00*" { + return Ok(ImageFmt::Tiff); + } + if bytes[0] == 0x00 + && bytes[1] == 0x00 + && (bytes[2] == 0x01 || bytes[2] == 0x02) + && bytes[3] == 0x00 + { + return Ok(ImageFmt::Ico); + } + } + Err(AppError::new( + ErrorCode::UnsupportedFormat, + "不支持的图片格式", + )) +} + +pub async fn compress_image_bytes( + state: &AppState, + input: &[u8], + format_in: ImageFmt, + format_out: ImageFmt, + level: CompressionLevel, + compression_rate: Option, + max_width: Option, + max_height: Option, + preserve_metadata: bool, +) -> Result, AppError> { + if format_in == ImageFmt::Gif { + if is_animated_gif(input)? { + return Err(AppError::new( + ErrorCode::UnsupportedFormat, + "暂不支持动图 GIF", + )); + } + } + + let rate = effective_rate(compression_rate, level); + let (icc_profile, exif) = if preserve_metadata { + extract_metadata(input) + } else { + (None, None) + }; + + let mut resized = false; + let mut output = if format_in == ImageFmt::Png + && format_out == ImageFmt::Png + && max_width.is_none() + && max_height.is_none() + { + let preset = png_preset_from_rate(rate); + let mut opts = oxipng::Options::from_preset(preset); + if !preserve_metadata { + opts.strip = StripChunks::Safe; + } + oxipng::optimize_from_memory(input, &opts) + .map_err(|err| AppError::new(ErrorCode::CompressionFailed, "PNG 压缩失败").with_source(err))? + } else { + let image = image::load_from_memory(input) + .map_err(|err| AppError::new(ErrorCode::InvalidImage, "图片解码失败").with_source(err))?; + + enforce_pixel_limit(state, &image)?; + + let (image, did_resize) = resize_if_needed(image, max_width, max_height); + resized = did_resize; + + match format_out { + ImageFmt::Png => encode_png(image, rate, preserve_metadata)?, + ImageFmt::Jpeg => encode_jpeg(image, rate)?, + ImageFmt::Webp => encode_webp(image, rate)?, + ImageFmt::Avif => encode_avif(image, rate)?, + ImageFmt::Gif => encode_gif(image, rate)?, + ImageFmt::Bmp => encode_bmp(image)?, + ImageFmt::Tiff => encode_tiff(image)?, + ImageFmt::Ico => encode_ico(image)?, + } + }; + + if preserve_metadata { + output = apply_metadata(output, icc_profile, exif)?; + } + + if !resized && output.len() >= input.len() { + if preserve_metadata { + return Ok(input.to_vec()); + } + let stripped = strip_metadata(input).unwrap_or_else(|_| input.to_vec()); + return Ok(if stripped.len() <= input.len() { + stripped + } else { + input.to_vec() + }); + } + + Ok(output) +} + +fn enforce_pixel_limit(state: &AppState, image: &DynamicImage) -> Result<(), AppError> { + let (w, h) = image.dimensions(); + let pixels = (w as u64).saturating_mul(h as u64); + if pixels > state.config.max_image_pixels { + return Err(AppError::new( + ErrorCode::TooManyPixels, + format!("图片像素过大({}x{})", w, h), + )); + } + Ok(()) +} + +fn resize_if_needed( + image: DynamicImage, + max_width: Option, + max_height: Option, +) -> (DynamicImage, bool) { + if max_width.is_none() && max_height.is_none() { + return (image, false); + } + + let (w, h) = image.dimensions(); + let (target_w, target_h) = fit_within(w, h, max_width, max_height); + if target_w == w && target_h == h { + return (image, false); + } + + ( + image.resize(target_w, target_h, image::imageops::FilterType::Lanczos3), + true, + ) +} + +fn fit_within(w: u32, h: u32, max_width: Option, max_height: Option) -> (u32, u32) { + let mut scale = 1.0_f64; + if let Some(mw) = max_width.filter(|v| *v > 0) { + scale = scale.min(mw as f64 / w as f64); + } + if let Some(mh) = max_height.filter(|v| *v > 0) { + scale = scale.min(mh as f64 / h as f64); + } + if scale >= 1.0 { + return (w, h); + } + let nw = (w as f64 * scale).round().max(1.0) as u32; + let nh = (h as f64 * scale).round().max(1.0) as u32; + (nw, nh) +} + +fn encode_png( + image: DynamicImage, + rate: u8, + preserve_metadata: bool, +) -> Result, AppError> { + let rgba = image.to_rgba8(); + let (w, h) = rgba.dimensions(); + let mut out = Vec::new(); + + let encoder = PngEncoder::new(&mut out); + encoder + .write_image(rgba.as_raw(), w, h, ExtendedColorType::Rgba8) + .map_err(|err| AppError::new(ErrorCode::CompressionFailed, "PNG 编码失败").with_source(err))?; + + let preset = png_preset_from_rate(rate); + let mut opts = oxipng::Options::from_preset(preset); + if !preserve_metadata { + opts.strip = StripChunks::Safe; + } + oxipng::optimize_from_memory(&out, &opts) + .map_err(|err| AppError::new(ErrorCode::CompressionFailed, "PNG 优化失败").with_source(err)) +} + +fn encode_jpeg(image: DynamicImage, rate: u8) -> Result, AppError> { + let rgb = image.to_rgb8(); + let (w, h) = rgb.dimensions(); + let mut out = Vec::new(); + + let quality = jpeg_quality_from_rate(rate); + + let mut encoder = JpegEncoder::new_with_quality(&mut out, quality); + encoder + .encode(rgb.as_raw(), w, h, ExtendedColorType::Rgb8) + .map_err(|err| AppError::new(ErrorCode::CompressionFailed, "JPEG 编码失败").with_source(err))?; + + Ok(out) +} + +fn encode_webp(image: DynamicImage, rate: u8) -> Result, AppError> { + let rgba = image.to_rgba8(); + let (w, h) = rgba.dimensions(); + let encoder = webp::Encoder::from_rgba(rgba.as_raw(), w, h); + + let bytes = if rate <= 10 { + encoder.encode_lossless() + } else { + encoder.encode(webp_quality_from_rate(rate)) + }; + + Ok(bytes.to_vec()) +} + +fn encode_avif(image: DynamicImage, rate: u8) -> Result, AppError> { + let rgba = image.to_rgba8(); + let (w, h) = rgba.dimensions(); + + let quality = avif_quality_from_rate(rate); + + let raw = rgba.into_raw(); + let pixels = raw.as_rgba(); + let img = ravif::Img::new(pixels, w as usize, h as usize); + + let encoder = ravif::Encoder::new().with_quality(quality); + let encoded = encoder + .encode_rgba(img) + .map_err(|err| AppError::new(ErrorCode::CompressionFailed, "AVIF 编码失败").with_source(err))?; + + Ok(encoded.avif_file) +} + +fn encode_gif(image: DynamicImage, rate: u8) -> Result, AppError> { + let rgba = image.to_rgba8(); + let (w, h) = rgba.dimensions(); + let mut out = Vec::new(); + + let speed = gif_speed_from_rate(rate); + { + let mut encoder = GifEncoder::new_with_speed(&mut out, speed); + encoder + .encode(rgba.as_raw(), w, h, ExtendedColorType::Rgba8) + .map_err(|err| AppError::new(ErrorCode::CompressionFailed, "GIF 编码失败").with_source(err))?; + } + + Ok(out) +} + +fn encode_bmp(image: DynamicImage) -> Result, AppError> { + let rgba = image.to_rgba8(); + let (w, h) = rgba.dimensions(); + let mut out = Vec::new(); + let encoder = BmpEncoder::new(&mut out); + encoder + .write_image(rgba.as_raw(), w, h, ExtendedColorType::Rgba8) + .map_err(|err| AppError::new(ErrorCode::CompressionFailed, "BMP 编码失败").with_source(err))?; + Ok(out) +} + +fn encode_tiff(image: DynamicImage) -> Result, AppError> { + let rgba = image.to_rgba8(); + let (w, h) = rgba.dimensions(); + let mut out = Cursor::new(Vec::new()); + let encoder = TiffEncoder::new(&mut out); + encoder + .write_image(rgba.as_raw(), w, h, ExtendedColorType::Rgba8) + .map_err(|err| AppError::new(ErrorCode::CompressionFailed, "TIFF 编码失败").with_source(err))?; + Ok(out.into_inner()) +} + +fn encode_ico(image: DynamicImage) -> Result, AppError> { + let rgba = image.to_rgba8(); + let (w, h) = rgba.dimensions(); + let mut out = Vec::new(); + let encoder = IcoEncoder::new(&mut out); + encoder + .write_image(rgba.as_raw(), w, h, ExtendedColorType::Rgba8) + .map_err(|err| AppError::new(ErrorCode::CompressionFailed, "ICO 编码失败").with_source(err))?; + Ok(out) +} + +fn extract_metadata(input: &[u8]) -> (Option, Option) { + let bytes = ImgBytes::copy_from_slice(input); + match DynImage::from_bytes(bytes) { + Ok(Some(img)) => (img.icc_profile(), img.exif()), + _ => (None, None), + } +} + +fn apply_metadata( + output: Vec, + icc_profile: Option, + exif: Option, +) -> Result, AppError> { + if icc_profile.is_none() && exif.is_none() { + return Ok(output); + } + + let out_bytes = ImgBytes::from(output); + let dyn_img = DynImage::from_bytes(out_bytes.clone()) + .map_err(|err| AppError::new(ErrorCode::CompressionFailed, "解析输出图片元数据失败").with_source(err))?; + + let Some(mut img) = dyn_img else { + return Ok(out_bytes.to_vec()); + }; + + img.set_icc_profile(icc_profile); + img.set_exif(exif); + + let mut buf = Vec::new(); + img.encoder() + .write_to(&mut buf) + .map_err(|err| AppError::new(ErrorCode::CompressionFailed, "写入图片元数据失败").with_source(err))?; + Ok(buf) +} + +fn strip_metadata(input: &[u8]) -> Result, AppError> { + let bytes = ImgBytes::copy_from_slice(input); + let dyn_img = DynImage::from_bytes(bytes.clone()) + .map_err(|err| AppError::new(ErrorCode::CompressionFailed, "解析图片元数据失败").with_source(err))?; + let Some(mut img) = dyn_img else { + return Ok(bytes.to_vec()); + }; + + img.set_icc_profile(None); + img.set_exif(None); + + let mut buf = Vec::new(); + img.encoder() + .write_to(&mut buf) + .map_err(|err| AppError::new(ErrorCode::CompressionFailed, "写入图片元数据失败").with_source(err))?; + Ok(buf) +} + +fn effective_rate(rate: Option, level: CompressionLevel) -> u8 { + match rate { + Some(value) => value.clamp(1, 100), + None => match level { + CompressionLevel::Low => 25, + CompressionLevel::Medium => 55, + CompressionLevel::High => 80, + }, + } +} + +fn png_preset_from_rate(rate: u8) -> u8 { + (((rate.saturating_sub(1)) as f32 / 99.0) * 6.0).round() as u8 +} + +fn jpeg_quality_from_rate(rate: u8) -> u8 { + quality_from_rate(rate, 35, 95) +} + +fn webp_quality_from_rate(rate: u8) -> f32 { + quality_from_rate(rate, 40, 92) as f32 +} + +fn avif_quality_from_rate(rate: u8) -> f32 { + quality_from_rate(rate, 35, 90) as f32 +} + +fn quality_from_rate(rate: u8, min_quality: u8, max_quality: u8) -> u8 { + let min_q = min_quality as i32; + let max_q = max_quality as i32; + let rate = rate.clamp(1, 100) as i32; + let span = max_q - min_q; + let q = max_q - ((rate - 1) * span / 99); + q.clamp(min_q, max_q) as u8 +} + +fn gif_speed_from_rate(rate: u8) -> i32 { + let rate = rate.clamp(1, 100) as i32; + 1 + ((rate - 1) * 29 / 99) +} + +fn is_animated_gif(input: &[u8]) -> Result { + let decoder = GifDecoder::new(Cursor::new(input)) + .map_err(|err| AppError::new(ErrorCode::InvalidImage, "GIF 解码失败").with_source(err))?; + let mut frames = decoder.into_frames().into_iter(); + if let Some(frame) = frames.next() { + frame.map_err(|err| AppError::new(ErrorCode::InvalidImage, "GIF 解码失败").with_source(err))?; + } + if let Some(frame) = frames.next() { + frame.map_err(|err| AppError::new(ErrorCode::InvalidImage, "GIF 解码失败").with_source(err))?; + return Ok(true); + } + Ok(false) +} diff --git a/src/services/idempotency.rs b/src/services/idempotency.rs new file mode 100644 index 0000000..8734ede --- /dev/null +++ b/src/services/idempotency.rs @@ -0,0 +1,341 @@ +use crate::error::{AppError, ErrorCode}; +use crate::state::AppState; + +use chrono::{DateTime, Duration, Utc}; +use serde_json::Value as JsonValue; +use sha2::{Digest, Sha256}; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy)] +pub enum Scope { + User(Uuid), + ApiKey(Uuid), +} + +#[derive(Debug)] +pub enum BeginResult { + Acquired { expires_at: DateTime }, + Replay { response_status: i32, response_body: JsonValue }, + InProgress, +} + +#[derive(Debug, FromRow)] +struct IdemRow { + request_hash: String, + response_status: i32, + response_body: Option, + expires_at: DateTime, +} + +pub fn sha256_hex(parts: &[&[u8]]) -> String { + let mut hasher = Sha256::new(); + for p in parts { + hasher.update(p); + hasher.update([0u8]); // separator + } + hex::encode(hasher.finalize()) +} + +pub async fn begin( + state: &AppState, + scope: Scope, + idempotency_key: &str, + request_hash: &str, + ttl_hours: i64, +) -> Result { + if idempotency_key.trim().is_empty() { + return Err(AppError::new(ErrorCode::InvalidRequest, "Idempotency-Key 不能为空")); + } + if idempotency_key.len() > 128 { + return Err(AppError::new(ErrorCode::InvalidRequest, "Idempotency-Key 过长")); + } + if request_hash.len() != 64 { + return Err(AppError::new(ErrorCode::InvalidRequest, "request_hash 不合法")); + } + + let now = Utc::now(); + let expires_at = now + Duration::hours(ttl_hours.max(1)); + + cleanup_expired_for_key(state, scope, idempotency_key, now).await?; + + let inserted = match scope { + Scope::User(user_id) => { + sqlx::query( + r#" + INSERT INTO idempotency_keys ( + user_id, idempotency_key, request_hash, + response_status, response_body, + expires_at + ) VALUES ( + $1, $2, $3, + 0, NULL, + $4 + ) + ON CONFLICT DO NOTHING + "#, + ) + .bind(user_id) + .bind(idempotency_key) + .bind(request_hash) + .bind(expires_at) + .execute(&state.db) + .await + } + Scope::ApiKey(api_key_id) => { + sqlx::query( + r#" + INSERT INTO idempotency_keys ( + api_key_id, idempotency_key, request_hash, + response_status, response_body, + expires_at + ) VALUES ( + $1, $2, $3, + 0, NULL, + $4 + ) + ON CONFLICT DO NOTHING + "#, + ) + .bind(api_key_id) + .bind(idempotency_key) + .bind(request_hash) + .bind(expires_at) + .execute(&state.db) + .await + } + } + .map_err(|err| AppError::new(ErrorCode::Internal, "写入幂等记录失败").with_source(err))?; + + if inserted.rows_affected() > 0 { + return Ok(BeginResult::Acquired { expires_at }); + } + + let row = get_row(state, scope, idempotency_key, now).await?; + let Some(row) = row else { + return Ok(BeginResult::Acquired { expires_at }); + }; + + if row.request_hash != request_hash { + return Err(AppError::new( + ErrorCode::IdempotencyConflict, + "同一个 Idempotency-Key 的请求参数不一致", + )); + } + + if row.response_status == 0 || row.response_body.is_none() { + return Ok(BeginResult::InProgress); + } + + Ok(BeginResult::Replay { + response_status: row.response_status, + response_body: row.response_body.unwrap_or(JsonValue::Null), + }) +} + +pub async fn wait_for_replay( + state: &AppState, + scope: Scope, + idempotency_key: &str, + request_hash: &str, + max_wait_ms: u64, +) -> Result, AppError> { + let started = tokio::time::Instant::now(); + let now = Utc::now(); + + loop { + let row = get_row(state, scope, idempotency_key, now).await?; + let Some(row) = row else { return Ok(None) }; + + if row.request_hash != request_hash { + return Err(AppError::new( + ErrorCode::IdempotencyConflict, + "同一个 Idempotency-Key 的请求参数不一致", + )); + } + + if row.response_status != 0 { + return Ok(Some(( + row.response_status, + row.response_body.unwrap_or(JsonValue::Null), + ))); + } + + if started.elapsed().as_millis() as u64 >= max_wait_ms { + return Ok(None); + } + + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } +} + +pub async fn complete( + state: &AppState, + scope: Scope, + idempotency_key: &str, + request_hash: &str, + response_status: i32, + response_body: JsonValue, +) -> Result<(), AppError> { + let updated = match scope { + Scope::User(user_id) => { + sqlx::query( + r#" + UPDATE idempotency_keys + SET response_status = $4, + response_body = $5 + WHERE user_id = $1 + AND idempotency_key = $2 + AND request_hash = $3 + AND response_status = 0 + "#, + ) + .bind(user_id) + .bind(idempotency_key) + .bind(request_hash) + .bind(response_status) + .bind(response_body) + .execute(&state.db) + .await + } + Scope::ApiKey(api_key_id) => { + sqlx::query( + r#" + UPDATE idempotency_keys + SET response_status = $4, + response_body = $5 + WHERE api_key_id = $1 + AND idempotency_key = $2 + AND request_hash = $3 + AND response_status = 0 + "#, + ) + .bind(api_key_id) + .bind(idempotency_key) + .bind(request_hash) + .bind(response_status) + .bind(response_body) + .execute(&state.db) + .await + } + } + .map_err(|err| AppError::new(ErrorCode::Internal, "写入幂等结果失败").with_source(err))?; + + if updated.rows_affected() == 0 { + tracing::warn!("idempotency record not updated (maybe already completed?)"); + } + + Ok(()) +} + +pub async fn abort( + state: &AppState, + scope: Scope, + idempotency_key: &str, + request_hash: &str, +) -> Result<(), AppError> { + match scope { + Scope::User(user_id) => { + let _ = sqlx::query( + "DELETE FROM idempotency_keys WHERE user_id = $1 AND idempotency_key = $2 AND request_hash = $3 AND response_status = 0", + ) + .bind(user_id) + .bind(idempotency_key) + .bind(request_hash) + .execute(&state.db) + .await; + } + Scope::ApiKey(api_key_id) => { + let _ = sqlx::query( + "DELETE FROM idempotency_keys WHERE api_key_id = $1 AND idempotency_key = $2 AND request_hash = $3 AND response_status = 0", + ) + .bind(api_key_id) + .bind(idempotency_key) + .bind(request_hash) + .execute(&state.db) + .await; + } + } + Ok(()) +} + +async fn cleanup_expired_for_key( + state: &AppState, + scope: Scope, + idempotency_key: &str, + now: DateTime, +) -> Result<(), AppError> { + match scope { + Scope::User(user_id) => { + let _ = sqlx::query( + "DELETE FROM idempotency_keys WHERE user_id = $1 AND idempotency_key = $2 AND expires_at < $3", + ) + .bind(user_id) + .bind(idempotency_key) + .bind(now) + .execute(&state.db) + .await; + } + Scope::ApiKey(api_key_id) => { + let _ = sqlx::query( + "DELETE FROM idempotency_keys WHERE api_key_id = $1 AND idempotency_key = $2 AND expires_at < $3", + ) + .bind(api_key_id) + .bind(idempotency_key) + .bind(now) + .execute(&state.db) + .await; + } + } + Ok(()) +} + +async fn get_row( + state: &AppState, + scope: Scope, + idempotency_key: &str, + now: DateTime, +) -> Result, AppError> { + let row = match scope { + Scope::User(user_id) => { + sqlx::query_as::<_, IdemRow>( + r#" + SELECT request_hash, response_status, response_body, expires_at + FROM idempotency_keys + WHERE user_id = $1 + AND idempotency_key = $2 + AND expires_at > $3 + ORDER BY created_at DESC + LIMIT 1 + "#, + ) + .bind(user_id) + .bind(idempotency_key) + .bind(now) + .fetch_optional(&state.db) + .await + } + Scope::ApiKey(api_key_id) => { + sqlx::query_as::<_, IdemRow>( + r#" + SELECT request_hash, response_status, response_body, expires_at + FROM idempotency_keys + WHERE api_key_id = $1 + AND idempotency_key = $2 + AND expires_at > $3 + ORDER BY created_at DESC + LIMIT 1 + "#, + ) + .bind(api_key_id) + .bind(idempotency_key) + .bind(now) + .fetch_optional(&state.db) + .await + } + } + .map_err(|err| AppError::new(ErrorCode::Internal, "查询幂等记录失败").with_source(err))?; + + Ok(row) +} + diff --git a/src/services/mail.rs b/src/services/mail.rs new file mode 100644 index 0000000..8b3fbc4 --- /dev/null +++ b/src/services/mail.rs @@ -0,0 +1,344 @@ +use crate::config::Config; +use crate::error::{AppError, ErrorCode}; +use crate::services::settings; +use crate::state::AppState; + +use chrono::Datelike; +use lettre::message::{header::ContentType, MultiPart, SinglePart}; +use lettre::transport::smtp::authentication::Credentials; +use lettre::transport::smtp::client::{Tls, TlsParameters}; +use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; + +#[derive(Clone)] +pub struct Mailer { + enabled: bool, + log_links_when_disabled: bool, + from: String, + from_name: String, + transport: Option>, +} + +#[derive(Debug, Clone)] +pub struct MailSettings { + pub enabled: bool, + pub log_links_when_disabled: bool, + pub provider: String, + pub from: String, + pub from_name: String, + pub password: String, + pub smtp_host: Option, + pub smtp_port: Option, + pub smtp_encryption: Option, +} + +impl MailSettings { + pub fn from_env(config: &Config) -> Self { + Self { + enabled: config.mail_enabled, + log_links_when_disabled: config.mail_log_links_when_disabled, + provider: config.mail_provider.clone(), + from: config.mail_from.clone(), + from_name: config.mail_from_name.clone(), + password: config.mail_password.clone(), + smtp_host: config.mail_smtp_host.clone(), + smtp_port: config.mail_smtp_port, + smtp_encryption: config.mail_smtp_encryption.clone(), + } + } +} + +impl Mailer { + pub fn new(config: &Config) -> Result { + Self::from_settings(MailSettings::from_env(config)) + } + + pub fn from_settings(settings: MailSettings) -> Result { + if !settings.enabled { + return Ok(Self { + enabled: false, + log_links_when_disabled: settings.log_links_when_disabled, + from: settings.from, + from_name: settings.from_name, + transport: None, + }); + } + + if settings.password.trim().is_empty() { + return Err(AppError::new( + ErrorCode::InvalidRequest, + "邮件服务已启用但未配置授权码/密码", + )); + } + + let smtp = SmtpConfig::from_settings(&settings)?; + let creds = Credentials::new(settings.from.clone(), settings.password.clone()); + + let tls_params = if smtp.encryption == SmtpEncryption::None { + None + } else { + Some( + TlsParameters::new(smtp.host.clone()) + .map_err(|err| AppError::new(ErrorCode::Internal, "SMTP TLS 参数错误").with_source(err))?, + ) + }; + + let tls = match (smtp.encryption, tls_params) { + (SmtpEncryption::Ssl, Some(params)) => Tls::Wrapper(params), + (SmtpEncryption::StartTls, Some(params)) => Tls::Required(params), + (SmtpEncryption::None, _) => Tls::None, + _ => Tls::None, + }; + + let transport = AsyncSmtpTransport::::builder_dangerous(&smtp.host) + .port(smtp.port) + .tls(tls) + .credentials(creds) + .build(); + + Ok(Self { + enabled: true, + log_links_when_disabled: false, + from: settings.from, + from_name: settings.from_name, + transport: Some(transport), + }) + } + + pub async fn send_verification_email( + &self, + to: &str, + username: &str, + verification_url: &str, + ) -> Result<(), AppError> { + if !self.enabled { + if self.log_links_when_disabled { + tracing::info!( + to = %to, + verification_url = %verification_url, + "MAIL_ENABLED=false, verification email link" + ); + } else { + tracing::info!(to = %to, "MAIL_ENABLED=false, skip sending verification email"); + } + return Ok(()); + } + + let year = chrono::Utc::now().year().to_string(); + + let html = render_template( + include_str!("../../templates/email_verification.html"), + &[ + ("{{username}}", username), + ("{{verification_url}}", verification_url), + ("{{year}}", &year), + ], + ); + let text = render_template( + include_str!("../../templates/email_verification.txt"), + &[ + ("{{username}}", username), + ("{{verification_url}}", verification_url), + ("{{year}}", &year), + ], + ); + + self.send_email(to, "验证您的 ImageForge 账号", &text, &html) + .await + } + + pub async fn send_password_reset_email( + &self, + to: &str, + username: &str, + reset_url: &str, + ) -> Result<(), AppError> { + if !self.enabled { + if self.log_links_when_disabled { + tracing::info!(to = %to, reset_url = %reset_url, "MAIL_ENABLED=false, password reset link"); + } else { + tracing::info!(to = %to, "MAIL_ENABLED=false, skip sending password reset email"); + } + return Ok(()); + } + + let year = chrono::Utc::now().year().to_string(); + + let html = render_template( + include_str!("../../templates/password_reset.html"), + &[ + ("{{username}}", username), + ("{{reset_url}}", reset_url), + ("{{year}}", &year), + ], + ); + let text = render_template( + include_str!("../../templates/password_reset.txt"), + &[ + ("{{username}}", username), + ("{{reset_url}}", reset_url), + ("{{year}}", &year), + ], + ); + + self.send_email(to, "重置您的 ImageForge 密码", &text, &html) + .await + } + + async fn send_email( + &self, + to: &str, + subject: &str, + text_body: &str, + html_body: &str, + ) -> Result<(), AppError> { + if !self.enabled { + tracing::info!(to = %to, subject = %subject, "MAIL_ENABLED=false, skip sending email"); + return Ok(()); + } + + let Some(transport) = &self.transport else { + return Err(AppError::new(ErrorCode::Internal, "邮件服务未初始化")); + }; + + let from = format!("{} <{}>", self.from_name, self.from); + let email = Message::builder() + .from(from.parse().map_err(|err| { + AppError::new(ErrorCode::InvalidRequest, "MAIL_FROM/MAIL_FROM_NAME 格式错误") + .with_source(err) + })?) + .to(to.parse().map_err(|err| { + AppError::new(ErrorCode::InvalidRequest, "收件人邮箱格式错误").with_source(err) + })?) + .subject(subject) + .multipart( + MultiPart::alternative() + .singlepart(SinglePart::builder() + .header(ContentType::TEXT_PLAIN) + .body(text_body.to_string())) + .singlepart(SinglePart::builder() + .header(ContentType::TEXT_HTML) + .body(html_body.to_string())), + ) + .map_err(|err| AppError::new(ErrorCode::Internal, "构建邮件失败").with_source(err))?; + + transport + .send(email) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "邮件发送失败").with_source(err))?; + + Ok(()) + } +} + +#[derive(Debug, Clone)] +struct SmtpConfig { + host: String, + port: u16, + encryption: SmtpEncryption, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SmtpEncryption { + Ssl, + StartTls, + None, +} + +impl SmtpConfig { + fn from_settings(settings: &MailSettings) -> Result { + if settings.provider.eq_ignore_ascii_case("custom") { + let host = settings.smtp_host.clone().ok_or_else(|| { + AppError::new(ErrorCode::InvalidRequest, "自定义 SMTP 必须配置 host") + })?; + let port = settings + .smtp_port + .ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "自定义 SMTP 必须配置端口"))?; + let encryption = parse_encryption(settings.smtp_encryption.as_deref().unwrap_or("ssl"))?; + return Ok(Self { host, port, encryption }); + } + + let provider = settings.provider.to_ascii_lowercase(); + let (host, port, encryption) = match provider.as_str() { + "qq" => ("smtp.qq.com", 465, SmtpEncryption::Ssl), + "163" => ("smtp.163.com", 465, SmtpEncryption::Ssl), + "aliyun_enterprise" => ("smtp.qiye.aliyun.com", 465, SmtpEncryption::Ssl), + "tencent_enterprise" => ("smtp.exmail.qq.com", 465, SmtpEncryption::Ssl), + "gmail" => ("smtp.gmail.com", 587, SmtpEncryption::StartTls), + "outlook" => ("smtp.office365.com", 587, SmtpEncryption::StartTls), + other => { + return Err(AppError::new( + ErrorCode::InvalidRequest, + format!("未知 MAIL_PROVIDER: {other}"), + )) + } + }; + + Ok(Self { + host: host.to_string(), + port, + encryption, + }) + } +} + +fn parse_encryption(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "ssl" => Ok(SmtpEncryption::Ssl), + "starttls" => Ok(SmtpEncryption::StartTls), + "none" => Ok(SmtpEncryption::None), + _ => Err(AppError::new( + ErrorCode::InvalidRequest, + "MAIL_SMTP_ENCRYPTION 仅支持 ssl/starttls/none", + )), + } +} + +fn render_template(template: &str, vars: &[(&str, &str)]) -> String { + let mut out = template.to_string(); + for (key, value) in vars { + out = out.replace(key, value); + } + out +} + +pub async fn send_verification_email( + state: &AppState, + to: &str, + username: &str, + verification_url: &str, +) -> Result<(), AppError> { + let mailer = resolve_mailer(state).await?; + mailer + .send_verification_email(to, username, verification_url) + .await +} + +pub async fn send_password_reset_email( + state: &AppState, + to: &str, + username: &str, + reset_url: &str, +) -> Result<(), AppError> { + let mailer = resolve_mailer(state).await?; + mailer.send_password_reset_email(to, username, reset_url).await +} + +pub async fn send_test_email(state: &AppState, to: &str) -> Result<(), AppError> { + let mailer = resolve_mailer(state).await?; + let year = chrono::Utc::now().year().to_string(); + let html = format!( + "

ImageForge 邮件测试

这是一封测试邮件。

{}

", + year + ); + let text = format!("ImageForge 邮件测试\n\n这是一封测试邮件。\n{}\n", year); + mailer + .send_email(to, "ImageForge 邮件测试", &text, &html) + .await +} + +async fn resolve_mailer(state: &AppState) -> Result { + if let Some(settings) = settings::load_mail_settings(state).await? { + return Mailer::from_settings(settings); + } + Ok(state.mailer.as_ref().clone()) +} diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..d90051c --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1,7 @@ +pub mod mail; +pub mod billing; +pub mod quota; +pub mod compress; +pub mod idempotency; +pub mod settings; +pub mod bootstrap; diff --git a/src/services/quota.rs b/src/services/quota.rs new file mode 100644 index 0000000..ca0a9fd --- /dev/null +++ b/src/services/quota.rs @@ -0,0 +1,73 @@ +use crate::error::{AppError, ErrorCode}; +use crate::state::AppState; + +use chrono::{Duration, Utc}; +use std::net::IpAddr; + +pub async fn consume_anonymous_units( + state: &AppState, + session_id: &str, + ip: IpAddr, + units: u32, +) -> Result<(), AppError> { + if units == 0 { + return Ok(()); + } + + let date = utc8_date(); + let session_key = format!("anon_quota:{session_id}:{date}"); + let ip_key = format!("anon_quota_ip:{ip}:{date}"); + + let mut conn = state.redis.clone(); + + let limit = state.config.anon_daily_units as i64; + let ttl_seconds = 48 * 60 * 60; + let inc = units as i64; + + let script = redis::Script::new( + r#" + local limit = tonumber(ARGV[1]) + local ttl = tonumber(ARGV[2]) + local inc = tonumber(ARGV[3]) + + local v1 = tonumber(redis.call('GET', KEYS[1]) or '0') + local v2 = tonumber(redis.call('GET', KEYS[2]) or '0') + + if v1 + inc > limit or v2 + inc > limit then + return -1 + end + + v1 = redis.call('INCRBY', KEYS[1], inc) + v2 = redis.call('INCRBY', KEYS[2], inc) + + if v1 == inc then redis.call('EXPIRE', KEYS[1], ttl) end + if v2 == inc then redis.call('EXPIRE', KEYS[2], ttl) end + + return v1 + "#, + ); + + let new_value: i64 = script + .key(session_key) + .key(ip_key) + .arg(limit) + .arg(ttl_seconds) + .arg(inc) + .invoke_async(&mut conn) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "匿名配额检查失败").with_source(err))?; + + if new_value < 0 { + return Err(AppError::new( + ErrorCode::QuotaExceeded, + "匿名试用次数已用完(每日 10 次)", + )); + } + + Ok(()) +} + +fn utc8_date() -> String { + let now = Utc::now() + Duration::hours(8); + now.format("%Y-%m-%d").to_string() +} diff --git a/src/services/settings.rs b/src/services/settings.rs new file mode 100644 index 0000000..80da314 --- /dev/null +++ b/src/services/settings.rs @@ -0,0 +1,227 @@ +use crate::error::{AppError, ErrorCode}; +use crate::services::mail::MailSettings; +use crate::state::AppState; + +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Nonce}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use serde::de::DeserializeOwned; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MailCustomSmtp { + pub host: String, + pub port: u16, + pub encryption: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MailConfigStored { + pub enabled: bool, + pub provider: String, + pub from: String, + pub from_name: String, + pub password_encrypted: Option, + pub custom_smtp: Option, + pub log_links_when_disabled: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StripeConfigStored { + pub secret_key_encrypted: Option, + pub webhook_secret_encrypted: Option, + pub secret_key_prefix: Option, +} + +#[derive(Debug, Clone)] +pub struct StripeSecrets { + pub secret_key: String, + pub webhook_secret: Option, + pub secret_key_prefix: Option, +} + +pub async fn load_system_config( + state: &AppState, + key: &str, +) -> Result, AppError> { + let value: Option = + sqlx::query_scalar("SELECT value FROM system_config WHERE key = $1") + .bind(key) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询系统配置失败").with_source(err))?; + + let Some(value) = value else { + return Ok(None); + }; + + let parsed = serde_json::from_value::(value) + .map_err(|err| AppError::new(ErrorCode::Internal, "解析系统配置失败").with_source(err))?; + Ok(Some(parsed)) +} + +pub async fn upsert_system_config( + state: &AppState, + key: &str, + value: serde_json::Value, + description: Option<&str>, + updated_by: Option, +) -> Result<(), AppError> { + sqlx::query( + r#" + INSERT INTO system_config (key, value, description, updated_at, updated_by) + VALUES ($1, $2, $3, NOW(), $4) + ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value, + description = COALESCE(EXCLUDED.description, system_config.description), + updated_at = NOW(), + updated_by = $4 + "#, + ) + .bind(key) + .bind(value) + .bind(description) + .bind(updated_by) + .execute(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "更新系统配置失败").with_source(err))?; + + Ok(()) +} + +pub async fn load_mail_settings(state: &AppState) -> Result, AppError> { + let Some(cfg) = load_system_config::(state, "mail").await? else { + return Ok(None); + }; + + let password = match cfg.password_encrypted.as_deref() { + Some(value) => decrypt_secret(state, value)?, + None => String::new(), + }; + + Ok(Some(MailSettings { + enabled: cfg.enabled, + log_links_when_disabled: cfg + .log_links_when_disabled + .unwrap_or(state.config.mail_log_links_when_disabled), + provider: cfg.provider, + from: cfg.from, + from_name: cfg.from_name, + password, + smtp_host: cfg.custom_smtp.as_ref().map(|v| v.host.clone()), + smtp_port: cfg.custom_smtp.as_ref().map(|v| v.port), + smtp_encryption: cfg.custom_smtp.as_ref().map(|v| v.encryption.clone()), + })) +} + +pub async fn load_stripe_secrets(state: &AppState) -> Result, AppError> { + let Some(cfg) = load_system_config::(state, "stripe").await? else { + return Ok(None); + }; + + let secret_key = match cfg.secret_key_encrypted.as_deref() { + Some(value) => decrypt_secret(state, value)?, + None => String::new(), + }; + + if secret_key.is_empty() { + return Ok(None); + } + + let webhook_secret = match cfg.webhook_secret_encrypted.as_deref() { + Some(value) if !value.is_empty() => Some(decrypt_secret(state, value)?), + _ => None, + }; + + Ok(Some(StripeSecrets { + secret_key, + webhook_secret, + secret_key_prefix: cfg.secret_key_prefix, + })) +} + +pub fn encrypt_secret(state: &AppState, plain: &str) -> Result { + let key = derive_key(&state.config.api_key_pepper); + let cipher = Aes256Gcm::new_from_slice(&key) + .map_err(|err| AppError::new(ErrorCode::Internal, "加密密钥初始化失败").with_source(err))?; + + let mut nonce_bytes = [0u8; 12]; + rand::rngs::OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher + .encrypt(nonce, plain.as_bytes()) + .map_err(|err| AppError::new(ErrorCode::Internal, "加密失败").with_source(err))?; + + let mut out = Vec::with_capacity(nonce_bytes.len() + ciphertext.len()); + out.extend_from_slice(&nonce_bytes); + out.extend_from_slice(&ciphertext); + + Ok(URL_SAFE_NO_PAD.encode(out)) +} + +pub fn decrypt_secret(state: &AppState, encoded: &str) -> Result { + let key = derive_key(&state.config.api_key_pepper); + let cipher = Aes256Gcm::new_from_slice(&key) + .map_err(|err| AppError::new(ErrorCode::Internal, "解密密钥初始化失败").with_source(err))?; + + let decoded = URL_SAFE_NO_PAD + .decode(encoded.as_bytes()) + .map_err(|err| AppError::new(ErrorCode::InvalidRequest, "密文格式错误").with_source(err))?; + + if decoded.len() < 12 { + return Err(AppError::new(ErrorCode::InvalidRequest, "密文长度错误")); + } + + let (nonce_bytes, cipher_bytes) = decoded.split_at(12); + let nonce = Nonce::from_slice(nonce_bytes); + let plain = cipher + .decrypt(nonce, cipher_bytes) + .map_err(|err| AppError::new(ErrorCode::InvalidRequest, "密文解密失败").with_source(err))?; + + String::from_utf8(plain) + .map_err(|err| AppError::new(ErrorCode::Internal, "解密文本编码错误").with_source(err)) +} + +fn derive_key(secret: &str) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(secret.as_bytes()); + let result = hasher.finalize(); + let mut out = [0u8; 32]; + out.copy_from_slice(&result); + out +} + +pub async fn get_stripe_secret(state: &AppState) -> Result { + if let Some(cfg) = load_stripe_secrets(state).await? { + if !cfg.secret_key.trim().is_empty() { + return Ok(cfg.secret_key); + } + } + + state + .config + .stripe_secret_key + .clone() + .filter(|v| !v.trim().is_empty()) + .ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "未配置 Stripe Secret Key")) +} + +pub async fn get_stripe_webhook_secret(state: &AppState) -> Result { + if let Some(cfg) = load_stripe_secrets(state).await? { + if let Some(secret) = cfg.webhook_secret { + if !secret.trim().is_empty() { + return Ok(secret); + } + } + } + + state + .config + .stripe_webhook_secret + .clone() + .filter(|v| !v.trim().is_empty()) + .ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "未配置 Stripe Webhook Secret")) +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..9665077 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,10 @@ +use crate::config::Config; +use crate::services::mail::Mailer; + +#[derive(Clone)] +pub struct AppState { + pub config: Config, + pub db: sqlx::PgPool, + pub redis: redis::aio::ConnectionManager, + pub mailer: std::sync::Arc, +} diff --git a/src/worker/mod.rs b/src/worker/mod.rs new file mode 100644 index 0000000..4cbcf30 --- /dev/null +++ b/src/worker/mod.rs @@ -0,0 +1,738 @@ +use crate::error::{AppError, ErrorCode}; +use crate::services::billing; +use crate::services::compress; +use crate::services::quota; +use crate::state::AppState; + +use redis::streams::StreamReadOptions; +use redis::AsyncCommands; +use sqlx::FromRow; +use std::net::IpAddr; +use std::time::Instant; +use uuid::Uuid; + +const STREAM_KEY: &str = "stream:compress_jobs"; +const GROUP_NAME: &str = "compress_workers"; + +pub async fn run(state: AppState) -> Result<(), AppError> { + tracing::info!("Worker started"); + + if let Err(err) = crate::services::bootstrap::ensure_schema(&state).await { + tracing::error!(error = %err, "数据库结构初始化失败"); + } + + let consumer = format!("worker_{}", Uuid::new_v4()); + ensure_group(&state, &consumer).await?; + + let mut last_maintenance = Instant::now(); + + loop { + if let Err(err) = poll_once(&state, &consumer).await { + tracing::error!(error = ?err, "worker poll error"); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + + if last_maintenance.elapsed().as_secs() >= 300 { + if let Err(err) = maintenance(&state).await { + tracing::error!(error = ?err, "maintenance failed"); + } + last_maintenance = Instant::now(); + } + } +} + +async fn ensure_group(state: &AppState, _consumer: &str) -> Result<(), AppError> { + let mut conn = state.redis.clone(); + + let res: Result = redis::cmd("XGROUP") + .arg("CREATE") + .arg(STREAM_KEY) + .arg(GROUP_NAME) + .arg("0") + .arg("MKSTREAM") + .query_async(&mut conn) + .await; + + match res { + Ok(_) => Ok(()), + Err(err) => { + let msg = err.to_string(); + if msg.contains("BUSYGROUP") { + return Ok(()); + } + Err(AppError::new(ErrorCode::Internal, "初始化队列失败").with_source(err)) + } + } +} + +async fn poll_once(state: &AppState, consumer: &str) -> Result<(), AppError> { + let mut conn = state.redis.clone(); + + let opts = StreamReadOptions::default() + .group(GROUP_NAME, consumer) + .count(1) + .block(5000); + + let reply: redis::streams::StreamReadReply = conn + .xread_options(&[STREAM_KEY], &[">"], &opts) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "读取队列失败").with_source(err))?; + + if reply.keys.is_empty() { + return Ok(()); + } + + for key in reply.keys { + for msg in key.ids { + let Some(task_id_str) = msg.get::("task_id") else { + ack_message(&mut conn, &msg.id).await?; + continue; + }; + + let task_id = match Uuid::parse_str(&task_id_str) { + Ok(v) => v, + Err(_) => { + ack_message(&mut conn, &msg.id).await?; + continue; + } + }; + + if let Err(err) = process_task(state, task_id).await { + tracing::error!(task_id = %task_id, error = %err, "task processing failed"); + } + + ack_message(&mut conn, &msg.id).await?; + } + } + + Ok(()) +} + +async fn ack_message(conn: &mut redis::aio::ConnectionManager, msg_id: &str) -> Result<(), AppError> { + let _: i64 = redis::cmd("XACK") + .arg(STREAM_KEY) + .arg(GROUP_NAME) + .arg(msg_id) + .query_async(conn) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "确认队列消息失败").with_source(err))?; + Ok(()) +} + +#[derive(Debug, FromRow)] +struct TaskProcRow { + id: Uuid, + status: String, + compression_level: String, + compression_rate: Option, + max_width: Option, + max_height: Option, + preserve_metadata: bool, + total_files: i32, + completed_files: i32, + failed_files: i32, + user_id: Option, + session_id: Option, + api_key_id: Option, + source: String, + client_ip: Option, +} + +#[derive(Debug, FromRow)] +struct TaskFileProcRow { + id: Uuid, + storage_path: Option, + original_name: String, + original_format: String, + output_format: String, + original_size: i64, + status: String, +} + +async fn process_task(state: &AppState, task_id: Uuid) -> Result<(), AppError> { + let mut task: TaskProcRow = sqlx::query_as( + r#" + SELECT + id, + status::text AS status, + compression_level::text AS compression_level, + compression_rate, + max_width, + max_height, + preserve_metadata, + total_files, + completed_files, + failed_files, + user_id, + session_id, + api_key_id, + source::text AS source, + client_ip::text AS client_ip + FROM tasks + WHERE id = $1 + "#, + ) + .bind(task_id) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))? + .ok_or_else(|| AppError::new(ErrorCode::NotFound, "任务不存在"))?; + + if matches!(task.status.as_str(), "completed" | "failed" | "cancelled") { + return Ok(()); + } + + let updated = sqlx::query( + r#" + UPDATE tasks + SET status = 'processing', started_at = NOW() + WHERE id = $1 AND status = 'pending' + "#, + ) + .bind(task_id) + .execute(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "更新任务状态失败").with_source(err))?; + + if updated.rows_affected() == 0 && task.status == "pending" { + // Another worker might have taken it. + return Ok(()); + } + + // Refresh task row after status change + task.status = "processing".to_string(); + + let compression_rate = task + .compression_rate + .and_then(|v| u8::try_from(v).ok()); + let level = compression_rate + .map(compress::rate_to_level) + .unwrap_or(compress::parse_level(&task.compression_level)?); + let max_width = task.max_width.and_then(|v| u32::try_from(v).ok()); + let max_height = task.max_height.and_then(|v| u32::try_from(v).ok()); + + let mut files: Vec = sqlx::query_as( + r#" + SELECT + id, + storage_path, + original_name, + original_format, + output_format, + original_size, + status::text AS status + FROM task_files + WHERE task_id = $1 + ORDER BY created_at ASC + "#, + ) + .bind(task_id) + .fetch_all(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询任务文件失败").with_source(err))?; + + let billing_ctx = if let Some(user_id) = task.user_id { + Some(billing::get_user_billing(state, user_id).await?) + } else { + None + }; + + let anon_ip: Option = task + .client_ip + .as_deref() + .and_then(|s| s.parse::().ok()); + + for file in &mut files { + // Stop early if cancelled. + let status: Option = sqlx::query_scalar("SELECT status::text FROM tasks WHERE id = $1") + .bind(task_id) + .fetch_optional(&state.db) + .await + .unwrap_or(None); + if matches!(status.as_deref(), Some("cancelled")) { + break; + } + + if file.status != "pending" { + continue; + } + + let updated = sqlx::query("UPDATE task_files SET status = 'processing' WHERE id = $1 AND status = 'pending'") + .bind(file.id) + .execute(&state.db) + .await + .unwrap_or_else(|_| sqlx::postgres::PgQueryResult::default()); + if updated.rows_affected() == 0 { + continue; + } + + let Some(input_path) = file.storage_path.clone() else { + mark_file_failed(state, task_id, file.id, "原文件不存在").await?; + continue; + }; + + let input_bytes = match tokio::fs::read(&input_path).await { + Ok(v) => v, + Err(_) => { + mark_file_failed(state, task_id, file.id, "读取原文件失败").await?; + continue; + } + }; + + let format_in = parse_image_fmt(&file.original_format)?; + let format_out = parse_image_fmt(&file.output_format)?; + + let compressed = match compress::compress_image_bytes( + state, + &input_bytes, + format_in, + format_out, + level, + compression_rate, + max_width, + max_height, + task.preserve_metadata, + ) + .await + { + Ok(v) => v, + Err(err) => { + mark_file_failed(state, task_id, file.id, &err.message).await?; + let _ = tokio::fs::remove_file(&input_path).await; + continue; + } + }; + + let original_size = input_bytes.len() as u64; + let compressed_size = compressed.len() as u64; + let saved_percent = if original_size == 0 { + 0.0 + } else { + (original_size.saturating_sub(compressed_size) as f64) * 100.0 / (original_size as f64) + }; + let charge_units = compressed_size < original_size; + + // Anonymous quota enforcement requires session_id + client_ip. + if task.user_id.is_none() && charge_units { + let Some(session_id) = task.session_id.as_deref() else { + mark_file_failed(state, task_id, file.id, "匿名任务缺少 session_id").await?; + let _ = tokio::fs::remove_file(&input_path).await; + continue; + }; + let Some(ip) = anon_ip else { + mark_file_failed(state, task_id, file.id, "匿名任务缺少 client_ip").await?; + let _ = tokio::fs::remove_file(&input_path).await; + continue; + }; + if let Err(err) = quota::consume_anonymous_units(state, session_id, ip, 1).await { + mark_file_failed(state, task_id, file.id, &err.message).await?; + let _ = tokio::fs::remove_file(&input_path).await; + continue; + } + } + + let output_path = format!( + "{}/{}.{}", + state.config.storage_path, + file.id, + format_out.extension() + ); + if let Err(err) = tokio::fs::write(&output_path, &compressed).await { + mark_file_failed(state, task_id, file.id, "写入压缩文件失败").await?; + let _ = tokio::fs::remove_file(&input_path).await; + return Err(AppError::new(ErrorCode::StorageUnavailable, "写入压缩文件失败").with_source(err)); + } + + if let Err(err) = finalize_file( + state, + &billing_ctx, + task.api_key_id, + &task.source, + task_id, + file.id, + &output_path, + original_size as i64, + compressed_size as i64, + saved_percent, + format_in, + format_out, + charge_units, + ) + .await + { + // If quota exceeded for paid users, don't leave output behind. + if err.code == ErrorCode::QuotaExceeded { + let _ = tokio::fs::remove_file(&output_path).await; + mark_file_failed(state, task_id, file.id, &err.message).await?; + } else { + mark_file_failed(state, task_id, file.id, &err.message).await?; + } + let _ = tokio::fs::remove_file(&input_path).await; + continue; + } + + // Success: remove original. + let _ = tokio::fs::remove_file(&input_path).await; + } + + finalize_task_status(state, task_id).await?; + Ok(()) +} + +fn parse_image_fmt(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "png" => Ok(compress::ImageFmt::Png), + "jpeg" | "jpg" => Ok(compress::ImageFmt::Jpeg), + "webp" => Ok(compress::ImageFmt::Webp), + "avif" => Ok(compress::ImageFmt::Avif), + "gif" => Ok(compress::ImageFmt::Gif), + "bmp" => Ok(compress::ImageFmt::Bmp), + "tif" | "tiff" => Ok(compress::ImageFmt::Tiff), + "ico" => Ok(compress::ImageFmt::Ico), + _ => Err(AppError::new(ErrorCode::InvalidRequest, "未知图片格式")), + } +} + +async fn finalize_file( + state: &AppState, + billing_ctx: &Option, + api_key_id: Option, + source: &str, + task_id: Uuid, + task_file_id: Uuid, + output_path: &str, + bytes_in: i64, + bytes_out: i64, + saved_percent: f64, + format_in: compress::ImageFmt, + format_out: compress::ImageFmt, + charge_units: bool, +) -> Result<(), AppError> { + let mut tx = state + .db + .begin() + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?; + + // Paid users: charge before marking file completed (atomic w/ status update). + if charge_units { + if let Some(billing) = billing_ctx { + charge_one_unit( + &mut tx, + billing, + api_key_id, + source, + task_id, + task_file_id, + format_in, + format_out, + bytes_in as u64, + bytes_out as u64, + ) + .await?; + } + } + + sqlx::query( + r#" + UPDATE task_files + SET storage_path = $2, + compressed_size = $3, + saved_percent = $4, + status = 'completed', + completed_at = NOW() + WHERE id = $1 + "#, + ) + .bind(task_file_id) + .bind(output_path) + .bind(bytes_out) + .bind(saved_percent) + .execute(&mut *tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "更新文件失败").with_source(err))?; + + sqlx::query( + r#" + UPDATE tasks + SET completed_files = completed_files + 1, + total_compressed_size = total_compressed_size + $2 + WHERE id = $1 + "#, + ) + .bind(task_id) + .bind(bytes_out) + .execute(&mut *tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "更新任务统计失败").with_source(err))?; + + tx.commit() + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?; + + Ok(()) +} + +async fn mark_file_failed(state: &AppState, task_id: Uuid, task_file_id: Uuid, message: &str) -> Result<(), AppError> { + let mut tx = state + .db + .begin() + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?; + + let updated = sqlx::query( + r#" + UPDATE task_files + SET status = 'failed', + error_message = $2, + storage_path = NULL, + completed_at = NOW() + WHERE id = $1 + AND status NOT IN ('completed', 'failed') + "#, + ) + .bind(task_file_id) + .bind(message) + .execute(&mut *tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "更新文件失败").with_source(err))?; + + if updated.rows_affected() > 0 { + sqlx::query( + r#" + UPDATE tasks + SET failed_files = failed_files + 1 + WHERE id = $1 + "#, + ) + .bind(task_id) + .execute(&mut *tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "更新任务统计失败").with_source(err))?; + } + + tx.commit() + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?; + + Ok(()) +} + +async fn mark_task_failed(state: &AppState, task_id: Uuid, message: &str) -> Result<(), AppError> { + sqlx::query("UPDATE tasks SET status = 'failed', error_message = $2, completed_at = NOW() WHERE id = $1") + .bind(task_id) + .bind(message) + .execute(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "更新任务失败").with_source(err))?; + Ok(()) +} + +async fn finalize_task_status(state: &AppState, task_id: Uuid) -> Result<(), AppError> { + let row: Option<(i32, i32, i32, String)> = sqlx::query_as( + "SELECT total_files, completed_files, failed_files, status::text AS status FROM tasks WHERE id = $1", + ) + .bind(task_id) + .fetch_optional(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))?; + + let Some((total, completed, failed, status)) = row else { return Ok(()); }; + if status == "cancelled" { + let paths: Vec> = sqlx::query_scalar( + "SELECT storage_path FROM task_files WHERE task_id = $1 AND status IN ('pending','processing')", + ) + .bind(task_id) + .fetch_all(&state.db) + .await + .unwrap_or_default(); + for p in paths.into_iter().flatten() { + let _ = tokio::fs::remove_file(p).await; + } + + let _ = sqlx::query( + "UPDATE task_files SET status = 'failed', error_message = '已取消', storage_path = NULL, completed_at = NOW() WHERE task_id = $1 AND status IN ('pending','processing')", + ) + .bind(task_id) + .execute(&state.db) + .await; + + let _ = sqlx::query( + "UPDATE tasks SET failed_files = GREATEST(total_files - completed_files, 0), completed_at = NOW() WHERE id = $1 AND completed_at IS NULL", + ) + .bind(task_id) + .execute(&state.db) + .await; + return Ok(()); + } + + if completed + failed >= total && total > 0 { + let final_status = if completed == 0 && failed == total { + "failed" + } else { + "completed" + }; + sqlx::query( + "UPDATE tasks SET status = $2::task_status, completed_at = NOW() WHERE id = $1", + ) + .bind(task_id) + .bind(final_status) + .execute(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "更新任务状态失败").with_source(err))?; + } + + Ok(()) +} + +async fn charge_one_unit( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + billing: &billing::BillingContext, + api_key_id: Option, + source: &str, + task_id: Uuid, + task_file_id: Uuid, + format_in: compress::ImageFmt, + format_out: compress::ImageFmt, + bytes_in: u64, + bytes_out: u64, +) -> Result<(), AppError> { + // Ensure usage period row exists. + sqlx::query( + r#" + INSERT INTO usage_periods (user_id, subscription_id, period_start, period_end) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, period_start, period_end) DO NOTHING + "#, + ) + .bind(billing.user_id) + .bind(billing.subscription_id) + .bind(billing.period_start) + .bind(billing.period_end) + .execute(&mut **tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "初始化用量周期失败").with_source(err))?; + + let updated: Option = sqlx::query_scalar( + r#" + UPDATE usage_periods + SET used_units = used_units + 1, + bytes_in = bytes_in + $1, + bytes_out = bytes_out + $2, + updated_at = NOW() + WHERE user_id = $3 + AND period_start = $4 + AND period_end = $5 + AND used_units + 1 <= $6 + bonus_units + RETURNING used_units + "#, + ) + .bind(bytes_in as i64) + .bind(bytes_out as i64) + .bind(billing.user_id) + .bind(billing.period_start) + .bind(billing.period_end) + .bind(billing.plan.included_units_per_period) + .fetch_optional(&mut **tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "扣减配额失败").with_source(err))?; + + if updated.is_none() { + return Err(AppError::new(ErrorCode::QuotaExceeded, "当期配额已用完")); + } + + sqlx::query( + r#" + INSERT INTO usage_events ( + user_id, api_key_id, source, + task_id, task_file_id, + units, bytes_in, bytes_out, format_in, format_out + ) VALUES ( + $1, $2, $3::task_source, + $4, $5, + 1, $6, $7, $8, $9 + ) + "#, + ) + .bind(billing.user_id) + .bind(api_key_id) + .bind(source) + .bind(task_id) + .bind(task_file_id) + .bind(bytes_in as i64) + .bind(bytes_out as i64) + .bind(format_in.as_str()) + .bind(format_out.as_str()) + .execute(&mut **tx) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "写入用量明细失败").with_source(err))?; + + Ok(()) +} + +async fn maintenance(state: &AppState) -> Result<(), AppError> { + cleanup_expired_tasks(state).await?; + cleanup_expired_records(state).await?; + Ok(()) +} + +async fn cleanup_expired_records(state: &AppState) -> Result<(), AppError> { + let _ = sqlx::query("DELETE FROM idempotency_keys WHERE expires_at < NOW()") + .execute(&state.db) + .await; + + let _ = sqlx::query("DELETE FROM email_verifications WHERE expires_at < NOW() AND verified_at IS NULL") + .execute(&state.db) + .await; + + let _ = sqlx::query("DELETE FROM password_resets WHERE expires_at < NOW() - INTERVAL '7 days'") + .execute(&state.db) + .await; + + let _ = sqlx::query("DELETE FROM webhook_events WHERE received_at < NOW() - INTERVAL '90 days'") + .execute(&state.db) + .await; + + Ok(()) +} + +async fn cleanup_expired_tasks(state: &AppState) -> Result<(), AppError> { + let task_ids: Vec = sqlx::query_scalar("SELECT id FROM tasks WHERE expires_at < NOW() LIMIT 200") + .fetch_all(&state.db) + .await + .unwrap_or_default(); + + if task_ids.is_empty() { + return Ok(()); + } + + if state.config.storage_type.to_ascii_lowercase() == "local" { + for task_id in &task_ids { + let paths: Vec> = + sqlx::query_scalar("SELECT storage_path FROM task_files WHERE task_id = $1") + .bind(task_id) + .fetch_all(&state.db) + .await + .unwrap_or_default(); + + for p in paths.into_iter().flatten() { + let _ = tokio::fs::remove_file(p).await; + } + + let zip_path = format!("{}/zips/{task_id}.zip", state.config.storage_path); + let _ = tokio::fs::remove_file(zip_path).await; + + let orig_dir = format!("{}/orig/{task_id}", state.config.storage_path); + let _ = tokio::fs::remove_dir_all(orig_dir).await; + } + } + + for task_id in task_ids { + let _ = sqlx::query("DELETE FROM tasks WHERE id = $1") + .bind(task_id) + .execute(&state.db) + .await; + } + + Ok(()) +} diff --git a/templates/email_verification.html b/templates/email_verification.html new file mode 100644 index 0000000..b430169 --- /dev/null +++ b/templates/email_verification.html @@ -0,0 +1,44 @@ + + + + + + 验证您的邮箱 + + + +
+
+ +
+
+

欢迎注册 ImageForge

+

您好,{{username}}!

+

感谢您注册 ImageForge。请点击下方按钮验证您的邮箱地址:

+

+ 验证邮箱 +

+

或复制以下链接到浏览器打开:

+ +

+ 此链接将在 24 小时后失效。如果您没有注册 ImageForge 账号,请忽略此邮件。 +

+
+ +
+ + + diff --git a/templates/email_verification.txt b/templates/email_verification.txt new file mode 100644 index 0000000..2ed4a92 --- /dev/null +++ b/templates/email_verification.txt @@ -0,0 +1,14 @@ +欢迎注册 ImageForge + +您好,{{username}}! + +感谢您注册 ImageForge。请点击以下链接验证您的邮箱地址: + +{{verification_url}} + +此链接将在 24 小时后失效。如果您没有注册 ImageForge 账号,请忽略此邮件。 + +--- +© {{year}} ImageForge +此邮件由系统自动发送,请勿直接回复。 + diff --git a/templates/password_reset.html b/templates/password_reset.html new file mode 100644 index 0000000..57490ac --- /dev/null +++ b/templates/password_reset.html @@ -0,0 +1,44 @@ + + + + + + 重置密码 + + + +
+
+ +
+
+

重置您的密码

+

您好,{{username}}!

+

我们收到了您的密码重置请求。请点击下方按钮设置新密码:

+

+ 重置密码 +

+

或复制以下链接到浏览器打开:

+ +
+ 此链接将在 1 小时后失效。如果您未发起该请求,请忽略此邮件并建议尽快修改密码。 +
+
+ +
+ + + diff --git a/templates/password_reset.txt b/templates/password_reset.txt new file mode 100644 index 0000000..8e37f48 --- /dev/null +++ b/templates/password_reset.txt @@ -0,0 +1,14 @@ +重置您的 ImageForge 密码 + +您好,{{username}}! + +我们收到了您的密码重置请求。请点击以下链接设置新密码: + +{{reset_url}} + +此链接将在 1 小时后失效。如果您没有发起该请求,请忽略此邮件。 + +--- +© {{year}} ImageForge +此邮件由系统自动发送,请勿直接回复。 +