diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5d813ac --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.env +target +uploads +frontend/node_modules +frontend/dist diff --git a/.env.example b/.env.example index b505260..1f7026e 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,12 @@ REDIS_URL=redis://localhost:6379 # Worker 并发(每个批量任务内同时处理的文件数) WORKER_CONCURRENCY=4 +# 单进程图片处理并发上限(API 与 Worker 均生效,默认等于 CPU 线程数) +IMAGE_PROCESSING_CONCURRENCY=4 + +# 仅当后端只能由可信反向代理访问时启用,否则客户端可伪造来源 IP +TRUST_PROXY_HEADERS=false + # JWT(网站/管理后台) JWT_SECRET=your-super-secret-key-change-in-production JWT_EXPIRY_HOURS=168 @@ -24,17 +30,9 @@ JWT_EXPIRY_HOURS=168 # API Key(仅 Pro/Business 可创建) API_KEY_PEPPER=please-change-this-in-production -# 存储(生产建议 S3/MinIO + 预签名 URL) -STORAGE_TYPE=local # local | s3 +# 存储(当前实现仅支持 local) +STORAGE_TYPE=local 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 diff --git a/Cargo.lock b/Cargo.lock index 590d1ca..7b41938 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,15 +65,6 @@ 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" @@ -222,15 +213,6 @@ 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" @@ -289,26 +271,6 @@ dependencies = [ "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" @@ -425,12 +387,6 @@ 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" @@ -455,15 +411,6 @@ 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" @@ -521,12 +468,6 @@ 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" @@ -763,15 +704,6 @@ 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" @@ -1050,7 +982,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1070,21 +1002,6 @@ 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" @@ -1689,17 +1606,12 @@ dependencies = [ "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", @@ -1733,8 +1645,9 @@ dependencies = [ "jsonwebtoken", "lettre", "oxipng", + "percent-encoding", "rand 0.8.5", - "ravif 0.11.20", + "ravif", "redis", "reqwest", "rgb", @@ -1833,15 +1746,6 @@ 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" @@ -1892,12 +1796,6 @@ 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" @@ -2232,7 +2130,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2407,12 +2305,6 @@ 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" @@ -2603,15 +2495,6 @@ 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" @@ -2670,7 +2553,7 @@ dependencies = [ "once_cell", "socket2 0.6.1", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -2769,12 +2652,12 @@ dependencies = [ "arg_enum_proc_macro", "arrayvec", "av1-grain", - "bitstream-io 2.6.0", - "built 0.7.7", + "bitstream-io", + "built", "cc", "cfg-if", "interpolate_name", - "itertools 0.12.1", + "itertools", "libc", "libfuzzer-sys", "log", @@ -2796,41 +2679,6 @@ dependencies = [ "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" @@ -2841,22 +2689,7 @@ dependencies = [ "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", + "rav1e", "rayon", "rgb", ] @@ -2883,9 +2716,9 @@ dependencies = [ [[package]] name = "redis" -version = "0.24.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c580d9cbbe1d1b479e8d67cf9daf6a62c957e6846048408b80b43ac3f6af84cd" +checksum = "9e23805debcc4435229c51187c0023a4d04499d354c101490e60744c087e973a" dependencies = [ "arc-swap", "async-trait", @@ -3037,7 +2870,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3717,7 +3550,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4378,7 +4211,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c071456adef4aca59bf6a583c46b90ff5eb0b4f758fc347cea81290288f37ce1" dependencies = [ - "image", "libwebp-sys", ] @@ -4764,12 +4596,6 @@ 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" @@ -4969,15 +4795,6 @@ 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" diff --git a/Cargo.toml b/Cargo.toml index f2636a7..e56392c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ 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"] } +redis = { version = "0.24.1", features = ["tokio-comp", "connection-manager", "streams"] } # Auth / security argon2 = "0.5" @@ -33,15 +33,18 @@ base64 = "0.22" hex = "0.4" hmac = "0.12" jsonwebtoken = "9" +percent-encoding = "2" rand = "0.8" sha2 = "0.10" aes-gcm = "0.10" # Images -image = { version = "0.25", features = ["avif-native"] } +# Keep image-rs limited to formats exposed by the API. AVIF decoding uses +# libdav1d on Linux; encoding is handled by the direct ravif dependency. +image = { version = "0.25", default-features = false, features = ["bmp", "gif", "ico", "jpeg", "png", "tiff", "webp"] } oxipng = "9" -ravif = "0.11" -webp = "0.3" +ravif = { version = "0.11", default-features = false, features = ["threading"] } +webp = { version = "0.3", default-features = false } rgb = "0.8" img-parts = "0.4" @@ -54,3 +57,7 @@ lettre = { version = "0.11", default-features = false, features = ["tokio1", "to # ZIP download (batch) tokio-util = { version = "0.7", features = ["io"] } zip = "2" + +[target.'cfg(target_os = "linux")'.dependencies] +image = { version = "0.25", default-features = false, features = ["avif-native"] } +ravif = { version = "0.11", default-features = false, features = ["asm", "threading"] } diff --git a/README.md b/README.md index 97df58d..ae65841 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ | 缓存/队列 | Redis | 会话管理、限流、任务队列(Streams) | | 前端 | Vue3 + TypeScript | SPA 单页应用 | | 认证 | JWT + API Key | 双重认证机制 | -| 存储 | S3 兼容 / 本地 | 对象存储 + 预签名 URL(推荐) | +| 存储 | 本地文件系统 | S3/MinIO 尚在规划中 | | 计费 | Stripe | Checkout/Portal/Webhook | ## 目录结构 @@ -95,8 +95,7 @@ cp .env.example .env # 启动 PostgreSQL / Redis(开发) docker compose -f docker/docker-compose.dev.yml up -d -# 初始化数据库 -psql "$DATABASE_URL" -f migrations/001_init.sql +# 首次启动时会自动按顺序执行 migrations/ 下的全部迁移 # 启动后端 cargo run diff --git a/docker/Dockerfile b/docker/Dockerfile index 0fd537f..103c4cf 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -4,6 +4,7 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ cmake \ + libdav1d-dev \ nasm \ pkg-config \ && rm -rf /var/lib/apt/lists/* @@ -27,6 +28,7 @@ FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ + libdav1d6 \ && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/docker/nginx.conf b/docker/nginx.conf index 2d5dcfc..82fd96d 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -28,7 +28,7 @@ http { 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-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; } @@ -36,7 +36,7 @@ http { 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-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; } diff --git a/docs/deployment.md b/docs/deployment.md index 3fb0089..87b08c5 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -20,6 +20,7 @@ sudo apt install -y \ libssl-dev \ libpq-dev \ cmake \ + libdav1d-dev \ nasm \ libjpeg-dev \ libpng-dev \ @@ -71,7 +72,7 @@ services: volumes: - redis_data:/data - # 可选:MinIO(S3 兼容,本地开发更接近生产) + # 规划:接入 S3 存储后可增加 MinIO;当前版本仅支持本地存储 # minio: # image: minio/minio:RELEASE.2024-01-28T20-20-01Z # command: server /data --console-address ":9001" @@ -116,6 +117,12 @@ REDIS_URL=redis://localhost:6379 # Worker 并发(每个批量任务内同时处理的文件数) WORKER_CONCURRENCY=4 +# 图片处理全局并发(每个 API/Worker 进程) +IMAGE_PROCESSING_CONCURRENCY=4 + +# 仅在后端端口不对公网开放、请求必经可信代理时启用 +TRUST_PROXY_HEADERS=false + # JWT(网站/管理后台) JWT_SECRET=your-super-secret-key-change-in-production JWT_EXPIRY_HOURS=168 @@ -123,18 +130,9 @@ JWT_EXPIRY_HOURS=168 # API Key API_KEY_PEPPER=please-change-this-in-production -# 存储(生产建议 S3/MinIO + 预签名 URL) -STORAGE_TYPE=local # local | s3 +# 存储(当前实现仅支持 local) +STORAGE_TYPE=local 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 @@ -159,10 +157,7 @@ ADMIN_PASSWORD=changeme123 ### 3. 初始化数据库 -```bash -# 运行首期迁移(可重复执行) -psql "$DATABASE_URL" -f migrations/001_init.sql -``` +API 或 Worker 启动时会通过 SQLx 自动、顺序执行 `migrations/` 下尚未应用的迁移。迁移失败时进程会退出,不会在不完整的数据库结构上继续提供服务。 ### 4. 启动开发服务器 @@ -211,6 +206,7 @@ services: dockerfile: docker/Dockerfile environment: - IMAGEFORGE_ROLE=api + - TRUST_PROXY_HEADERS=true - BILLING_PROVIDER=stripe - PUBLIC_BASE_URL=https://your-domain.com - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY} @@ -218,10 +214,11 @@ services: - DATABASE_URL=postgres://imageforge:${DB_PASSWORD}@postgres:5432/imageforge - REDIS_URL=redis://redis:6379 - JWT_SECRET=${JWT_SECRET} + - API_KEY_PEPPER=${API_KEY_PEPPER} - STORAGE_TYPE=local - STORAGE_PATH=/app/uploads - ports: - - "8080:8080" + expose: + - "8080" volumes: - uploads:/app/uploads depends_on: @@ -238,6 +235,7 @@ services: - DATABASE_URL=postgres://imageforge:${DB_PASSWORD}@postgres:5432/imageforge - REDIS_URL=redis://redis:6379 - JWT_SECRET=${JWT_SECRET} + - API_KEY_PEPPER=${API_KEY_PEPPER} - STORAGE_TYPE=local - STORAGE_PATH=/app/uploads volumes: @@ -289,6 +287,7 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ cmake \ + libdav1d-dev \ nasm \ pkg-config \ && rm -rf /var/lib/apt/lists/* @@ -314,6 +313,7 @@ FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ + libdav1d6 \ && rm -rf /var/lib/apt/lists/* WORKDIR /app @@ -397,7 +397,7 @@ http { 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-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7f6f610..4fe0f35 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -612,9 +612,9 @@ "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==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", "cpu": [ "arm" ], @@ -626,9 +626,9 @@ ] }, "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==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", "cpu": [ "arm64" ], @@ -640,9 +640,9 @@ ] }, "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==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", "cpu": [ "arm64" ], @@ -654,9 +654,9 @@ ] }, "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==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", "cpu": [ "x64" ], @@ -668,9 +668,9 @@ ] }, "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==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", "cpu": [ "arm64" ], @@ -682,9 +682,9 @@ ] }, "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==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", "cpu": [ "x64" ], @@ -696,9 +696,9 @@ ] }, "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==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", "cpu": [ "arm" ], @@ -710,9 +710,9 @@ ] }, "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==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", "cpu": [ "arm" ], @@ -724,9 +724,9 @@ ] }, "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==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", "cpu": [ "arm64" ], @@ -738,9 +738,9 @@ ] }, "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==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", "cpu": [ "arm64" ], @@ -752,9 +752,23 @@ ] }, "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==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", "cpu": [ "loong64" ], @@ -766,9 +780,23 @@ ] }, "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==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", "cpu": [ "ppc64" ], @@ -780,9 +808,9 @@ ] }, "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==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", "cpu": [ "riscv64" ], @@ -794,9 +822,9 @@ ] }, "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==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", "cpu": [ "riscv64" ], @@ -808,9 +836,9 @@ ] }, "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==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", "cpu": [ "s390x" ], @@ -822,9 +850,9 @@ ] }, "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==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", "cpu": [ "x64" ], @@ -836,9 +864,9 @@ ] }, "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==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", "cpu": [ "x64" ], @@ -849,10 +877,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "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==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", "cpu": [ "arm64" ], @@ -864,9 +906,9 @@ ] }, "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==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", "cpu": [ "arm64" ], @@ -878,9 +920,9 @@ ] }, "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==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", "cpu": [ "ia32" ], @@ -892,9 +934,9 @@ ] }, "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==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", "cpu": [ "x64" ], @@ -906,9 +948,9 @@ ] }, "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==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", "cpu": [ "x64" ], @@ -947,9 +989,9 @@ } }, "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==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -959,7 +1001,6 @@ "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1259,9 +1300,9 @@ } }, "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==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -1316,13 +1357,16 @@ } }, "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==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", + "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/binary-extensions": { @@ -1380,7 +1424,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1406,9 +1449,9 @@ } }, "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==", + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "dev": true, "funding": [ { @@ -1885,9 +1928,9 @@ } }, "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==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -1923,9 +1966,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -2004,12 +2047,11 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2059,9 +2101,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -2077,9 +2119,8 @@ } ], "license": "MIT", - "peer": true, "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2259,9 +2300,9 @@ } }, "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==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -2310,13 +2351,13 @@ "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==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -2326,28 +2367,31 @@ "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", + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" } }, @@ -2447,7 +2491,6 @@ "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -2546,7 +2589,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2601,14 +2643,13 @@ "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==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -2688,7 +2729,6 @@ "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", @@ -2744,9 +2784,9 @@ } }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "dev": true, "license": "ISC", "bin": { diff --git a/src/api/admin.rs b/src/api/admin.rs index 29305c0..c43dc51 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -11,7 +11,7 @@ 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 chrono::{DateTime, Datelike, Duration, FixedOffset, TimeZone, Timelike, Utc}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; use std::net::{IpAddr, SocketAddr}; @@ -108,7 +108,10 @@ async fn resolve_user_id(state: &AppState, identifier: &str) -> Result 1 { - return Err(AppError::new(ErrorCode::InvalidRequest, "用户 ID 前缀不唯一")); + return Err(AppError::new( + ErrorCode::InvalidRequest, + "用户 ID 前缀不唯一", + )); } } @@ -260,7 +263,10 @@ async fn list_users( 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 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); @@ -414,7 +420,10 @@ async fn list_tasks( 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 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") @@ -723,7 +732,12 @@ async fn grant_credits( .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)) + ( + 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) @@ -853,7 +867,10 @@ async fn create_manual_subscription( let months = req.months.unwrap_or(1); if months <= 0 || months > 24 { - return Err(AppError::new(ErrorCode::InvalidRequest, "months 需在 1-24 之间")); + return Err(AppError::new( + ErrorCode::InvalidRequest, + "months 需在 1-24 之间", + )); } let user_id = resolve_user_id(&state, &req.user_id).await?; @@ -1012,7 +1029,10 @@ fn days_in_month(tz: FixedOffset, year: i32, month: u32) -> u32 { } else { (year, month + 1) }; - let first_next = tz.with_ymd_and_hms(next_year, next_month, 1, 0, 0, 0).single().unwrap(); + 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() } @@ -1155,29 +1175,30 @@ async fn get_stripe_config( 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)), - ) - }; + 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, @@ -1227,7 +1248,8 @@ async fn update_stripe_config( if webhook_secret.is_empty() { stored.webhook_secret_encrypted = None; } else { - stored.webhook_secret_encrypted = Some(settings::encrypt_secret(&state, &webhook_secret)?); + stored.webhook_secret_encrypted = + Some(settings::encrypt_secret(&state, &webhook_secret)?); } } @@ -1343,10 +1365,16 @@ async fn update_mail_config( 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") + AppError::new( + ErrorCode::InvalidRequest, + "自定义 SMTP 需要填写 host/port/encryption", + ) })?; if custom.host.trim().is_empty() { - return Err(AppError::new(ErrorCode::InvalidRequest, "SMTP host 不能为空")); + return Err(AppError::new( + ErrorCode::InvalidRequest, + "SMTP host 不能为空", + )); } } @@ -1387,8 +1415,9 @@ async fn update_mail_config( settings::upsert_system_config( &state, "mail", - serde_json::to_value(&stored) - .map_err(|err| AppError::new(ErrorCode::Internal, "序列化邮件配置失败").with_source(err))?, + serde_json::to_value(&stored).map_err(|err| { + AppError::new(ErrorCode::Internal, "序列化邮件配置失败").with_source(err) + })?, Some("邮件服务配置"), Some(admin_id), ) @@ -1423,15 +1452,11 @@ async fn test_mail( 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 to = req + .to + .as_ref() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); let recipient = if let Some(to) = to { to @@ -1440,13 +1465,17 @@ async fn test_mail( .bind(admin_id) .fetch_one(&state.db) .await - .map_err(|err| AppError::new(ErrorCode::Internal, "查询管理员邮箱失败").with_source(err))?; + .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))?; + .map_err(|err| { + AppError::new(ErrorCode::MailSendFailed, "测试邮件发送失败").with_source(err) + })?; Ok(Json(Envelope { success: true, diff --git a/src/api/auth.rs b/src/api/auth.rs index a34e9f8..dd47849 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -1,16 +1,11 @@ -use crate::auth; use crate::api::envelope::Envelope; +use crate::auth; 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 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; @@ -106,8 +101,12 @@ async fn register( .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 (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); @@ -133,7 +132,9 @@ async fn register( mail::send_verification_email(&state, &user.email, &user.username, &verification_url) .await - .map_err(|err| AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err))?; + .map_err(|err| { + AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err) + })?; let body = RegisterResponse { user: UserView { @@ -159,7 +160,10 @@ async fn login( ) -> Result>, AppError> { let identity = req.email.trim(); if identity.is_empty() { - return Err(AppError::new(ErrorCode::InvalidRequest, "邮箱或用户名不能为空")); + return Err(AppError::new( + ErrorCode::InvalidRequest, + "邮箱或用户名不能为空", + )); } let user = if identity.contains('@') { @@ -210,8 +214,12 @@ async fn login( 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)?; + 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, @@ -241,7 +249,11 @@ async fn send_verification( 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 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) @@ -257,7 +269,10 @@ async fn send_verification( .unwrap_or(()); } if count > 1 { - return Err(AppError::new(ErrorCode::RateLimited, "发送过于频繁,请稍后再试")); + return Err(AppError::new( + ErrorCode::RateLimited, + "发送过于频繁,请稍后再试", + )); } let user = sqlx::query_as::<_, UserRow>( @@ -313,7 +328,9 @@ async fn send_verification( mail::send_verification_email(&state, &user.email, &user.username, &verification_url) .await - .map_err(|err| AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err))?; + .map_err(|err| { + AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err) + })?; Ok(Json(Envelope { success: true, @@ -434,7 +451,8 @@ async fn forgot_password( state.config.public_base_url, reset_token ); - let _ = mail::send_password_reset_email(&state, &user.email, &user.username, &reset_url).await; + let _ = + mail::send_password_reset_email(&state, &user.email, &user.username, &reset_url).await; } Ok(Json(Envelope { @@ -497,12 +515,14 @@ async fn reset_password( .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))?; + 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 diff --git a/src/api/billing.rs b/src/api/billing.rs index 19e42d4..82db557 100644 --- a/src/api/billing.rs +++ b/src/api/billing.rs @@ -46,7 +46,9 @@ struct PlansResponse { plans: Vec, } -async fn list_plans(State(state): State) -> Result>, AppError> { +async fn list_plans( + State(state): State, +) -> Result>, AppError> { let plans = sqlx::query_as::<_, PlanView>( r#" SELECT @@ -422,7 +424,8 @@ async fn create_checkout( }); let mut idem_acquired = false; - if let (Some(idem), Some(request_hash)) = (idempotency_key.as_deref(), request_hash.as_deref()) { + if let (Some(idem), Some(request_hash)) = (idempotency_key.as_deref(), request_hash.as_deref()) + { match idempotency::begin( &state, idempotency::Scope::User(user_id), @@ -433,10 +436,14 @@ async fn create_checkout( .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 })); + 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( @@ -451,14 +458,17 @@ async fn create_checkout( 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 Ok(Json(Envelope { + success: true, + data: resp, + })); } return Err(AppError::new( ErrorCode::InvalidRequest, "请求正在处理中,请稍后重试", )); } - idempotency::BeginResult::Acquired { .. } => { + idempotency::BeginResult::Acquired => { idem_acquired = true; } } @@ -525,8 +535,10 @@ async fn create_checkout( cus }; - let success_url = - format!("{}/dashboard/billing?checkout=success", state.config.public_base_url); + 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( @@ -619,7 +631,10 @@ async fn create_portal( .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")); + return Err(AppError::new( + ErrorCode::InvalidRequest, + "未找到 Stripe Customer", + )); }; let return_url = format!("{}/dashboard/billing", state.config.public_base_url); @@ -631,7 +646,11 @@ async fn create_portal( })) } -async fn stripe_create_customer(secret: &str, email: &str, user_id: Uuid) -> Result { +async fn stripe_create_customer( + secret: &str, + email: &str, + user_id: Uuid, +) -> Result { let resp: serde_json::Value = stripe_post_form( secret, "/v1/customers", @@ -723,10 +742,9 @@ async fn stripe_post_form( .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))?; + 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"); diff --git a/src/api/compress.rs b/src/api/compress.rs index 2ed394c..2c3dbb5 100644 --- a/src/api/compress.rs +++ b/src/api/compress.rs @@ -54,7 +54,7 @@ struct CompressRequest { file_bytes: Vec, level: CompressionLevel, compression_rate: Option, - target_size_bytes: Option, // 新增:直接指定目标大小(字节) + target_size_bytes: Option, // 新增:直接指定目标大小(字节) output_format: Option, max_width: Option, max_height: Option, @@ -99,11 +99,17 @@ async fn compress_json( ConnectInfo(addr): ConnectInfo, headers: HeaderMap, mut multipart: Multipart, -) -> Result<(axum_extra::extract::cookie::CookieJar, Json>), AppError> { +) -> 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 mut req = parse_single_file_request(&mut multipart).await?; let format_in = compress::detect_format(&req.file_bytes)?; let format_out = req.output_format.unwrap_or(format_in); @@ -121,7 +127,9 @@ async fn compress_json( .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::ApiKey { api_key_id, .. } => { + Some(idempotency::Scope::ApiKey(*api_key_id)) + } _ => None, }; @@ -139,7 +147,12 @@ async fn compress_json( .compression_rate .map(|v| v.to_string()) .unwrap_or_default(); + let target_size_key = req + .target_size_bytes + .map(|v| v.to_string()) + .unwrap_or_default(); h.update(rate_key.as_bytes()); + h.update(target_size_key.as_bytes()); h.update(mw.as_bytes()); h.update(mh.as_bytes()); h.update(preserve.as_bytes()); @@ -211,32 +224,40 @@ async fn compress_json( .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 }))); + 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? + 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 Ok(( + jar, + Json(Envelope { + success: true, + data: resp, + }), + )); } return Err(AppError::new( ErrorCode::InvalidRequest, "请求正在处理中,请稍后重试", )); } - idempotency::BeginResult::Acquired { .. } => { + idempotency::BeginResult::Acquired => { idem_acquired = true; } } @@ -249,21 +270,22 @@ async fn compress_json( QuotaContext::Anonymous { .. } => {} } + let original_size = req.file_bytes.len() as u64; + let input = std::mem::take(&mut req.file_bytes); let compressed = compress::compress_image_bytes( &state, - &req.file_bytes, + input, format_in, format_out, effective_level, req.compression_rate, - req.target_size_bytes, // 新增:目标大小 + req.target_size_bytes, // 新增:目标大小 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 { @@ -280,7 +302,7 @@ async fn compress_json( } } - if state.config.storage_type.to_ascii_lowercase() != "local" { + if !state.config.storage_type.eq_ignore_ascii_case("local") { return Err(AppError::new( ErrorCode::StorageUnavailable, "当前仅支持本地存储(STORAGE_TYPE=local)", @@ -370,7 +392,13 @@ async fn compress_json( .await; } } - Ok((jar, Json(Envelope { success: true, data: resp }))) + Ok(( + jar, + Json(Envelope { + success: true, + data: resp, + }), + )) } Err(err) => { if let (Some(scope), Some(idem_key), Some(request_hash)) = ( @@ -393,7 +421,13 @@ async fn compress_direct( ConnectInfo(addr): ConnectInfo, headers: HeaderMap, mut multipart: Multipart, -) -> Result<(axum_extra::extract::cookie::CookieJar, axum::response::Response), AppError> { +) -> 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?; @@ -404,7 +438,7 @@ async fn compress_direct( )); } - let req = parse_single_file_request(&mut multipart).await?; + let mut req = parse_single_file_request(&mut multipart).await?; let email_verified = match &principal { context::Principal::User { email_verified, .. } => *email_verified, @@ -431,7 +465,9 @@ async fn compress_direct( .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::ApiKey { api_key_id, .. } => { + Some(idempotency::Scope::ApiKey(*api_key_id)) + } context::Principal::Anonymous { .. } => None, }; @@ -449,7 +485,12 @@ async fn compress_direct( .compression_rate .map(|v| v.to_string()) .unwrap_or_default(); + let target_size_key = req + .target_size_bytes + .map(|v| v.to_string()) + .unwrap_or_default(); h.update(rate_key.as_bytes()); + h.update(target_size_key.as_bytes()); h.update(mw.as_bytes()); h.update(mh.as_bytes()); h.update(preserve.as_bytes()); @@ -505,7 +546,8 @@ async fn compress_direct( 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 (bytes, fmt) = + load_direct_replay_bytes(&state, &principal, data.file_id).await?; let mut resp_headers = HeaderMap::new(); resp_headers.insert( @@ -537,14 +579,9 @@ async fn compress_direct( 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? + 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| { @@ -587,7 +624,7 @@ async fn compress_direct( "请求正在处理中,请稍后重试", )); } - idempotency::BeginResult::Acquired { .. } => { + idempotency::BeginResult::Acquired => { idem_acquired = true; } } @@ -600,21 +637,22 @@ async fn compress_direct( QuotaContext::Anonymous { .. } => {} } + let original_size = req.file_bytes.len() as u64; + let input = std::mem::take(&mut req.file_bytes); let compressed = compress::compress_image_bytes( &state, - &req.file_bytes, + input, format_in, format_out, effective_level, req.compression_rate, - req.target_size_bytes, // 新增:目标大小 + req.target_size_bytes, // 新增:目标大小 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 { @@ -625,7 +663,7 @@ async fn compress_direct( let skip_charge = req.compression_rate == Some(100); let charge_units = !skip_charge && compressed_size < original_size; - if state.config.storage_type.to_ascii_lowercase() != "local" { + if !state.config.storage_type.eq_ignore_ascii_case("local") { return Err(AppError::new( ErrorCode::StorageUnavailable, "当前仅支持本地存储(STORAGE_TYPE=local)", @@ -689,7 +727,10 @@ async fn compress_direct( "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-Bytes", + saved_bytes.to_string().parse().unwrap(), + ); resp_headers.insert( "ImageForge-Saved-Percent", format!("{saved_percent:.2}").parse().unwrap(), @@ -752,12 +793,12 @@ async fn compress_direct( } 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))?; + 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(()) } @@ -811,7 +852,9 @@ async fn load_direct_replay_bytes( .fetch_optional(&state.db) .await } - context::Principal::Anonymous { .. } => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")), + 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, "文件不存在"))?; @@ -826,9 +869,9 @@ async fn load_direct_replay_bytes( return Err(AppError::new(ErrorCode::NotFound, "文件不存在")); }; - let bytes = tokio::fs::read(&path) - .await - .map_err(|err| AppError::new(ErrorCode::StorageUnavailable, "读取文件失败").with_source(err))?; + 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)) @@ -840,36 +883,27 @@ async fn parse_single_file_request(multipart: &mut Multipart) -> Result = None; let mut compression_rate: Option = None; - let mut target_size_bytes: Option = None; // 新增 + let mut target_size_bytes: 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))? - { + 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_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))?; + let text = field.text().await.map_err(|err| { + AppError::new(ErrorCode::InvalidRequest, "读取字段失败").with_source(err) + })?; match name.as_str() { "level" => { @@ -890,19 +924,17 @@ async fn parse_single_file_request(multipart: &mut Multipart) -> Result { let v = text.trim(); if !v.is_empty() { - max_width = Some( - v.parse::() - .map_err(|_| AppError::new(ErrorCode::InvalidRequest, "max_width 格式错误"))?, - ); + 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 格式错误"))?, - ); + max_height = Some(v.parse::().map_err(|_| { + AppError::new(ErrorCode::InvalidRequest, "max_height 格式错误") + })?); } } "preserve_metadata" => { @@ -914,10 +946,12 @@ async fn parse_single_file_request(multipart: &mut Multipart) -> Result { let v = text.trim(); if !v.is_empty() { - target_size_bytes = Some( - v.parse::() - .map_err(|_| AppError::new(ErrorCode::InvalidRequest, "target_size_bytes 格式错误,需为正整数(字节)"))?, - ); + target_size_bytes = Some(v.parse::().map_err(|_| { + AppError::new( + ErrorCode::InvalidRequest, + "target_size_bytes 格式错误,需为正整数(字节)", + ) + })?); // 最小目标大小限制:1KB if let Some(size) = target_size_bytes { if size < 1024 { @@ -933,7 +967,8 @@ async fn parse_single_file_request(multipart: &mut Multipart) -> Result Result Result<(), A if bytes.len() as u64 > max { return Err(AppError::new( ErrorCode::FileTooLarge, - format!("匿名试用单文件最大 {} MB", state.config.anon_max_file_size_mb), + format!( + "匿名试用单文件最大 {} MB", + state.config.anon_max_file_size_mb + ), )); } Ok(()) @@ -1020,6 +1058,7 @@ async fn ensure_quota_available( Ok(()) } +#[allow(clippy::too_many_arguments)] async fn record_task_and_metering( state: &AppState, principal: &context::Principal, @@ -1042,9 +1081,15 @@ async fn record_task_and_metering( 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::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"), + context::Principal::ApiKey { + user_id, + api_key_id, + .. + } => (Some(*user_id), None, Some(*api_key_id), "api"), }; let mut tx = state @@ -1161,6 +1206,7 @@ async fn record_task_and_metering( Ok(()) } +#[allow(clippy::too_many_arguments)] async fn charge_one_unit( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, billing: &BillingContext, diff --git a/src/api/context.rs b/src/api/context.rs index c54a392..9d34af9 100644 --- a/src/api/context.rs +++ b/src/api/context.rs @@ -18,8 +18,14 @@ 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 }, + Anonymous { + session_id: String, + }, + User { + user_id: Uuid, + role: String, + email_verified: bool, + }, ApiKey { user_id: Uuid, api_key_id: Uuid, @@ -29,6 +35,14 @@ pub enum Principal { } pub fn client_ip(headers: &HeaderMap, connect_ip: IpAddr) -> IpAddr { + resolve_client_ip(headers, connect_ip, crate::config::trust_proxy_headers()) +} + +fn resolve_client_ip(headers: &HeaderMap, connect_ip: IpAddr, trust_proxy: bool) -> IpAddr { + if !trust_proxy { + return connect_ip; + } + if let Some(ip) = parse_forwarded_for(headers) { return ip; } @@ -69,7 +83,13 @@ pub async fn authenticate( return Err(AppError::new(ErrorCode::Unauthorized, "未登录")); } - let (jar, session_id) = ensure_session_cookie(jar); + let cookie_secure = state + .config + .public_base_url + .trim() + .to_ascii_lowercase() + .starts_with("https://"); + let (jar, session_id) = ensure_session_cookie(jar, cookie_secure); Ok((jar, Principal::Anonymous { session_id })) } @@ -178,11 +198,12 @@ async fn try_api_key( 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; + 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, @@ -192,7 +213,7 @@ async fn try_api_key( })) } -pub fn ensure_session_cookie(jar: CookieJar) -> (CookieJar, String) { +pub fn ensure_session_cookie(jar: CookieJar, secure: bool) -> (CookieJar, String) { if let Some(cookie) = jar.get("if_session") { let session_id = cookie.value().trim().to_string(); if !session_id.is_empty() { @@ -204,6 +225,7 @@ pub fn ensure_session_cookie(jar: CookieJar) -> (CookieJar, String) { let cookie = Cookie::build(("if_session", session_id.clone())) .path("/") .http_only(true) + .secure(secure) .same_site(SameSite::Lax) .max_age(TimeDuration::days(7)) .build(); @@ -220,10 +242,37 @@ fn generate_session_id() -> String { 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))?; + 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)) } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn untrusted_proxy_headers_cannot_override_peer_ip() { + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-for", "203.0.113.9".parse().unwrap()); + let peer = "192.0.2.10".parse().unwrap(); + assert_eq!(resolve_client_ip(&headers, peer, false), peer); + } + + #[test] + fn trusted_proxy_headers_use_forwarded_client_ip() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-forwarded-for", + "203.0.113.9, 192.0.2.20".parse().unwrap(), + ); + let peer = "192.0.2.10".parse().unwrap(); + assert_eq!( + resolve_client_ip(&headers, peer, true), + "203.0.113.9".parse::().unwrap() + ); + } +} diff --git a/src/api/downloads.rs b/src/api/downloads.rs index fc5d821..32126ac 100644 --- a/src/api/downloads.rs +++ b/src/api/downloads.rs @@ -2,13 +2,14 @@ use crate::api::context; use crate::error::{AppError, ErrorCode}; use crate::state::AppState; +use axum::body::Body; 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 percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; use sqlx::FromRow; use std::collections::HashMap; use std::net::SocketAddr; @@ -78,9 +79,11 @@ async fn download_file( return Err(AppError::new(ErrorCode::NotFound, "文件不存在")); }; - let bytes = tokio::fs::read(path) - .await - .map_err(|err| AppError::new(ErrorCode::StorageUnavailable, "读取文件失败").with_source(err))?; + let file = tokio::fs::File::open(path).await.map_err(|err| { + AppError::new(ErrorCode::StorageUnavailable, "读取文件失败").with_source(err) + })?; + let content_length = file.metadata().await.ok().map(|metadata| metadata.len()); + let body = Body::from_stream(ReaderStream::new(file)); let mut resp_headers = HeaderMap::new(); resp_headers.insert( @@ -89,12 +92,16 @@ async fn download_file( ); resp_headers.insert( header::CONTENT_DISPOSITION, - format!("attachment; filename=\"{}\"", sanitize_filename(&row.original_name)) - .parse() - .unwrap(), + content_disposition(&row.original_name)?, ); + if let Some(content_length) = content_length { + resp_headers.insert( + header::CONTENT_LENGTH, + content_length.to_string().parse().unwrap(), + ); + } - Ok((jar, (resp_headers, bytes).into_response())) + Ok((jar, (resp_headers, body).into_response())) } fn authorize_download(principal: &context::Principal, row: &DownloadRow) -> Result<(), AppError> { @@ -129,12 +136,40 @@ fn sanitize_filename(name: &str) -> String { out = "download".to_string(); } out = out.replace(['\r', '\n', '"', '\\'], "_"); - if out.len() > 120 { - out.truncate(120); - } + truncate_utf8(&mut out, 120); out } +fn truncate_utf8(value: &mut String, max_bytes: usize) { + if value.len() <= max_bytes { + return; + } + + let mut end = max_bytes; + while !value.is_char_boundary(end) { + end -= 1; + } + value.truncate(end); +} + +fn content_disposition(name: &str) -> Result { + let sanitized = sanitize_filename(name); + let ascii_fallback: String = sanitized + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') { + ch + } else { + '_' + } + }) + .collect(); + let encoded = utf8_percent_encode(&sanitized, NON_ALPHANUMERIC); + let value = format!("attachment; filename=\"{ascii_fallback}\"; filename*=UTF-8''{encoded}"); + axum::http::HeaderValue::from_str(&value) + .map_err(|err| AppError::new(ErrorCode::Internal, "生成下载文件名失败").with_source(err)) +} + #[derive(Debug, FromRow)] struct TaskZipRow { user_id: Option, @@ -146,7 +181,6 @@ struct TaskZipRow { #[derive(Debug, FromRow)] struct TaskZipFileRow { - id: Uuid, storage_path: Option, original_name: String, output_format: String, @@ -201,7 +235,7 @@ async fn download_task_zip( } } - if state.config.storage_type.to_ascii_lowercase() != "local" { + if !state.config.storage_type.eq_ignore_ascii_case("local") { return Err(AppError::new( ErrorCode::StorageUnavailable, "当前仅支持本地存储(STORAGE_TYPE=local)", @@ -209,9 +243,9 @@ async fn download_task_zip( } 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))?; + 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) { @@ -220,7 +254,7 @@ async fn download_task_zip( let rows = sqlx::query_as::<_, TaskZipFileRow>( r#" - SELECT id, storage_path, original_name, output_format + SELECT storage_path, original_name, output_format FROM task_files WHERE task_id = $1 AND status = 'completed' ORDER BY created_at ASC @@ -238,7 +272,9 @@ async fn download_task_zip( 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 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)); } @@ -248,10 +284,12 @@ async fn download_task_zip( 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))?; + 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 } @@ -261,9 +299,9 @@ async fn stream_zip( 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 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); @@ -298,7 +336,11 @@ fn build_zip_entry_name( _ => "bin", }; - let base = if base.is_empty() { "file".to_string() } else { base }; + 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 { @@ -314,19 +356,21 @@ fn build_zip_entry_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); - } + truncate_utf8(&mut out, 120); out } -fn generate_zip_file(zip_path: &PathBuf, task_id: &str, entries: &[(String, String)]) -> Result<(), String> { +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); + let options = + zip::write::FileOptions::<()>::default().compression_method(zip::CompressionMethod::Stored); for (name, path) in entries { zip.start_file(name, options) @@ -341,3 +385,30 @@ fn generate_zip_file(zip_path: &PathBuf, task_id: &str, entries: &[(String, Stri tracing::info!(task_id = %task_id, path = %zip_path.to_string_lossy(), "ZIP generated"); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn content_disposition_supports_unicode_names() { + let value = content_disposition("测试 图片.jpg").unwrap(); + let value = value.to_str().unwrap(); + assert!(value.contains("filename=\"_____.jpg\"")); + assert!(value.contains("filename*=UTF-8''")); + assert!(value.contains("%E6%B5%8B%E8%AF%95")); + } + + #[test] + fn sanitize_filename_blocks_header_injection() { + assert_eq!(sanitize_filename("a\r\n\"b\\c.png"), "a___b_c.png"); + } + + #[test] + fn sanitize_filename_truncates_at_utf8_boundary() { + let name = format!("{}中.png", "a".repeat(119)); + let sanitized = sanitize_filename(&name); + assert_eq!(sanitized, "a".repeat(119)); + assert!(sanitized.is_char_boundary(sanitized.len())); + } +} diff --git a/src/api/envelope.rs b/src/api/envelope.rs index 67cb974..89c7a4e 100644 --- a/src/api/envelope.rs +++ b/src/api/envelope.rs @@ -5,4 +5,3 @@ pub struct Envelope { pub success: bool, pub data: T, } - diff --git a/src/api/health.rs b/src/api/health.rs index 67e569c..7e03389 100644 --- a/src/api/health.rs +++ b/src/api/health.rs @@ -2,6 +2,9 @@ use crate::state::AppState; use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; use serde::Serialize; +use std::time::Duration; + +const DEPENDENCY_TIMEOUT: Duration = Duration::from_secs(2); #[derive(Debug, Serialize)] struct HealthResponse { @@ -11,16 +14,24 @@ struct HealthResponse { } pub async fn health(State(state): State) -> impl IntoResponse { - let database_ok = sqlx::query("SELECT 1") - .execute(&state.db) + let database_check = async { + tokio::time::timeout( + DEPENDENCY_TIMEOUT, + 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) + .is_ok_and(|result| result.is_ok()) + }; + let redis_check = async { + let mut redis_conn = state.redis.clone(); + tokio::time::timeout( + DEPENDENCY_TIMEOUT, + redis::cmd("PING").query_async::<_, String>(&mut redis_conn), + ) .await - .is_ok(); + .is_ok_and(|result| result.is_ok()) + }; + let (database_ok, redis_ok) = tokio::join!(database_check, redis_check); let status = if database_ok && redis_ok { StatusCode::OK @@ -29,11 +40,18 @@ pub async fn health(State(state): State) -> impl IntoResponse { }; let body = HealthResponse { - status: if status == StatusCode::OK { "healthy" } else { "unhealthy" }, - database: if database_ok { "connected" } else { "unavailable" }, + 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 index 7e2a7d0..8f290a7 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,15 +1,15 @@ -mod auth; -mod context; -mod envelope; -mod compress; -mod downloads; -mod billing; -mod webhooks; -mod user; -mod tasks; mod admin; +mod auth; +mod billing; +mod compress; +mod context; +mod downloads; +mod envelope; mod health; mod response; +mod tasks; +mod user; +mod webhooks; use crate::error::{AppError, ErrorCode}; use crate::state::AppState; @@ -23,14 +23,11 @@ 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, "管理员账号初始化失败"); - } + crate::services::bootstrap::ensure_schema(&state).await?; + crate::services::bootstrap::ensure_admin_user(&state).await?; - let static_service = ServeDir::new("static").not_found_service(ServeFile::new("static/index.html")); + let static_service = + ServeDir::new("static").not_found_service(ServeFile::new("static/index.html")); let v1 = v1_router().layer(DefaultBodyLimit::max(100 * 1024 * 1024)); @@ -48,9 +45,12 @@ pub async fn run(state: AppState) -> Result<(), AppError> { 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)) + 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 { diff --git a/src/api/response.rs b/src/api/response.rs index 572e741..46e95bd 100644 --- a/src/api/response.rs +++ b/src/api/response.rs @@ -3,4 +3,3 @@ 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 index cb720d5..a00d2d6 100644 --- a/src/api/tasks.rs +++ b/src/api/tasks.rs @@ -62,11 +62,17 @@ async fn create_batch_task( ConnectInfo(addr): ConnectInfo, headers: HeaderMap, mut multipart: Multipart, -) -> Result<(axum_extra::extract::cookie::CookieJar, Json>), AppError> { +) -> 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" { + if !state.config.storage_type.eq_ignore_ascii_case("local") { return Err(AppError::new( ErrorCode::StorageUnavailable, "当前仅支持本地存储(STORAGE_TYPE=local)", @@ -81,7 +87,9 @@ async fn create_batch_task( .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::ApiKey { api_key_id, .. } => { + Some(idempotency::Scope::ApiKey(*api_key_id)) + } _ => None, }; @@ -110,31 +118,38 @@ async fn create_batch_task( 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 }))); + 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? + 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 Ok(( + jar, + Json(Envelope { + success: true, + data: resp, + }), + )); } return Err(AppError::new( ErrorCode::InvalidRequest, "请求正在处理中,请稍后重试", )); } - idempotency::BeginResult::Acquired { .. } => { + idempotency::BeginResult::Acquired => { idem_acquired = true; } } @@ -212,16 +227,18 @@ async fn create_batch_task( 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)), + 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))?; + let mut tx = + state.db.begin().await.map_err(|err| { + AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err) + })?; sqlx::query( r#" @@ -285,7 +302,9 @@ async fn create_batch_task( .bind(&file.storage_path) .execute(&mut *tx) .await - .map_err(|err| AppError::new(ErrorCode::Internal, "创建文件记录失败").with_source(err))?; + .map_err(|err| { + AppError::new(ErrorCode::Internal, "创建文件记录失败").with_source(err) + })?; } tx.commit() @@ -293,11 +312,12 @@ async fn create_batch_task( .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; + 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); } @@ -325,7 +345,13 @@ async fn create_batch_task( .await; } } - Ok((jar, Json(Envelope { success: true, data: resp }))) + Ok(( + jar, + Json(Envelope { + success: true, + data: resp, + }), + )) } Err(err) => { if let (Some(scope), Some(idem_key)) = (idempotency_scope, idempotency_key.as_deref()) { @@ -379,9 +405,9 @@ async fn parse_batch_request( }; 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))?; + 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| { @@ -434,16 +460,15 @@ async fn parse_batch_request( Ok(v) => v, Err(err) => { cleanup_file_paths(&files).await; - return Err( - AppError::new(ErrorCode::StorageUnavailable, "写入文件失败").with_source(err), - ); + 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), + AppError::new(ErrorCode::StorageUnavailable, "写入文件失败").with_source(err) ); } @@ -464,7 +489,7 @@ async fn parse_batch_request( Err(err) => { cleanup_file_paths(&files).await; return Err( - AppError::new(ErrorCode::InvalidRequest, "读取字段失败").with_source(err), + AppError::new(ErrorCode::InvalidRequest, "读取字段失败").with_source(err) ); } }; @@ -509,7 +534,10 @@ async fn parse_batch_request( Ok(n) => n, Err(_) => { cleanup_file_paths(&files).await; - return Err(AppError::new(ErrorCode::InvalidRequest, "max_width 格式错误")); + return Err(AppError::new( + ErrorCode::InvalidRequest, + "max_width 格式错误", + )); } }); } @@ -521,7 +549,10 @@ async fn parse_batch_request( Ok(n) => n, Err(_) => { cleanup_file_paths(&files).await; - return Err(AppError::new(ErrorCode::InvalidRequest, "max_height 格式错误")); + return Err(AppError::new( + ErrorCode::InvalidRequest, + "max_height 格式错误", + )); } }); } @@ -571,7 +602,10 @@ async fn parse_batch_request( Ok((files, opts, request_hash)) } -fn enforce_batch_limits_anonymous(state: &AppState, files: &[BatchFileInput]) -> Result<(), AppError> { +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( @@ -585,7 +619,10 @@ fn enforce_batch_limits_anonymous(state: &AppState, files: &[BatchFileInput]) -> if f.original_size > max_bytes { return Err(AppError::new( ErrorCode::FileTooLarge, - format!("匿名试用单文件最大 {} MB", state.config.anon_max_file_size_mb), + format!( + "匿名试用单文件最大 {} MB", + state.config.anon_max_file_size_mb + ), )); } } @@ -679,7 +716,10 @@ async fn anonymous_remaining_units( .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))) + Ok(std::cmp::min( + limit - v1.unwrap_or(0), + limit - v2.unwrap_or(0), + )) } fn utc8_date() -> String { @@ -689,7 +729,6 @@ fn utc8_date() -> String { #[derive(Debug, FromRow)] struct TaskRow { - id: Uuid, status: String, total_files: i32, completed_files: i32, @@ -743,14 +782,19 @@ async fn get_task( ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Path(task_id): Path, -) -> Result<(axum_extra::extract::cookie::CookieJar, Json>), AppError> { +) -> 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, @@ -774,7 +818,11 @@ async fn get_task( return Err(AppError::new(ErrorCode::NotFound, "任务已过期或不存在")); } - authorize_task(&principal, task.user_id, task.session_id.as_deref().unwrap_or(""))?; + authorize_task( + &principal, + task.user_id, + task.session_id.as_deref().unwrap_or(""), + )?; let files = sqlx::query_as::<_, TaskFileRow>( r#" @@ -848,12 +896,18 @@ async fn cancel_task( ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Path(task_id): Path, -) -> Result<(axum_extra::extract::cookie::CookieJar, Json>), AppError> { +) -> 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", + "SELECT 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) @@ -861,7 +915,11 @@ async fn cancel_task( .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(""))?; + authorize_task( + &principal, + task.user_id, + task.session_id.as_deref().unwrap_or(""), + )?; if matches!(task.status.as_str(), "completed" | "failed" | "cancelled") { return Ok(( @@ -900,12 +958,18 @@ async fn delete_task( ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Path(task_id): Path, -) -> Result<(axum_extra::extract::cookie::CookieJar, Json>), AppError> { +) -> 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", + "SELECT 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) @@ -913,7 +977,11 @@ async fn delete_task( .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(""))?; + authorize_task( + &principal, + task.user_id, + task.session_id.as_deref().unwrap_or(""), + )?; if task.status == "processing" { return Err(AppError::new( @@ -933,7 +1001,7 @@ async fn delete_task( let _ = tokio::fs::remove_file(p).await; } - if state.config.storage_type.to_ascii_lowercase() == "local" { + if state.config.storage_type.eq_ignore_ascii_case("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); diff --git a/src/api/user.rs b/src/api/user.rs index 27e48f8..72a13c3 100644 --- a/src/api/user.rs +++ b/src/api/user.rs @@ -205,7 +205,11 @@ async fn update_profile( .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 email_verified_at = if email_changed { + None + } else { + user.email_verified_at + }; let updated = sqlx::query_as::<_, UserRow>( r#" @@ -243,7 +247,9 @@ async fn update_profile( .bind(expires_at) .execute(&mut *tx) .await - .map_err(|err| AppError::new(ErrorCode::Internal, "创建邮箱验证记录失败").with_source(err))?; + .map_err(|err| { + AppError::new(ErrorCode::Internal, "创建邮箱验证记录失败").with_source(err) + })?; verification_link = Some(format!( "{}/verify-email?token={}", @@ -258,7 +264,9 @@ async fn update_profile( 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))?; + .map_err(|err| { + AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err) + })?; } let message = if email_changed { @@ -396,17 +404,18 @@ async fn list_history( 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 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))? + 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) @@ -530,7 +539,10 @@ async fn list_history( 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 { + download_url: if file.status == "completed" + && file.storage_path.is_some() + && task.expires_at > now + { Some(format!("/downloads/{}", file.id)) } else { None @@ -667,7 +679,10 @@ async fn create_api_key( let billing = billing::get_user_billing(&state, user_id).await?; if !billing.plan.feature_api_enabled { - return Err(AppError::new(ErrorCode::Forbidden, "当前套餐未开通 API Key")); + return Err(AppError::new( + ErrorCode::Forbidden, + "当前套餐未开通 API Key", + )); } let permissions = normalize_permissions(req.permissions)?; @@ -718,12 +733,15 @@ async fn disable_api_key( _ => 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))?; + 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 不存在")); @@ -813,7 +831,13 @@ fn generate_api_key() -> (String, String) { } fn normalize_permissions(input: Option>) -> Result { - let allowed = ["compress", "batch_compress", "read_stats", "billing_read", "webhook_manage"]; + let allowed = [ + "compress", + "batch_compress", + "read_stats", + "billing_read", + "webhook_manage", + ]; let mut perms = Vec::::new(); if let Some(values) = input { diff --git a/src/api/webhooks.rs b/src/api/webhooks.rs index bb123bb..3f5980d 100644 --- a/src/api/webhooks.rs +++ b/src/api/webhooks.rs @@ -48,8 +48,9 @@ async fn stripe_webhook( 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 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#" @@ -112,21 +113,31 @@ fn verify_stripe_signature(payload: &[u8], sig_header: &str, secret: &str) -> Re } let Some(ts) = timestamp else { - return Err(AppError::new(ErrorCode::InvalidRequest, "Stripe-Signature 缺少 t")); + return Err(AppError::new( + ErrorCode::InvalidRequest, + "Stripe-Signature 缺少 t", + )); }; if signatures.is_empty() { - return Err(AppError::new(ErrorCode::InvalidRequest, "Stripe-Signature 缺少 v1")); + 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 时间戳过期")); + 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))?; + 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); @@ -159,7 +170,9 @@ async fn process_stripe_event(state: &AppState, event: &StripeEvent) -> Result<( 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, + "invoice.paid" | "invoice.payment_failed" => { + upsert_invoice(state, &event.data.object).await + } _ => Ok(()), } } @@ -206,7 +219,9 @@ async fn map_checkout_session_completed( .bind(customer_id) .execute(&state.db) .await - .map_err(|err| AppError::new(ErrorCode::Internal, "更新 Stripe Customer 映射失败").with_source(err))?; + .map_err(|err| { + AppError::new(ErrorCode::Internal, "更新 Stripe Customer 映射失败").with_source(err) + })?; if updated.rows_affected() == 0 { let existing: Option = sqlx::query_scalar::<_, Option>( @@ -270,7 +285,11 @@ async fn upsert_subscription(state: &AppState, object: &serde_json::Value) -> Re 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())) + .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 = @@ -401,7 +420,10 @@ async fn upsert_invoice(state: &AppState, object: &serde_json::Value) -> Result< return Ok(()); }; - let stripe_status = object.get("status").and_then(|v| v.as_str()).unwrap_or("open"); + 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 @@ -418,11 +440,23 @@ async fn upsert_invoice(state: &AppState, object: &serde_json::Value) -> Result< .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 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 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") diff --git a/src/auth.rs b/src/auth.rs index d76480c..2d47ff7 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -42,7 +42,10 @@ pub fn require_jwt(jwt_secret: &str, headers: &HeaderMap) -> Result") + AppError::new( + ErrorCode::Unauthorized, + "缺少 Authorization: Bearer ", + ) })?; decode_jwt(jwt_secret, token) @@ -57,4 +60,3 @@ pub fn decode_jwt(jwt_secret: &str, token: &str) -> Result { .map(|data| data.claims) .map_err(|_| AppError::new(ErrorCode::Unauthorized, "Token 无效或已过期")) } - diff --git a/src/config.rs b/src/config.rs index 59d73ea..0c6ee9e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,7 @@ use crate::error::{AppError, ErrorCode}; +static TRUST_PROXY_HEADERS: std::sync::OnceLock = std::sync::OnceLock::new(); + #[derive(Debug, Clone)] pub struct Config { pub role: String, @@ -13,19 +15,18 @@ pub struct Config { pub redis_url: String, pub worker_concurrency: u32, + pub image_processing_concurrency: u32, 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, @@ -67,22 +68,41 @@ impl Config { .map(|v| v.get() as u32) .unwrap_or(4) }); + let image_processing_concurrency = env_u32("IMAGE_PROCESSING_CONCURRENCY") + .filter(|value| *value > 0) + .unwrap_or_else(|| { + std::thread::available_parallelism() + .map(|v| v.get() as u32) + .unwrap_or(4) + }); 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 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()); + if !billing_provider.eq_ignore_ascii_case("stripe") { + return Err(AppError::new( + ErrorCode::InvalidRequest, + "BILLING_PROVIDER 目前仅支持 stripe", + )); + } 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()); + if !storage_type.eq_ignore_ascii_case("local") { + return Err(AppError::new( + ErrorCode::InvalidRequest, + "STORAGE_TYPE 目前仅支持 local", + )); + } 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); @@ -94,11 +114,14 @@ impl Config { 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_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_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_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"); @@ -112,15 +135,14 @@ impl Config { database_max_connections, redis_url, worker_concurrency, + image_processing_concurrency, 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, @@ -142,7 +164,9 @@ impl Config { } fn env_string(key: &str) -> Option { - std::env::var(key).ok().filter(|value| !value.trim().is_empty()) + std::env::var(key) + .ok() + .filter(|value| !value.trim().is_empty()) } fn env_u16(key: &str) -> Option { @@ -168,3 +192,7 @@ fn env_bool(key: &str) -> Option { _ => None, }) } + +pub fn trust_proxy_headers() -> bool { + *TRUST_PROXY_HEADERS.get_or_init(|| env_bool("TRUST_PROXY_HEADERS").unwrap_or(false)) +} diff --git a/src/main.rs b/src/main.rs index 6f5ea87..92182ef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,6 @@ use crate::services::mail::Mailer; use crate::state::AppState; use sqlx::postgres::PgPoolOptions; -use tracing::Level; #[tokio::main] async fn main() -> Result<(), AppError> { @@ -34,11 +33,16 @@ async fn main() -> Result<(), AppError> { .await .map_err(|err| AppError::new(ErrorCode::Internal, "Redis 连接失败").with_source(err))?; + let image_processing_semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new( + config.image_processing_concurrency as usize, + )); + let state = AppState { config, db, redis, mailer: std::sync::Arc::new(mailer), + image_processing_semaphore, }; match state.config.role.as_str() { @@ -52,13 +56,9 @@ async fn main() -> Result<(), AppError> { } 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") - }); + 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(); + tracing_subscriber::fmt().with_env_filter(env_filter).init(); } diff --git a/src/services/billing.rs b/src/services/billing.rs index 9005501..ac6d8de 100644 --- a/src/services/billing.rs +++ b/src/services/billing.rs @@ -7,8 +7,6 @@ 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, @@ -36,8 +34,6 @@ struct SubscriptionRow { #[derive(Debug, FromRow)] struct PlanRow { - id: Uuid, - code: String, included_units_per_period: i32, max_file_size_mb: i32, max_files_per_batch: i32, @@ -68,19 +64,26 @@ pub async fn get_user_billing(state: &AppState, user_id: Uuid) -> Result( r#" - SELECT id, code, included_units_per_period, max_file_size_mb, max_files_per_batch, retention_days, features + SELECT included_units_per_period, max_file_size_mb, max_files_per_batch, retention_days, features FROM plans WHERE id = $1 "#, @@ -100,8 +103,6 @@ pub async fn get_user_billing(state: &AppState, user_id: Uuid) -> Result) -> (DateTime, Date let year = now.year(); let month = now.month(); - let start = tz.with_ymd_and_hms(year, month, 1, 0, 0, 0).single().unwrap(); + 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) diff --git a/src/services/bootstrap.rs b/src/services/bootstrap.rs index a9fc2dc..eff0cb7 100644 --- a/src/services/bootstrap.rs +++ b/src/services/bootstrap.rs @@ -7,6 +7,8 @@ use sqlx::FromRow; use tracing::{info, warn}; use uuid::Uuid; +static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); + #[derive(Debug, FromRow)] struct AdminRow { id: Uuid, @@ -28,13 +30,8 @@ pub async fn ensure_admin_user(state: &AppState) -> Result<(), AppError> { return Ok(()); } - let admin_username = env_string("ADMIN_USERNAME").unwrap_or_else(|| { - admin_email - .split('@') - .next() - .unwrap_or("admin") - .to_string() - }); + 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)?; @@ -85,7 +82,9 @@ pub async fn ensure_admin_user(state: &AppState) -> Result<(), AppError> { .bind(row.id) .fetch_one(&state.db) .await - .map_err(|err| AppError::new(ErrorCode::Internal, "校验管理员用户名失败").with_source(err))?; + .map_err(|err| { + AppError::new(ErrorCode::Internal, "校验管理员用户名失败").with_source(err) + })?; if name_taken { warn!( @@ -105,7 +104,9 @@ pub async fn ensure_admin_user(state: &AppState) -> Result<(), AppError> { .bind(row.id) .execute(&state.db) .await - .map_err(|err| AppError::new(ErrorCode::Internal, "更新管理员用户名失败").with_source(err))?; + .map_err(|err| { + AppError::new(ErrorCode::Internal, "更新管理员用户名失败").with_source(err) + })?; } } @@ -138,32 +139,20 @@ pub async fn ensure_admin_user(state: &AppState) -> Result<(), AppError> { } 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; + MIGRATOR + .run(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "执行数据库迁移失败").with_source(err))?; Ok(()) } fn validate_email(email: &str) -> Result<(), AppError> { if email.trim().is_empty() || !email.contains('@') { - return Err(AppError::new(ErrorCode::InvalidRequest, "管理员邮箱格式不正确")); + return Err(AppError::new( + ErrorCode::InvalidRequest, + "管理员邮箱格式不正确", + )); } if email.len() > 255 { return Err(AppError::new(ErrorCode::InvalidRequest, "管理员邮箱过长")); @@ -173,7 +162,10 @@ fn validate_email(email: &str) -> Result<(), AppError> { fn validate_username(username: &str) -> Result<(), AppError> { if username.trim().is_empty() { - return Err(AppError::new(ErrorCode::InvalidRequest, "管理员用户名不能为空")); + return Err(AppError::new( + ErrorCode::InvalidRequest, + "管理员用户名不能为空", + )); } if username.len() > 50 { return Err(AppError::new(ErrorCode::InvalidRequest, "管理员用户名过长")); @@ -183,7 +175,10 @@ fn validate_username(username: &str) -> Result<(), AppError> { fn validate_password(password: &str) -> Result<(), AppError> { if password.len() < 8 { - return Err(AppError::new(ErrorCode::InvalidRequest, "管理员密码至少 8 位")); + return Err(AppError::new( + ErrorCode::InvalidRequest, + "管理员密码至少 8 位", + )); } if password.len() > 128 { return Err(AppError::new(ErrorCode::InvalidRequest, "管理员密码过长")); @@ -200,5 +195,7 @@ fn hash_password(password: &str) -> Result { } fn env_string(key: &str) -> Option { - std::env::var(key).ok().filter(|value| !value.trim().is_empty()) + std::env::var(key) + .ok() + .filter(|value| !value.trim().is_empty()) } diff --git a/src/services/compress.rs b/src/services/compress.rs index d1670aa..7408d9e 100644 --- a/src/services/compress.rs +++ b/src/services/compress.rs @@ -1,15 +1,15 @@ 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 image::{DynamicImage, ExtendedColorType, ImageEncoder}; +use img_parts::{Bytes as ImgBytes, DynImage, ImageEXIF, ImageICC}; use oxipng::StripChunks; use rgb::FromSlice; use std::io::Cursor; @@ -105,15 +105,12 @@ pub fn parse_level(value: &str) -> Result { } pub fn parse_compression_rate(value: &str) -> Result { - let rate: u8 = value - .trim() - .parse() - .map_err(|_| { - AppError::new( - ErrorCode::InvalidRequest, - "compression_rate 需为 1-100 的整数(压缩后体积占比)", - ) - })?; + 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, @@ -199,8 +196,7 @@ pub fn detect_format(bytes: &[u8]) -> Result { if has_brand( &brands, &[ - *b"heic", *b"heix", *b"hevc", *b"hevx", *b"heis", *b"heim", *b"mif1", - *b"msf1", + *b"heic", *b"heix", *b"hevc", *b"hevx", *b"heis", *b"heim", *b"mif1", *b"msf1", ], ) { return Err(AppError::new( @@ -233,27 +229,69 @@ pub fn detect_format(bytes: &[u8]) -> Result { )) } - +#[allow(clippy::too_many_arguments)] pub async fn compress_image_bytes( state: &AppState, - input: &[u8], + input: Vec, format_in: ImageFmt, format_out: ImageFmt, level: CompressionLevel, compression_rate: Option, - target_size_bytes: Option, // 新增:直接指定目标大小(字节) + target_size_bytes: Option, // 新增:直接指定目标大小(字节) max_width: Option, max_height: Option, preserve_metadata: bool, +) -> Result, AppError> { + let max_image_pixels = state.config.max_image_pixels; + let permit = state + .image_processing_semaphore + .clone() + .acquire_owned() + .await + .map_err(|err| { + AppError::new(ErrorCode::Internal, "图片处理并发控制器已关闭").with_source(err) + })?; + + tokio::task::spawn_blocking(move || { + let _permit = permit; + compress_image_bytes_sync( + input, + format_in, + format_out, + level, + compression_rate, + target_size_bytes, + max_width, + max_height, + preserve_metadata, + max_image_pixels, + ) + }) + .await + .map_err(|err| { + AppError::new(ErrorCode::CompressionFailed, "图片处理任务异常退出").with_source(err) + })? +} + +#[allow(clippy::too_many_arguments)] +fn compress_image_bytes_sync( + input: Vec, + format_in: ImageFmt, + format_out: ImageFmt, + level: CompressionLevel, + compression_rate: Option, + target_size_bytes: Option, + max_width: Option, + max_height: Option, + preserve_metadata: bool, + max_image_pixels: u64, ) -> Result, AppError> { let original_size = input.len() as u64; - if format_in == ImageFmt::Gif { - if is_animated_gif(input)? { - return Err(AppError::new( - ErrorCode::UnsupportedFormat, - "暂不支持动图 GIF", - )); - } + if format_in == ImageFmt::Gif && is_animated_gif(&input)? { + return Err(AppError::new( + ErrorCode::UnsupportedFormat, + "暂不支持动图 GIF", + )); } let retention_rate = effective_rate(compression_rate, level); @@ -269,14 +307,14 @@ pub async fn compress_image_bytes( && max_height.is_none() { if preserve_metadata { - return Ok(input.to_vec()); + return Ok(input); } - let stripped = strip_metadata(input).unwrap_or_else(|_| input.to_vec()); + let stripped = strip_metadata(&input).unwrap_or_else(|_| input.clone()); return Ok(stripped); } let (icc_profile, exif) = if preserve_metadata { - extract_metadata(input) + extract_metadata(&input) } else { (None, None) }; @@ -294,13 +332,23 @@ pub async fn compress_image_bytes( if !preserve_metadata { opts.strip = StripChunks::Safe; } - oxipng::optimize_from_memory(input, &opts) - .map_err(|err| AppError::new(ErrorCode::CompressionFailed, "PNG 压缩失败").with_source(err))? + 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))?; + #[cfg(not(target_os = "linux"))] + if format_in == ImageFmt::Avif { + return Err(AppError::new( + ErrorCode::UnsupportedFormat, + "当前平台构建不支持 AVIF 解码,请转换为 PNG/JPEG/WebP 后重试", + )); + } - enforce_pixel_limit(state, &image)?; + let image = image::load_from_memory(&input).map_err(|err| { + AppError::new(ErrorCode::InvalidImage, "图片解码失败").with_source(err) + })?; + + enforce_pixel_limit(max_image_pixels, &image)?; let (image, did_resize) = resize_if_needed(image, max_width, max_height); resized = did_resize; @@ -332,23 +380,23 @@ pub async fn compress_image_bytes( if !resized && output.len() >= input.len() { if preserve_metadata { - return Ok(input.to_vec()); + return Ok(input); } - let stripped = strip_metadata(input).unwrap_or_else(|_| input.to_vec()); + let stripped = strip_metadata(&input).unwrap_or_else(|_| input.clone()); return Ok(if stripped.len() <= input.len() { stripped } else { - input.to_vec() + input }); } Ok(output) } -fn enforce_pixel_limit(state: &AppState, image: &DynamicImage) -> Result<(), AppError> { +fn enforce_pixel_limit(max_image_pixels: u64, 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 { + if pixels > max_image_pixels { return Err(AppError::new( ErrorCode::TooManyPixels, format!("图片像素过大({}x{})", w, h), @@ -394,11 +442,7 @@ fn fit_within(w: u32, h: u32, max_width: Option, max_height: Option) - (nw, nh) } -fn encode_png( - image: DynamicImage, - rate: u8, - preserve_metadata: bool, -) -> Result, AppError> { +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(); @@ -406,7 +450,9 @@ fn encode_png( 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))?; + .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); @@ -433,7 +479,9 @@ fn encode_jpeg_raw(raw: &[u8], w: u32, h: u32, quality: u8) -> Result, A let mut encoder = JpegEncoder::new_with_quality(&mut out, quality); encoder .encode(raw, w, h, ExtendedColorType::Rgb8) - .map_err(|err| AppError::new(ErrorCode::CompressionFailed, "JPEG 编码失败").with_source(err))?; + .map_err(|err| { + AppError::new(ErrorCode::CompressionFailed, "JPEG 编码失败").with_source(err) + })?; Ok(out) } @@ -451,12 +499,6 @@ fn encode_webp(image: DynamicImage, rate: u8) -> Result, AppError> { Ok(bytes.to_vec()) } -fn encode_webp_with_quality(image: DynamicImage, quality: u8) -> Result, AppError> { - let rgba = image.to_rgba8(); - let (w, h) = rgba.dimensions(); - encode_webp_raw(rgba.as_raw(), w, h, quality) -} - fn encode_webp_raw(raw: &[u8], w: u32, h: u32, quality: u8) -> Result, AppError> { let encoder = webp::Encoder::from_rgba(raw, w, h); Ok(encoder.encode(quality as f32).to_vec()) @@ -473,26 +515,20 @@ fn encode_avif(image: DynamicImage, rate: u8) -> Result, AppError> { 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))?; + let encoded = encoder.encode_rgba(img).map_err(|err| { + AppError::new(ErrorCode::CompressionFailed, "AVIF 编码失败").with_source(err) + })?; Ok(encoded.avif_file) } -fn encode_avif_with_quality(image: DynamicImage, quality: u8) -> Result, AppError> { - let rgba = image.to_rgba8(); - let (w, h) = rgba.dimensions(); - encode_avif_raw(rgba.as_raw(), w, h, quality) -} - fn encode_avif_raw(raw: &[u8], w: u32, h: u32, quality: u8) -> Result, AppError> { 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 as f32); - let encoded = encoder - .encode_rgba(img) - .map_err(|err| AppError::new(ErrorCode::CompressionFailed, "AVIF 编码失败").with_source(err))?; + let encoded = encoder.encode_rgba(img).map_err(|err| { + AppError::new(ErrorCode::CompressionFailed, "AVIF 编码失败").with_source(err) + })?; Ok(encoded.avif_file) } @@ -579,7 +615,8 @@ where Some((_bytes, best_w, best_h, best_size)) => { let new_pixels = (new_w as u64).saturating_mul(new_h as u64); let best_pixels = (*best_w as u64).saturating_mul(*best_h as u64); - new_pixels > best_pixels || (new_pixels == best_pixels && result_size > *best_size) + new_pixels > best_pixels + || (new_pixels == best_pixels && result_size > *best_size) } }; @@ -587,9 +624,7 @@ where best_under = Some((result, new_w, new_h, result_size)); } - if new_w == orig_w - && new_h == orig_h - && target_size.saturating_sub(result_size) <= 1024 + if new_w == orig_w && new_h == orig_h && target_size.saturating_sub(result_size) <= 1024 { break; } @@ -645,11 +680,7 @@ where let mut consider = |bytes: Vec| { let size = bytes.len() as u64; let is_under = size <= target_size; - let diff = if size > target_size { - size - target_size - } else { - target_size - size - }; + let diff = size.abs_diff(target_size); let should_update = match (best_is_under, is_under) { (false, true) => true, @@ -689,87 +720,6 @@ where best.ok_or_else(|| AppError::new(ErrorCode::CompressionFailed, "压缩失败")) } -fn encode_target_quality( - min_q: u8, - max_q: u8, - target_size: u64, - mut encode: F, -) -> Result, AppError> -where - F: FnMut(u8) -> Result, AppError>, -{ - let mut best: Option> = None; - let mut best_diff = u64::MAX; - let mut best_is_under = false; // 记录最佳结果是否小于目标 - let mut best_size = 0u64; - - // 考虑一个候选结果 - let consider = |bytes: Vec, best: &mut Option>, best_diff: &mut u64, best_is_under: &mut bool, best_size: &mut u64| { - let size = bytes.len() as u64; - let is_under = size <= target_size; - let diff = if size > target_size { - size - target_size - } else { - target_size - size - }; - - // 优先选择不超过目标大小的结果 - let should_update = match (*best_is_under, is_under) { - (false, true) => true, // 当前小于目标,之前大于目标 -> 更新 - (true, false) => false, // 当前大于目标,之前小于目标 -> 不更新 - _ => diff < *best_diff, // 同类情况,选择更接近的 - }; - - if should_update { - *best_diff = diff; - *best_is_under = is_under; - *best_size = size; - *best = Some(bytes); - } - }; - - // 先尝试两端 - let bytes = encode(min_q)?; - consider(bytes, &mut best, &mut best_diff, &mut best_is_under, &mut best_size); - if min_q != max_q { - let bytes = encode(max_q)?; - consider(bytes, &mut best, &mut best_diff, &mut best_is_under, &mut best_size); - } - - // 二分查找,增加迭代次数到 12 次以提高精度 - let mut low = min_q; - let mut high = max_q; - for _ in 0..12 { - if low > high { - break; - } - let mid = (low + high) / 2; - let bytes = encode(mid)?; - let size = bytes.len() as u64; - consider(bytes, &mut best, &mut best_diff, &mut best_is_under, &mut best_size); - if size > target_size { - high = mid.saturating_sub(1); - } else { - low = mid.saturating_add(1); - } - } - - // 精细调整:如果当前结果超出目标太多,尝试更低质量 - if best_size > target_size { - let mut q = min_q; - while q <= min_q.saturating_add(5) && q <= max_q { - let bytes = encode(q)?; - consider(bytes, &mut best, &mut best_diff, &mut best_is_under, &mut best_size); - if best_size <= target_size { - break; // 已找到满足条件的结果 - } - q = q.saturating_add(1); - } - } - - best.ok_or_else(|| AppError::new(ErrorCode::CompressionFailed, "压缩失败")) -} - fn encode_gif(image: DynamicImage, rate: u8) -> Result, AppError> { let rgba = image.to_rgba8(); let (w, h) = rgba.dimensions(); @@ -780,7 +730,9 @@ fn encode_gif(image: DynamicImage, rate: u8) -> Result, AppError> { 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))?; + .map_err(|err| { + AppError::new(ErrorCode::CompressionFailed, "GIF 编码失败").with_source(err) + })?; } Ok(out) @@ -793,7 +745,9 @@ fn encode_bmp(image: DynamicImage) -> Result, AppError> { 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))?; + .map_err(|err| { + AppError::new(ErrorCode::CompressionFailed, "BMP 编码失败").with_source(err) + })?; Ok(out) } @@ -804,7 +758,9 @@ fn encode_tiff(image: DynamicImage) -> Result, AppError> { 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))?; + .map_err(|err| { + AppError::new(ErrorCode::CompressionFailed, "TIFF 编码失败").with_source(err) + })?; Ok(out.into_inner()) } @@ -815,7 +771,9 @@ fn encode_ico(image: DynamicImage) -> Result, AppError> { 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))?; + .map_err(|err| { + AppError::new(ErrorCode::CompressionFailed, "ICO 编码失败").with_source(err) + })?; Ok(out) } @@ -837,8 +795,9 @@ fn apply_metadata( } 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 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()); @@ -848,16 +807,17 @@ fn apply_metadata( 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))?; + 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 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()); }; @@ -866,9 +826,9 @@ fn strip_metadata(input: &[u8]) -> Result, AppError> { 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))?; + img.encoder().write_to(&mut buf).map_err(|err| { + AppError::new(ErrorCode::CompressionFailed, "写入图片元数据失败").with_source(err) + })?; Ok(buf) } @@ -927,13 +887,58 @@ fn strength_from_rate(rate: u8) -> u8 { 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(); + let mut frames = decoder.into_frames(); if let Some(frame) = frames.next() { - frame.map_err(|err| AppError::new(ErrorCode::InvalidImage, "GIF 解码失败").with_source(err))?; + 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))?; + frame.map_err(|err| { + AppError::new(ErrorCode::InvalidImage, "GIF 解码失败").with_source(err) + })?; return Ok(true); } Ok(false) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_supported_formats_from_signatures() { + assert_eq!(detect_format(b"\x89PNG\r\n\x1a\n").unwrap(), ImageFmt::Png); + assert_eq!(detect_format(b"\xff\xd8").unwrap(), ImageFmt::Jpeg); + assert_eq!( + detect_format(b"RIFF\x00\x00\x00\x00WEBP").unwrap(), + ImageFmt::Webp + ); + assert_eq!(detect_format(b"GIF89a").unwrap(), ImageFmt::Gif); + assert_eq!(detect_format(b"BM").unwrap(), ImageFmt::Bmp); + } + + #[test] + fn detects_avif_and_rejects_heic() { + let avif = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00avif"; + assert_eq!(detect_format(avif).unwrap(), ImageFmt::Avif); + + let heic = b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00mif1"; + let error = detect_format(heic).unwrap_err(); + assert_eq!(error.code, ErrorCode::UnsupportedFormat); + } + + #[test] + fn fit_within_preserves_aspect_ratio_and_never_upscales() { + assert_eq!(fit_within(4000, 2000, Some(1000), None), (1000, 500)); + assert_eq!(fit_within(4000, 2000, None, Some(250)), (500, 250)); + assert_eq!(fit_within(400, 200, Some(800), Some(800)), (400, 200)); + } + + #[test] + fn compression_rate_maps_to_expected_target_size() { + assert_eq!(target_size_from_rate(10_000, 1), 100); + assert_eq!(target_size_from_rate(10_000, 55), 5_500); + assert_eq!(target_size_from_rate(10_000, 100), 10_000); + } +} diff --git a/src/services/idempotency.rs b/src/services/idempotency.rs index 8734ede..6c6babb 100644 --- a/src/services/idempotency.rs +++ b/src/services/idempotency.rs @@ -15,8 +15,8 @@ pub enum Scope { #[derive(Debug)] pub enum BeginResult { - Acquired { expires_at: DateTime }, - Replay { response_status: i32, response_body: JsonValue }, + Acquired, + Replay { response_body: JsonValue }, InProgress, } @@ -25,7 +25,6 @@ struct IdemRow { request_hash: String, response_status: i32, response_body: Option, - expires_at: DateTime, } pub fn sha256_hex(parts: &[&[u8]]) -> String { @@ -45,13 +44,22 @@ pub async fn begin( ttl_hours: i64, ) -> Result { if idempotency_key.trim().is_empty() { - return Err(AppError::new(ErrorCode::InvalidRequest, "Idempotency-Key 不能为空")); + return Err(AppError::new( + ErrorCode::InvalidRequest, + "Idempotency-Key 不能为空", + )); } if idempotency_key.len() > 128 { - return Err(AppError::new(ErrorCode::InvalidRequest, "Idempotency-Key 过长")); + return Err(AppError::new( + ErrorCode::InvalidRequest, + "Idempotency-Key 过长", + )); } if request_hash.len() != 64 { - return Err(AppError::new(ErrorCode::InvalidRequest, "request_hash 不合法")); + return Err(AppError::new( + ErrorCode::InvalidRequest, + "request_hash 不合法", + )); } let now = Utc::now(); @@ -108,12 +116,12 @@ pub async fn begin( .map_err(|err| AppError::new(ErrorCode::Internal, "写入幂等记录失败").with_source(err))?; if inserted.rows_affected() > 0 { - return Ok(BeginResult::Acquired { expires_at }); + return Ok(BeginResult::Acquired); } let row = get_row(state, scope, idempotency_key, now).await?; let Some(row) = row else { - return Ok(BeginResult::Acquired { expires_at }); + return Ok(BeginResult::Acquired); }; if row.request_hash != request_hash { @@ -128,7 +136,6 @@ pub async fn begin( } Ok(BeginResult::Replay { - response_status: row.response_status, response_body: row.response_body.unwrap_or(JsonValue::Null), }) } @@ -300,7 +307,7 @@ async fn get_row( Scope::User(user_id) => { sqlx::query_as::<_, IdemRow>( r#" - SELECT request_hash, response_status, response_body, expires_at + SELECT request_hash, response_status, response_body FROM idempotency_keys WHERE user_id = $1 AND idempotency_key = $2 @@ -318,7 +325,7 @@ async fn get_row( Scope::ApiKey(api_key_id) => { sqlx::query_as::<_, IdemRow>( r#" - SELECT request_hash, response_status, response_body, expires_at + SELECT request_hash, response_status, response_body FROM idempotency_keys WHERE api_key_id = $1 AND idempotency_key = $2 @@ -338,4 +345,3 @@ async fn get_row( Ok(row) } - diff --git a/src/services/mail.rs b/src/services/mail.rs index 8b3fbc4..82ca23a 100644 --- a/src/services/mail.rs +++ b/src/services/mail.rs @@ -76,10 +76,9 @@ impl Mailer { 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))?, - ) + 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) { @@ -203,8 +202,11 @@ impl Mailer { 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) + 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) @@ -212,12 +214,16 @@ impl Mailer { .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())), + .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))?; @@ -250,11 +256,16 @@ impl SmtpConfig { 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 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(); @@ -320,7 +331,9 @@ pub async fn send_password_reset_email( reset_url: &str, ) -> Result<(), AppError> { let mailer = resolve_mailer(state).await?; - mailer.send_password_reset_email(to, username, reset_url).await + mailer + .send_password_reset_email(to, username, reset_url) + .await } pub async fn send_test_email(state: &AppState, to: &str) -> Result<(), AppError> { diff --git a/src/services/mod.rs b/src/services/mod.rs index d90051c..aafdc10 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,7 +1,7 @@ -pub mod mail; pub mod billing; -pub mod quota; +pub mod bootstrap; pub mod compress; pub mod idempotency; +pub mod mail; +pub mod quota; pub mod settings; -pub mod bootstrap; diff --git a/src/services/settings.rs b/src/services/settings.rs index 80da314..948153a 100644 --- a/src/services/settings.rs +++ b/src/services/settings.rs @@ -6,8 +6,8 @@ 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 serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -39,7 +39,6 @@ pub struct StripeConfigStored { pub struct StripeSecrets { pub secret_key: String, pub webhook_secret: Option, - pub secret_key_prefix: Option, } pub async fn load_system_config( @@ -51,7 +50,9 @@ pub async fn load_system_config( .bind(key) .fetch_optional(&state.db) .await - .map_err(|err| AppError::new(ErrorCode::Internal, "查询系统配置失败").with_source(err))?; + .map_err(|err| { + AppError::new(ErrorCode::Internal, "查询系统配置失败").with_source(err) + })?; let Some(value) = value else { return Ok(None); @@ -138,7 +139,6 @@ pub async fn load_stripe_secrets(state: &AppState) -> Result, + pub image_processing_semaphore: std::sync::Arc, } diff --git a/src/worker/mod.rs b/src/worker/mod.rs index e83ba06..b0119be 100644 --- a/src/worker/mod.rs +++ b/src/worker/mod.rs @@ -20,9 +20,7 @@ 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, "数据库结构初始化失败"); - } + crate::services::bootstrap::ensure_schema(&state).await?; let consumer = format!("worker_{}", Uuid::new_v4()); ensure_group(&state, &consumer).await?; @@ -71,15 +69,26 @@ async fn ensure_group(state: &AppState, _consumer: &str) -> Result<(), AppError> async fn poll_once(state: &AppState, consumer: &str) -> Result<(), AppError> { let mut conn = state.redis.clone(); - let opts = StreamReadOptions::default() + // Retry messages already delivered to this consumer before taking new work. + let pending_opts = StreamReadOptions::default() .group(GROUP_NAME, consumer) - .count(1) - .block(5000); - - let reply: redis::streams::StreamReadReply = conn - .xread_options(&[STREAM_KEY], &[">"], &opts) + .count(1); + let mut reply: redis::streams::StreamReadReply = conn + .xread_options(&[STREAM_KEY], &["0"], &pending_opts) .await - .map_err(|err| AppError::new(ErrorCode::Internal, "读取队列失败").with_source(err))?; + .map_err(|err| AppError::new(ErrorCode::Internal, "读取待重试任务失败").with_source(err))?; + + if !reply.keys.iter().any(|key| !key.ids.is_empty()) { + let opts = StreamReadOptions::default() + .group(GROUP_NAME, consumer) + .count(1) + .block(5000); + + reply = conn + .xread_options(&[STREAM_KEY], &[">"], &opts) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "读取队列失败").with_source(err))?; + } if reply.keys.is_empty() { return Ok(()); @@ -100,9 +109,10 @@ async fn poll_once(state: &AppState, consumer: &str) -> Result<(), AppError> { } }; - if let Err(err) = process_task(state, task_id).await { - tracing::error!(task_id = %task_id, error = %err, "task processing failed"); - } + process_task(state, task_id).await.map_err(|err| { + tracing::error!(task_id = %task_id, error = %err, "task processing failed; message left pending for retry"); + err + })?; ack_message(&mut conn, &msg.id).await?; } @@ -111,7 +121,10 @@ async fn poll_once(state: &AppState, consumer: &str) -> Result<(), AppError> { Ok(()) } -async fn ack_message(conn: &mut redis::aio::ConnectionManager, msg_id: &str) -> Result<(), AppError> { +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) @@ -124,16 +137,12 @@ async fn ack_message(conn: &mut redis::aio::ConnectionManager, msg_id: &str) -> #[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, @@ -145,10 +154,8 @@ struct TaskProcRow { struct TaskFileProcRow { id: Uuid, storage_path: Option, - original_name: String, original_format: String, output_format: String, - original_size: i64, status: String, } @@ -166,16 +173,12 @@ 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, @@ -195,6 +198,8 @@ async fn process_task(state: &AppState, task_id: Uuid) -> Result<(), AppError> { return Ok(()); } + let is_retry = task.status == "processing"; + let updated = sqlx::query( r#" UPDATE tasks @@ -215,9 +220,17 @@ async fn process_task(state: &AppState, task_id: Uuid) -> Result<(), AppError> { // 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()); + if is_retry { + sqlx::query( + "UPDATE task_files SET status = 'pending' WHERE task_id = $1 AND status = 'processing'", + ) + .bind(task_id) + .execute(&state.db) + .await + .map_err(|err| AppError::new(ErrorCode::Internal, "恢复待重试文件失败").with_source(err))?; + } + + 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)?); @@ -229,10 +242,8 @@ async fn process_task(state: &AppState, task_id: Uuid) -> Result<(), AppError> { SELECT id, storage_path, - original_name, original_format, output_format, - original_size, status::text AS status FROM task_files WHERE task_id = $1 @@ -281,7 +292,7 @@ async fn process_task(state: &AppState, task_id: Uuid) -> Result<(), AppError> { join_set.spawn(async move { let _permit = permit; - if let Err(err) = process_task_file( + let result = process_task_file( state, task_id, file, @@ -292,19 +303,35 @@ async fn process_task(state: &AppState, task_id: Uuid) -> Result<(), AppError> { ctx, billing_ctx, ) - .await - { + .await; + if let Err(err) = &result { tracing::error!(task_id = %task_id, file_id = %file_id, error = %err, "file processing failed"); } + result }); } + let mut first_error = None; while let Some(result) = join_set.join_next().await { - if let Err(err) = result { - tracing::error!(task_id = %task_id, error = %err, "file worker panicked"); + match result { + Ok(Ok(())) => {} + Ok(Err(err)) => { + if first_error.is_none() { + first_error = Some(err); + } + } + Err(err) => { + return Err( + AppError::new(ErrorCode::Internal, "文件处理线程异常退出").with_source(err) + ); + } } } + if let Some(err) = first_error { + return Err(err); + } + finalize_task_status(state, task_id).await?; Ok(()) } @@ -332,6 +359,7 @@ async fn is_task_cancelled(state: &AppState, task_id: Uuid) -> Result format, + Err(err) => { + mark_file_failed(&state, task_id, file.id, &err.message).await?; + let _ = tokio::fs::remove_file(&input_path).await; + return Ok(()); + } + }; + let format_out = match parse_image_fmt(&file.output_format) { + Ok(format) => format, + Err(err) => { + mark_file_failed(&state, task_id, file.id, &err.message).await?; + let _ = tokio::fs::remove_file(&input_path).await; + return Ok(()); + } + }; + let original_size = input_bytes.len() as u64; let compressed = match compress::compress_image_bytes( &state, - &input_bytes, + input_bytes, format_in, format_out, level, compression_rate, - None, // target_size_bytes: worker 批量任务不支持精确大小 + None, // target_size_bytes: worker 批量任务不支持精确大小 max_width, max_height, ctx.preserve_metadata, @@ -405,7 +450,6 @@ async fn process_task_file( return Ok(()); } - let original_size = input_bytes.len() as u64; let compressed_size = compressed.len() as u64; let saved_percent = if original_size == 0 { 0.0 @@ -442,7 +486,9 @@ async fn process_task_file( 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)); + return Err( + AppError::new(ErrorCode::StorageUnavailable, "写入压缩文件失败").with_source(err), + ); } if is_task_cancelled(&state, task_id).await? { @@ -483,6 +529,7 @@ async fn process_task_file( Ok(()) } +#[allow(clippy::too_many_arguments)] async fn finalize_file( state: &AppState, billing_ctx: &Option, @@ -563,7 +610,12 @@ async fn finalize_file( Ok(()) } -async fn mark_file_failed(state: &AppState, task_id: Uuid, task_file_id: Uuid, message: &str) -> Result<(), AppError> { +async fn mark_file_failed( + state: &AppState, + task_id: Uuid, + task_file_id: Uuid, + message: &str, +) -> Result<(), AppError> { let mut tx = state .db .begin() @@ -608,16 +660,6 @@ async fn mark_file_failed(state: &AppState, task_id: Uuid, task_file_id: Uuid, m 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", @@ -627,7 +669,9 @@ async fn finalize_task_status(state: &AppState, task_id: Uuid) -> Result<(), App .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))?; - let Some((total, completed, failed, status)) = row else { return Ok(()); }; + 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')", @@ -675,6 +719,7 @@ async fn finalize_task_status(state: &AppState, task_id: Uuid) -> Result<(), App Ok(()) } +#[allow(clippy::too_many_arguments)] async fn charge_one_unit( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, billing: &billing::BillingContext, @@ -771,32 +816,36 @@ async fn cleanup_expired_records(state: &AppState) -> Result<(), AppError> { .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 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; + 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(); + 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" { + if state.config.storage_type.eq_ignore_ascii_case("local") { for task_id in &task_ids { let paths: Vec> = sqlx::query_scalar("SELECT storage_path FROM task_files WHERE task_id = $1")