diff --git a/AGENTS.md b/AGENTS.md index d5c1d90..29cecb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,31 +4,55 @@ This file provides guidance to AI agent when working with code in this repositor ## Project Overview -This is an Axum-based RESTful API template with PostgreSQL support, Docker containerization, and GitHub Actions CI/CD. The application uses: +**Janus** is a RESTful API service that provides Bilibili dynamic posting capabilities with JWT authentication and PostgreSQL persistence. The application: +- Posts content (text and images) to Bilibili dynamics via API integration +- Uses ES256 JWT authentication for API security +- Stores data in PostgreSQL with SQLx (compile-time checked queries) +- Auto-generates OpenAPI documentation with Scalar UI +- Supports multipart file uploads for images + +**Tech Stack:** - **Axum 0.8** web framework with Tower middleware -- **PostgreSQL** with SQLx for database operations (compile-time checked queries) -- **Utoipa** for OpenAPI documentation with Scalar UI -- **Sentry** for optional error tracking +- **PostgreSQL** with SQLx for database operations +- **Utoipa** for OpenAPI documentation +- **Reqwest** HTTP client for Bilibili API calls +- **ES256 JWT** (ECDSA with P-256) for authentication - **Tracing** subscriber for structured logging +- **Sentry** for optional error tracking ## Build and Development Commands -### Building and Testing +### Essential Commands ```bash # Build the project cargo build -# Run with config file +# Run server (requires config.toml) cargo run -- server --config config.toml +# Generate JWT token for authentication +cargo run -- generate-jwt --config config.toml --subject user_id + +# Show version and build SHA +cargo run -- version + # Format code cargo fmt -# Run linter +# Run linter (strict mode - warnings as errors) cargo clippy --all-features -- -D warnings +``` -# Show version -cargo run -- version +### Testing +```bash +# Run all tests +cargo test + +# Run specific test +cargo test test_name + +# Run tests with output +cargo test -- --nocapture ``` ### Database Operations @@ -55,67 +79,146 @@ just pre-release ## Architecture ### Configuration System -All configuration is TOML-based and loaded via `config.rs`. The config file path is passed via CLI argument `--config`. Configuration structure: -- **Logger**: tracing-subscriber with configurable level (trace/debug/info/warn/error) and format (compact/pretty/json) +All configuration is TOML-based and loaded via `config.rs`. The config file path is passed via CLI argument `--config`. Configuration structure (`AppSettings`): +- **Logger**: enable, level (trace/debug/info/warn/error), format (compact/pretty/json) - **Server**: binding address, port, and host URL -- **Database**: PostgreSQL URI with optional connection pool settings -- **Mailer**: Optional SMTP configuration for emails -- **Sentry**: Optional error tracking with DSN and sampling rate +- **Database**: PostgreSQL URI with max connections and timeout +- **Smtp**: Optional SMTP configuration for emails +- **Sentry**: Optional DSN and traces_sample_rate +- **Bilibili**: sessdata and bili_jct cookies for API authentication +- **Jwt**: ES256 private/public keys in PEM format ### Application Flow 1. `main.rs`: Entry point using mimalloc global allocator -2. `app.rs::run()`: CLI parser (`clap`) with two commands: - - `server`: Loads config, initializes tracing/Sentry, starts web server - - `version`: Displays version and build SHA +2. `app.rs::run()`: CLI parser (`clap`) with three commands: + - `server --config `: Load config, initialize tracing/Sentry, start web server + - `generate-jwt --config --subject `: Generate ES256 JWT token + - `version`: Display version and build SHA 3. Server initialization sequence: - - Load TOML config - - Initialize tracing (based on logger config) + - Load TOML config via `AppSettings::new()` + - Initialize tracing subscriber with module whitelist: `["tower_http", "sqlx::query", "my_axum_template"]` - Initialize Sentry (if configured) - - Create AppState with PostgreSQL pool - - Run database migrations - - Build Axum router with middleware - - Start server with graceful shutdown handling + - Create `AppState` with PostgreSQL pool, shared HTTP client, and configs + - Run database migrations automatically via `repository.migrate()` + - Build Axum router with OpenAPI support + - Apply Tower middleware (timeout, compression) + - Bind TCP listener and start server with graceful shutdown handling ### State Management -`AppState` (src/state.rs) holds the `PostgresRepository` with a SQLx connection pool. The repository pattern provides: +`AppState` (src/state.rs) holds application-wide state: +- `repository: PostgresRepository` - Database operations with SQLx pool +- `bilibili_config: BilibiliConfig` - Bilibili API credentials +- `jwt_config: JwtConfig` - JWT signing/verification keys +- `http_client: reqwest::Client` - Shared HTTP client for Bilibili API calls + +Repository trait provides: - `health_check()`: Database connectivity check - `migrate()`: Run SQLx migrations from `./migrations` directory ### Routing Structure Routes are organized in `src/routes/` using Utoipa's OpenApiRouter: -- All API routes are prefixed with `/api/v1` +- All API routes prefixed with `/api` (note: NOT `/api/v1`) +- **Public routes** (no auth required): + - `GET /api/_ping` - Simple ping endpoint + - `GET /api/_health` - Database health check +- **Protected routes** (JWT required): + - `POST /api/bilibili/createDynamic` - Create Bilibili dynamic with multipart file upload - OpenAPI documentation available at: - - `/api/v1/scalar` - Scalar UI - - `/api/v1/openapi.json` - OpenAPI spec JSON -- Route handlers use `utoipa` macros for automatic OpenAPI spec generation + - `/api/scalar` - Scalar UI + - `/api/openapi.json` - OpenAPI spec JSON +- JWT auth middleware applied via `middleware::from_fn_with_state()` to protected routes +- Security scheme: HTTP Bearer with JWT format + +### Authentication +JWT-based authentication using ES256 algorithm (ECDSA with P-256): +- `Claims` structure: `sub` (subject) and `iat` (issued at timestamp) +- No expiration validation (`validate_exp = false`) - tokens are long-lived +- Token generation: `generate_token(subject, private_key_pem)` via CLI command +- Token verification: `verify_token(token, public_key_pem)` +- Middleware: `jwt_auth_middleware()` extracts `Authorization: Bearer ` header ### Middleware Layer -Applied in `src/middleware.rs` via Tower: +Applied in `src/middleware.rs` via Tower layers: +- **RequestBodyTimeoutLayer**: 10-second timeout on request body - **CompressionLayer**: Response compression (tower-http compression-full) -- **RequestBodyTimeoutLayer**: 10-second request timeout ### Error Handling -- **anyhow**: Used in `app.rs` for main application errors -- **thiserror**: Used in `config.rs` for typed config errors -- **SQLx**: Database errors propagate as Result types +Custom `AppError` enum (src/error.rs) with three variants: +- `BadRequest(anyhow::Error)` → HTTP 400 +- `Unauthorized(anyhow::Error)` → HTTP 401 +- `InternalError(anyhow::Error)` → HTTP 500 + +Response format: `{ "code": 1 }` (errors logged server-side with details) +Automatic conversions from: `sqlx::Error`, `serde_json::Error`, `reqwest::Error`, `anyhow::Error` +`AppResult` type alias: `Result` ### Testing - CI runs `rustfmt` and `clippy` checks - `SQLX_OFFLINE=true` is set in CI to allow offline builds (requires `sqlx-data.json` files) +- No dedicated test files in codebase (relies on manual testing via HTTP clients) ## Key Development Notes ### Adding New Routes -1. Create handler functions in `src/routes/` modules -2. Add `utoipa` OpenAPI macros to handlers -3. Register routes in `build_router()` using `routes!()` macro -4. Routes are automatically prefixed with `/api/v1` and documented +1. Create handler function in appropriate module under `src/routes/`: + ```rust + use crate::{error::AppResult, state::AppState}; + use axum::{Json, extract::State}; + use utoipa::ToSchema; + + #[derive(ToSchema, Serialize)] + pub struct MyResponse { pub field: String } + + #[utoipa::path( + post, + tag = "mytag", + path = "/myendpoint", + request_body(...), + responses(...), + security(("bearer_auth" = [])) + )] + pub async fn my_handler( + State(state): State, + ) -> AppResult> { + Ok(Json(MyResponse { field: "value".to_string() })) + } + ``` +2. Register route in `src/routes/mod.rs`: + - For public routes: Add before `.route_layer(jwt_auth_middleware)` + - For protected routes: Add after `.route_layer(jwt_auth_middleware)` +3. Add tag to `ApiDoc` struct's `tags()` macro if creating new tag +4. Routes automatically prefixed with `/api` and documented in OpenAPI spec ### Database Queries - Use SQLx with compile-time query verification +- Access pool via `state.repository.pool` (it's public but used directly in queries) - Place migration files in `./migrations` directory +- Create migrations: `sqlx migrate add -r migration_name` - Repository trait allows for alternative database backends -- All database operations go through `PostgresRepository` + +### Error Handling in Handlers +```rust +use crate::error::{AppError, AppResult}; + +pub async fn my_handler() -> AppResult> { + // Use ? operator for automatic conversion + let data = risky_operation()?; + + // Or use AppError explicitly + if invalid { + return Err(AppError::BadRequest(anyhow::anyhow!("Invalid input"))); + } + + Ok(Json(data)) +} +``` + +### Logging +Use `tracing` macros, not `println!` or `log!`: +```rust +use tracing::info; +info!("Creating dynamic with {} images", image_count); +``` ### Memory Allocation The application uses **mimalloc** as the global allocator (configured in `main.rs`) for improved performance. diff --git a/Cargo.lock b/Cargo.lock index a53eaeb..e401ff9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -240,7 +240,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -251,7 +251,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -474,9 +474,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.51" +version = "1.2.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" dependencies = [ "find-msvc-tools", "jobserver", @@ -665,9 +665,9 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", "typenum", @@ -796,7 +796,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -829,9 +829,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "find-msvc-tools" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" +checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" [[package]] name = "findshlibs" @@ -1004,9 +1004,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", @@ -1019,8 +1019,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1041,6 +1043,25 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1189,6 +1210,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http 1.4.0", "http-body", "httparse", @@ -1201,6 +1223,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.4.0", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -1236,9 +1274,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.1", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1375,9 +1415,9 @@ checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" [[package]] name = "indexmap" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -1413,6 +1453,38 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "janus" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "bytes", + "chrono", + "clap", + "futures", + "jsonwebtoken", + "mimalloc", + "rand 0.8.5", + "reqwest", + "sentry", + "serde", + "serde_json", + "serde_variant", + "sqlx", + "thiserror", + "tokio", + "toml", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "utoipa", + "utoipa-axum", + "utoipa-scalar", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -1433,6 +1505,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "language-tags" version = "0.3.2" @@ -1450,9 +1537,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.179" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libm" @@ -1619,35 +1706,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "my-axum-template" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "axum", - "bytes", - "chrono", - "clap", - "futures", - "mimalloc", - "sentry", - "serde", - "serde_json", - "serde_variant", - "sqlx", - "thiserror", - "tokio", - "toml", - "tower", - "tower-http", - "tracing", - "tracing-subscriber", - "utoipa", - "utoipa-axum", - "utoipa-scalar", -] - [[package]] name = "native-tls" version = "0.2.14" @@ -1683,7 +1741,17 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", ] [[package]] @@ -2013,6 +2081,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2255,17 +2333,22 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", + "encoding_rs", "futures-channel", "futures-core", "futures-util", + "h2", "http 1.4.0", "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-tls", "hyper-util", "js-sys", "log", + "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -2344,7 +2427,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2721,6 +2804,18 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "slab" version = "0.4.11" @@ -3033,6 +3128,27 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tempfile" version = "3.24.0" @@ -3043,7 +3159,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3169,6 +3285,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -3195,9 +3321,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.10+spec-1.1.0" +version = "0.9.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" dependencies = [ "indexmap", "serde_core", @@ -3772,6 +3898,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -4055,18 +4192,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.32" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fabae64378cb18147bb18bca364e63bdbe72a0ffe4adf0addfec8aa166b2c56" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.32" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9c2d862265a8bb4471d87e033e730f536e2a285cc7cb05dbce09a2a97075f90" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index f2394bd..17277bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "my-axum-template" +name = "janus" version = "0.1.0" edition = "2024" [[bin]] -name = "my-axum-template" +name = "janus" path = "src/main.rs" [dependencies] @@ -36,10 +36,10 @@ tower-http = { version = "0.6.8", features = [ "cors", "compression-full", "fs", - "set-header" + "set-header", ] } thiserror = "2.0.17" -toml = "0.9.10" +toml = "0.9.11" utoipa = { version = "5.4.0", features = [ "debug", "axum_extras" @@ -63,3 +63,6 @@ tower = "0.5.2" futures = "0.3.31" mimalloc = "0.1.48" serde_variant = "0.1.3" +reqwest = { version = "0.12.28", features = ["json", "multipart"] } +rand = "0.8" +jsonwebtoken = "9.3" diff --git a/docs/BILIBILI_API.md b/docs/BILIBILI_API.md new file mode 100644 index 0000000..fa41285 --- /dev/null +++ b/docs/BILIBILI_API.md @@ -0,0 +1,380 @@ +# Bilibili Dynamic Posting API + +This document describes the Bilibili dynamic posting functionality implemented in this project. + +## Overview + +The `POST /api/bilibili/createDynamic` endpoint posts text and optional images to Bilibili as a dynamic. + +- **Authentication:** Required. The route is protected by JWT middleware and expects `Authorization: Bearer `. +- **Implementation:** Axum multipart parsing + `reqwest` calls to Bilibili’s web APIs. + +## Configuration + +Add the following sections to your `config.toml`: + +```toml +[bilibili] +sessdata = "your_bilibili_sessdata_cookie" +bili_jct = "your_bilibili_bili_jct" # usually from `bili_jct` + +[jwt] +private_key = """-----BEGIN EC PRIVATE KEY----- +YOUR_PRIVATE_KEY_HERE +-----END EC PRIVATE KEY-----""" +public_key = """-----BEGIN PUBLIC KEY----- +YOUR_PUBLIC_KEY_HERE +-----END PUBLIC KEY-----""" +``` + +### Configuration Fields + +**Bilibili Config:** +- **sessdata** (required): Your Bilibili `SESSDATA` cookie value. +- **bili_jct** (required): Your Bilibili `bili_jct` cookie value. Used both as a request parameter and as a form field for image upload. + +**JWT Config:** +- **private_key** (required): ES256 private key in PEM format for signing JWT tokens. +- **public_key** (required): ES256 public key in PEM format for verifying JWT tokens. + +### Generating JWT Keys + +Generate ES256 key pair using OpenSSL: + +```bash +openssl ecparam -genkey -name prime256v1 -noout -out private.pem +openssl ec -in private.pem -pubout -out public.pem +``` + +### Obtaining Bilibili Credentials + +1. **SESSDATA**: Log into Bilibili in your browser and extract the `SESSDATA` cookie value. +2. **bili_jct**: Log into Bilibili in your browser and extract the `bili_jct` cookie value. + +## Generating JWT Tokens + +Generate a JWT token using the CLI command: + +```bash +cargo run -- generate-jwt --config config.toml --subject user_id +``` + +Options: +- `--config`: Path to config file (default: `config.toml`) +- `--subject`: Subject identifier (e.g., user ID or username) + +Notes: +- Tokens are ES256 signed. +- This implementation does not validate `exp` (no expiration claim is required/checked). + +## API Endpoint + +### POST `/api/bilibili/createDynamic` + +Creates a new Bilibili dynamic post with optional images. + +**Authentication:** Required via `Authorization: Bearer ` header. + +#### Request + +**Headers:** +- **Authorization** (required): `Bearer ` + +**Content-Type:** `multipart/form-data` + +**Form Fields:** +- **msg** (required, string): JSON value that will be sent to Bilibili as `dyn_req.content.contents`. +- **file(s)** (optional): Any multipart field *with a filename* is treated as an uploaded image. The server does not require a specific field name like `files`, `image`, etc. + +#### Request Examples + +**Text-only dynamic:** + +```bash +curl -X POST http://localhost:25150/api/bilibili/createDynamic \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -F 'msg=[{"type":1,"raw_text":"Hello from Rust API!","biz_id":""}]' +``` + +**Dynamic with a single image:** + +```bash +curl -X POST http://localhost:25150/api/bilibili/createDynamic \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -F 'msg=[{"type":1,"raw_text":"Check out this image!","biz_id":""}]' \ + -F "image=@photo.jpg" +``` + +**Dynamic with multiple images:** + +```bash +curl -X POST http://localhost:25150/api/bilibili/createDynamic \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -F 'msg=[{"type":1,"raw_text":"My photo gallery","biz_id":""}]' \ + -F "image1=@photo1.jpg" \ + -F "image2=@photo2.png" \ + -F "image3=@photo3.jpg" +``` + +#### Response Format + +**Success Response (HTTP 200):** + +```json +{ + "code": 0, + "data": { + "doc_id": 123, + "dynamic_id": 456, + "create_result": 0, + "errmsg": null + } +} +``` + +Notes: +- `data` may be omitted (`null`) even when Bilibili returns `code = 0`. + +**Error Response:** + +- Auth failures return **HTTP 401** with body `{ "code": 1 }`. +- Validation / Bilibili failures return JSON with body `{ "code": 1 }`. No additional error message fields are included in the response; detailed error information is only logged internally. + +#### Error Codes (Conceptual) + +| HTTP | code | Description | +|------|------|-------------| +| 401 | 1 | Missing/invalid Authorization header or JWT verification failed | +| 400 | 1 | Request validation failure (for example, missing or empty `msg` field) | +| 400 | 1 | Request validation failure (for example, invalid `msg` JSON format) | +| 500 | 1 | One or more images failed to upload to Bilibili | +| 200 | 1 | Bilibili returned non-zero `code` for dynamic creation (error details are only logged) | +| 500 | 1 | Failed to parse Bilibili create response | +| 500 | 1 | Network error or failed to read response (image-flow) | + +Note: +- These descriptions explain when errors occur, but the actual HTTP response body is always `{ "code": 1 }` for failures. + +## Message Format + +The `msg` field must contain a valid JSON array representing the dynamic content. The structure follows Bilibili's dynamic content format: + +### Basic Text Content + +```json +[ + { + "type": 1, + "raw_text": "Your text content here", + "biz_id": "" + } +] +``` + +### Rich Text Example + +```json +[ + { + "type": 1, + "raw_text": "Hello ", + "biz_id": "" + }, + { + "type": 2, + "raw_text": "@username", + "biz_id": "user_id_here" + }, + { + "type": 1, + "raw_text": " check this out!", + "biz_id": "" + } +] +``` + +**Content Types:** +- `type: 1` - Plain text +- `type: 2` - @mention (requires biz_id) +- Other types may be supported by Bilibili's API + +## Implementation Details + +### Image Upload Flow + +1. Client sends multipart request with text (`msg`) and optional image files +2. Server validates JWT authentication (Authorization: Bearer ``) +3. Server parses and validates the msg content +4. If images are present: + - Each image is uploaded individually to Bilibili's BFS (Bilibili File System) via `/x/dynamic/feed/draw/upload_bfs` + - Bilibili returns image URL, width, height for each uploaded image +5. Server creates the dynamic post via `/x/dynamic/feed/create/dyn`: + - **Text-only**: Uses `scene: 1` + - **With images**: Uses `scene: 2` and includes image metadata +6. Server returns success or error response + +### Upload ID Generation + +Each dynamic creation request includes a unique `upload_id` in the format: + +``` +{unix_timestamp_seconds}_{random_nonce} +``` + +Where: +- `unix_timestamp_seconds`: Current time in seconds since Unix epoch (floating-point, as produced by `as_secs_f64()`) +- `random_nonce`: Random 4-digit number (1000-9999) + +### Authentication Flow + +The endpoint uses multiple layers of authentication: + +1. **JWT Authentication**: Protects the endpoint with ES256 signed tokens + - Tokens must be included in the `Authorization: Bearer ` header + - Tokens are long-lived and, in the current implementation, do **not** automatically expire; you must rotate/revoke tokens explicitly if they are leaked or no longer needed + - Tokens are verified using the public key from configuration +2. **Bilibili SESSDATA**: Authenticates requests to Bilibili's API as your user +3. **CSRF Token**: Prevents cross-site request forgery attacks on Bilibili's API + +### HTTP Client + +The implementation uses a shared `reqwest::Client` instance stored in `AppState` for efficient connection pooling and reuse across requests. + +## OpenAPI Documentation + +The API is documented with OpenAPI. Access the interactive documentation at: + +- **Scalar UI**: `http://localhost:25150/api/scalar` +- **OpenAPI JSON**: `http://localhost:25150/api/openapi.json` + +## Notes on Current Implementation + +A few details are intentionally aligned with (or differ from) the original reference implementations: + +- Multipart parsing treats any part with `filename` as an image (field name does not matter). +- Image uploads are sent to Bilibili BFS endpoint `POST /x/dynamic/feed/draw/upload_bfs` with form fields: `file_up`, `biz=draw`, `category=daily`, `csrf`. +- Dynamic creation is sent to `POST /x/dynamic/feed/create/dyn?platform=web&csrf=...` with JSON body containing `dyn_req`. +- `upload_id` does not include UID (current format: `{timestamp_seconds}_{nonce}`). +- For Bilibili create failures (`code != 0`), this API returns HTTP 200 with `{ code: 1, exception: }`. + +## Deployment Considerations + +### Security + +- Keep your `config.toml` file secure and never commit it to version control +- Use environment-specific configuration files +- Consider adding rate limiting at the infrastructure level +- Monitor for failed authentication attempts + +### Performance + +- The shared HTTP client provides connection pooling for efficient Bilibili API calls +- Images are processed sequentially - for many images, consider parallel upload (future enhancement) +- Memory usage scales with uploaded image sizes (images kept in memory during upload) + +### Error Handling + +- All Bilibili API errors are logged using the tracing framework +- Failed uploads will return appropriate error messages +- Network timeouts are handled gracefully + +## Troubleshooting + +### JWT Token Issues +- Ensure the token is passed in the `Authorization: Bearer ` header format +- Check that the public/private key pair in config matches the keys used to generate the token +- Regenerate token using the CLI command if needed + +### "need msg" error +- Ensure the msg field is present in the request +- Verify the msg field is not empty +- Check that you're using the correct form field name ("msg") + +### "upload file fail" error +- Verify your SESSDATA and bili_jct tokens are valid and not expired +- Check that image files are not corrupted +- Ensure images are in supported formats (JPG, PNG, etc.) +- Check Bilibili API status + +### Images not showing in dynamic +- Verify the Bilibili API returned valid image URLs +- Check server logs for upload errors +- Ensure uploaded images meet Bilibili's requirements + +## Example Integration + +### Using with JavaScript/TypeScript + +```typescript +async function postToBilibili(text: string, jwtToken: string, images?: File[]) { + const formData = new FormData(); + formData.append('msg', JSON.stringify([{ + type: 1, + raw_text: text, + biz_id: '' + }])); + + if (images) { + images.forEach((image, index) => { + formData.append(`image${index}`, image); + }); + } + + const response = await fetch('http://localhost:25150/api/bilibili/createDynamic', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${jwtToken}` + }, + body: formData + }); + + return response.json(); +} +``` + +### Using with Python + +```python +import requests + +def post_to_bilibili(text, jwt_token, images=None): + url = 'http://localhost:25150/api/bilibili/createDynamic' + + headers = { + 'Authorization': f'Bearer {jwt_token}' + } + + data = { + 'msg': '[{"type":1,"raw_text":"' + text + '","biz_id":""}]' + } + + files = {} + if images: + for i, image_path in enumerate(images): + files[f'image{i}'] = open(image_path, 'rb') + + response = requests.post(url, data=data, files=files, headers=headers) + return response.json() +``` + +## Development + +### Building + +```bash +cargo build --release +``` + +### Running + +```bash +cargo run -- server --config config.toml +``` + +### Testing + +The endpoint can be tested with curl, Postman, or any HTTP client that supports multipart form data. + +## License + +This implementation follows the same license as the main project. diff --git a/example.toml b/example.toml index a5600b7..c7d0a74 100644 --- a/example.toml +++ b/example.toml @@ -23,7 +23,25 @@ host = "http://localhost" # Database Configuration [database] -uri = "postgres://ak:ak@localhost:25432/ak_asset_storage_next" + uri = "postgres://janus:janus@localhost:25432/janus" + +# Bilibili Configuration +[bilibili] +sessdata = "your_bilibili_sessdata_cookie" +bili_jct = "your_bilibili_bili_jct" + +# JWT Configuration (required for API authentication) +# Generate ES256 key pair (compatible with jsonwebtoken): +# openssl ecparam -genkey -name prime256v1 -noout -out private.pem +# Extract public key: +# openssl ec -in private.pem -pubout -out public.pem +[jwt] +private_key = """-----BEGIN EC PRIVATE KEY----- +YOUR_PRIVATE_KEY_HERE +-----END EC PRIVATE KEY-----""" +public_key = """-----BEGIN PUBLIC KEY----- +YOUR_PUBLIC_KEY_HERE +-----END PUBLIC KEY-----""" # [sentry] # dsn = "" diff --git a/src/app.rs b/src/app.rs index d8b50ad..b8f9584 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,6 +5,7 @@ use tokio::net::TcpListener; use tracing::info; use crate::{ + auth::generate_token, config::AppSettings, repository::Repository, routes::build_router, @@ -22,6 +23,14 @@ pub enum Commands { #[arg(short, long, default_value = "config.toml")] config: String, }, + /// Generate a JWT token + GenerateJwt { + #[arg(short, long, default_value = "config.toml")] + config: String, + /// Subject for the JWT (e.g., user ID or identifier) + #[arg(short, long)] + subject: String, + }, /// Show version information Version, } @@ -52,6 +61,16 @@ pub async fn run() -> Result<()> { start(&config).await?; Ok(()) } + Commands::GenerateJwt { config, subject } => { + let config = AppSettings::new(Path::new(&config))?; + + let token = generate_token(subject.clone(), &config.jwt.private_key)?; + + println!("Generated JWT token for subject '{}':", subject); + println!("{}", token); + + Ok(()) + } Commands::Version => { println!( "{} ({})", diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..6d3db5a --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,98 @@ +use axum::{ + extract::{Request, State}, + middleware::Next, + response::Response, +}; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::error::{AppError, AppResult}; +use crate::state::AppState; + +/// JWT Claims structure using standard registered claims +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Claims { + /// Subject (user identifier) + pub sub: String, + /// Issued at (as Unix timestamp) + pub iat: u64, +} + +impl Claims { + /// Create new claims with given subject + pub fn new(subject: String) -> Self { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + + Self { + sub: subject, + iat: now, + } + } +} + +/// Generate a JWT token using ES256 algorithm +pub fn generate_token( + subject: String, + private_key_pem: &str, +) -> Result { + let claims = Claims::new(subject); + let private_key_pem = private_key_pem.trim(); + let encoding_key = EncodingKey::from_ec_pem(private_key_pem.as_bytes())?; + let header = Header::new(Algorithm::ES256); + encode(&header, &claims, &encoding_key) +} + +/// Verify a JWT token using ES256 algorithm +pub fn verify_token( + token: &str, + public_key_pem: &str, +) -> Result { + let public_key_pem = public_key_pem.trim(); + let decoding_key = DecodingKey::from_ec_pem(public_key_pem.as_bytes())?; + let mut validation = Validation::new(Algorithm::ES256); + validation.validate_exp = false; // No expiration validation + + let token_data = decode::(token, &decoding_key, &validation)?; + Ok(token_data.claims) +} + +/// Extract JWT token from Authorization header +fn extract_token_from_header(auth_header: &str) -> Option<&str> { + auth_header.strip_prefix("Bearer ") +} + +/// JWT authentication middleware +pub async fn jwt_auth_middleware( + State(state): State, + request: Request, + next: Next, +) -> AppResult { + // Extract Authorization header + let auth_header = request + .headers() + .get("Authorization") + .ok_or_else(|| AppError::Unauthorized(anyhow::anyhow!("Missing authorization header")))? + .to_str() + .map_err(|_| { + AppError::Unauthorized(anyhow::anyhow!("Invalid authorization header format")) + })?; + + // Extract token from Bearer scheme + let token = extract_token_from_header(auth_header).ok_or_else(|| { + AppError::Unauthorized(anyhow::anyhow!( + "Invalid authorization format, expected: Bearer " + )) + })?; + + // Verify token + verify_token(token, &state.jwt_config.public_key).map_err(|err| { + AppError::Unauthorized(anyhow::anyhow!("JWT verification failed: {}", err)) + })?; + + // Token is valid, proceed with request + Ok(next.run(request).await) +} diff --git a/src/config.rs b/src/config.rs index b99db37..ba50be9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -93,6 +93,24 @@ pub struct SentryConfig { pub traces_sample_rate: f32, } +/// Bilibili configuration for dynamic posting +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BilibiliConfig { + /// Bilibili SESSDATA cookie value + pub sessdata: String, + /// Bilibili CSRF token + pub bili_jct: String, +} + +/// JWT configuration for authentication +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct JwtConfig { + /// ES256 private key in PEM format + pub private_key: String, + /// ES256 public key in PEM format + pub public_key: String, +} + /// Server configuration for application use #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ServerConfig { @@ -125,6 +143,8 @@ pub struct AppSettings { pub database: DatabaseConfig, pub mailer: Option, pub sentry: Option, + pub bilibili: BilibiliConfig, + pub jwt: JwtConfig, } impl AppSettings { diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..49d2a46 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,74 @@ +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde_json::json; +use thiserror::Error; + +/// Application-level errors for HTTP handlers +#[derive(Error, Debug)] +pub enum AppError { + #[error("Bad request: {0}")] + BadRequest(#[source] anyhow::Error), + + #[error("Unauthorized: {0}")] + Unauthorized(#[source] anyhow::Error), + + #[error("Internal error: {0}")] + InternalError(#[source] anyhow::Error), +} + +impl AppError { + /// Get the HTTP status code for this error + pub fn status_code(&self) -> StatusCode { + match self { + AppError::BadRequest(_) => StatusCode::BAD_REQUEST, + AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED, + AppError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let status = self.status_code(); + + // Log the detailed error with full context chain + tracing::error!("Handler error: {:?}", self); + + let body = json!({ + "code": 1, + }); + + (status, Json(body)).into_response() + } +} + +// Implement From for common error types to allow automatic conversion +impl From for AppError { + fn from(err: sqlx::Error) -> Self { + AppError::InternalError(anyhow::Error::new(err)) + } +} + +impl From for AppError { + fn from(err: serde_json::Error) -> Self { + AppError::BadRequest(anyhow::Error::new(err)) + } +} + +impl From for AppError { + fn from(err: reqwest::Error) -> Self { + AppError::InternalError(anyhow::Error::new(err)) + } +} + +impl From for AppError { + fn from(err: anyhow::Error) -> Self { + AppError::InternalError(err) + } +} + +/// Result type alias for handlers +pub type AppResult = Result; diff --git a/src/lib.rs b/src/lib.rs index 2026e73..ed65b3d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,7 @@ pub mod app; +pub mod auth; mod config; +pub mod error; mod middleware; mod repository; mod routes; diff --git a/src/main.rs b/src/main.rs index 015c110..d90a5ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ // CLI main entry point use anyhow::Result; +use janus::app::run; use mimalloc::MiMalloc; -use my_axum_template::app::run; #[global_allocator] static GLOBAL: MiMalloc = MiMalloc; diff --git a/src/routes/bilibili_handlers.rs b/src/routes/bilibili_handlers.rs new file mode 100644 index 0000000..3471248 --- /dev/null +++ b/src/routes/bilibili_handlers.rs @@ -0,0 +1,368 @@ +use anyhow::Context; +use axum::{ + Json, debug_handler, + extract::{Multipart, State}, +}; +use rand::Rng; +use reqwest::multipart::{Form, Part}; +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tracing::info; +use utoipa::ToSchema; + +use crate::error::{AppError, AppResult}; +use crate::state::AppState; + +/// Response for createDynamic endpoint +#[derive(ToSchema, Serialize, Deserialize)] +pub struct DynamicResponse { + pub code: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub msg: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub exception: Option, +} + +/// Bilibili upload response +#[derive(Debug, Deserialize)] +struct BilibiliUploadResponse { + code: i32, + data: Option, +} + +#[derive(Debug, Deserialize)] +struct BilibiliUploadData { + image_url: String, + image_width: f64, + image_height: f64, +} + +/// Bilibili create dynamic response +#[derive(Debug, Deserialize, Serialize)] +struct BilibiliCreateResponse { + code: i32, + data: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct BilibiliCreateData { + #[serde(default)] + doc_id: Option, + #[serde(default)] + dynamic_id: Option, + #[serde(default)] + create_result: Option, + #[serde(default)] + errmsg: Option, +} + +/// Picture info for dynamic request +#[derive(Debug, Serialize)] +struct PicInfo { + img_src: String, + img_width: f64, + img_height: f64, + img_size: f64, +} + +/// Generate headers for Bilibili API requests +fn create_headers(sessdata: &str) -> reqwest::header::HeaderMap { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert("Accept", "*/*".parse().unwrap()); + headers.insert( + "User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36" + .parse() + .unwrap(), + ); + headers.insert( + "Sec-Ch-Ua", + "\"Not A(Brand\";v=\"99\", \"Google Chrome\";v=\"121\", \"Chromium\";v=\"121\"" + .parse() + .unwrap(), + ); + headers.insert("Sec-Ch-Ua-Mobile", "?0".parse().unwrap()); + headers.insert("Sec-Ch-Ua-Platform", "\"Windows\"".parse().unwrap()); + headers.insert("Sec-Fetch-Dest", "empty".parse().unwrap()); + headers.insert("Sec-Fetch-Mode", "cors".parse().unwrap()); + headers.insert("Sec-Fetch-Site", "same-site".parse().unwrap()); + headers.insert( + "Cookie", + format!("SESSDATA={}; l=v", sessdata).parse().unwrap(), + ); + headers +} + +/// Generate random nonce +fn get_nonce() -> i32 { + rand::thread_rng().gen_range(1000..9999) +} + +/// Get unix timestamp in seconds +fn get_unix_seconds() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("System time should be after UNIX epoch") + .as_secs_f64() +} + +/// Upload a single image to Bilibili +async fn upload_image( + file_data: Vec, + file_name: String, + content_type: String, + sessdata: &str, + bili_jct: &str, + client: &reqwest::Client, +) -> AppResult<(f64, BilibiliUploadData)> { + let file_size_kb = file_data.len() as f64 / 1024.0; + + let file_part = Part::bytes(file_data) + .file_name(file_name) + .mime_str(&content_type) + .map_err(|e| { + AppError::InternalError(anyhow::Error::new(e).context("Failed to create file part")) + })?; + + let form = Form::new() + .part("file_up", file_part) + .text("biz", "draw") + .text("category", "daily") + .text("csrf", bili_jct.to_string()); + + let resp = client + .post("https://api.bilibili.com/x/dynamic/feed/draw/upload_bfs") + .headers(create_headers(sessdata)) + .multipart(form) + .send() + .await + .context("Upload request failed")?; + + let resp_text = resp.text().await.context("Failed to read response")?; + + let upload_resp: BilibiliUploadResponse = + serde_json::from_str(&resp_text).context("Failed to parse upload response")?; + + if upload_resp.code != 0 { + return Err(AppError::InternalError(anyhow::anyhow!( + "Bilibili file upload failed, response: {}", + resp_text + ))); + } + + let data = upload_resp + .data + .ok_or_else(|| AppError::InternalError(anyhow::anyhow!("Upload response missing data")))?; + + Ok((file_size_kb, data)) +} + +/// Helper function to handle Bilibili create dynamic response +async fn handle_create_dynamic_response( + result: Result, +) -> AppResult { + let resp = result.context("Create dynamic request failed")?; + + let body = resp.text().await.context("Read response failed")?; + + info!("Create dynamic response: {}", body); + + let r: BilibiliCreateResponse = + serde_json::from_str(&body).context("Parse create dynamic response failed")?; + + if r.code != 0 { + return Err(AppError::InternalError(anyhow::anyhow!( + "Bilibili API returned code {}", + r.code + ))); + } + + // Bilibili sometimes returns `code=0` but `data=null`. + // Treat `code=0` as success and pass through the raw data. + Ok(r.data + .as_ref() + .map(|d| serde_json::json!(d)) + .unwrap_or(serde_json::json!(null))) +} + +/// Helper function to create dynamic with specified scene and optional pics +async fn create_dynamic_with_scene( + contents: serde_json::Value, + pics: Option>, + sessdata: &str, + bili_jct: &str, + client: &reqwest::Client, +) -> AppResult> { + let upload_id = format!("{}_{}", get_unix_seconds(), get_nonce()); + + let mut dyn_req_content = serde_json::json!({ + "dyn_req": { + "content": { + "contents": contents + }, + "scene": if pics.is_some() {2} else {1}, + "attach_card": null, + "upload_id": upload_id, + "meta": { + "app_meta": { + "from": "create.dynamic.web", + "mobi_app": "web" + } + } + } + }); + + // Add pics field if provided + if let Some(pics) = pics { + dyn_req_content["dyn_req"]["pics"] = + serde_json::to_value(pics).context("Failed to serialize pics")?; + } + + let mut headers = create_headers(sessdata); + headers.insert("Content-Type", "application/json".parse().unwrap()); + + let url = format!( + "https://api.bilibili.com/x/dynamic/feed/create/dyn?platform=web&csrf={}", + bili_jct + ); + + let result = client + .post(&url) + .headers(headers) + .body(dyn_req_content.to_string()) + .send() + .await; + + let data = handle_create_dynamic_response(result).await?; + Ok(Json(DynamicResponse { + code: 0, + msg: None, + data: Some(data), + exception: None, + })) +} + +/// Create a Bilibili dynamic post with optional images +#[debug_handler] +#[utoipa::path( + post, + tag = "bilibili", + path = "/bilibili/createDynamic", + request_body(content_type = "multipart/form-data", + description = " +- **msg** (required, string): JSON value that will be sent to Bilibili as `dyn_req.content.contents`. For example: `[{\"type\":1,\"raw_text\":\"Hello from Rust API!\",\"biz_id\":\"\"}]`. +- **file(s)** (optional): Any multipart field *with a filename* is treated as an uploaded image. The server does not require a specific field name like `files`, `image`, etc." + ), + + responses( + (status = OK, body = DynamicResponse), + (status = UNAUTHORIZED, body = DynamicResponse), + (status = BAD_REQUEST, body = DynamicResponse), + (status = INTERNAL_SERVER_ERROR, body = DynamicResponse) + ), + security( + ("bearer_auth" = []) + ) +)] +pub async fn create_dynamic( + State(state): State, + mut multipart: Multipart, +) -> AppResult> { + // Extract Bilibili config + let bilibili_config = &state.bilibili_config; + + let mut msg: Option = None; + let mut files: Vec<(Vec, String, String)> = Vec::new(); + + // Parse multipart form data + loop { + match multipart.next_field().await { + Ok(Some(field)) => { + let field_name = field.name().unwrap_or("").to_string(); + + match field_name.as_str() { + "msg" => { + msg = field.text().await.ok(); + } + _ => { + // Assume it's a file upload + if let Some(file_name) = field.file_name() { + let file_name = file_name.to_string(); + let content_type = field + .content_type() + .unwrap_or("application/octet-stream") + .to_string(); + if let Ok(data) = field.bytes().await { + files.push((data.to_vec(), file_name, content_type)); + } + } + } + } + } + Ok(None) => { + // End of multipart fields + break; + } + Err(e) => { + info!("Error reading multipart field: {}", e); + break; + } + } + } + + // Validate msg + let msg_content = msg + .filter(|m| !m.is_empty()) + .ok_or_else(|| AppError::BadRequest(anyhow::anyhow!("need msg")))?; + + // Parse msg as JSON + let contents: serde_json::Value = + serde_json::from_str(&msg_content).context("Invalid msg format")?; + + // If files are present, upload them first + if !files.is_empty() { + info!("Uploading {} files", files.len()); + let mut pics: Vec = Vec::new(); + + for (file_data, file_name, content_type) in files { + let (size, data) = upload_image( + file_data, + file_name, + content_type, + &bilibili_config.sessdata, + &bilibili_config.bili_jct, + &state.http_client, + ) + .await?; + + pics.push(PicInfo { + img_src: data.image_url, + img_width: data.image_width, + img_height: data.image_height, + img_size: size, + }); + } + + // Create dynamic with images (scene 2) + create_dynamic_with_scene( + contents, + Some(pics), + &bilibili_config.sessdata, + &bilibili_config.bili_jct, + &state.http_client, + ) + .await + } else { + // Create text-only dynamic (scene 1) + create_dynamic_with_scene( + contents, + None, + &bilibili_config.sessdata, + &bilibili_config.bili_jct, + &state.http_client, + ) + .await + } +} diff --git a/src/routes/misc_handlers.rs b/src/routes/misc_handlers.rs index 50ac54e..a4e52e9 100644 --- a/src/routes/misc_handlers.rs +++ b/src/routes/misc_handlers.rs @@ -1,4 +1,4 @@ -use crate::{repository::Repository, state::AppState}; +use crate::{error::AppResult, repository::Repository, state::AppState}; use axum::{Json, debug_handler, extract::State}; use serde::Serialize; use utoipa::ToSchema; @@ -10,15 +10,16 @@ pub struct Health { /// /_ping #[debug_handler] -#[utoipa::path(get, path = "/_ping", responses((status = OK, body = Health)))] +#[utoipa::path(get, path = "/_ping", tag = "health", responses((status = OK, body = Health)))] pub async fn ping() -> Json { Json(Health { ok: true }) } + /// /_health #[debug_handler] -#[utoipa::path(get, path = "/_health", responses((status = OK, body = Health)))] -pub async fn health(State(state): State) -> Json { - Json(Health { +#[utoipa::path(get, path = "/_health", tag = "health", responses((status = OK, body = Health)))] +pub async fn health(State(state): State) -> AppResult> { + Ok(Json(Health { ok: state.repository.health_check().await, - }) + })) } diff --git a/src/routes/mod.rs b/src/routes/mod.rs index fd7e2a0..1bd9553 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,7 +1,8 @@ #![allow(clippy::needless_for_each)] +mod bilibili_handlers; mod misc_handlers; -use crate::{middleware::apply_axum_middleware, state::AppState}; -use axum::{Json, Router, routing::get}; +use crate::{auth::jwt_auth_middleware, middleware::apply_axum_middleware, state::AppState}; +use axum::{Json, Router, middleware, routing::get}; use utoipa::OpenApi; use utoipa_axum::{router::OpenApiRouter, routes}; use utoipa_scalar::{Scalar, Servable}; @@ -10,27 +11,57 @@ use utoipa_scalar::{Scalar, Servable}; #[openapi( tags( (name = "health", description = "Health check endpoints"), + (name = "bilibili", description = "Bilibili dynamic posting endpoints"), ), + components( + schemas(bilibili_handlers::DynamicResponse) + ), + modifiers(&SecurityAddon) )] pub struct ApiDoc; +struct SecurityAddon; + +impl utoipa::Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme( + "bearer_auth", + utoipa::openapi::security::SecurityScheme::Http( + utoipa::openapi::security::HttpBuilder::new() + .scheme(utoipa::openapi::security::HttpAuthScheme::Bearer) + .bearer_format("JWT") + .build(), + ), + ) + } + } +} + pub fn build_router(state: AppState) -> Router { let (api_routes, mut openapi) = OpenApiRouter::with_openapi(ApiDoc::openapi()) - // Health endpoints + // Health endpoints (no auth required) .routes(routes!(misc_handlers::ping)) .routes(routes!(misc_handlers::health)) + // Apply JWT authentication for subsequent routes + .route_layer(middleware::from_fn_with_state( + state.clone(), + jwt_auth_middleware, + )) + // Bilibili routes (protected by JWT auth) + .routes(routes!(bilibili_handlers::create_dynamic)) .split_for_parts(); openapi.paths.paths = openapi .paths .paths .into_iter() - .map(|(path, item)| (format!("/api/v1{path}"), item)) + .map(|(path, item)| (format!("/api{path}"), item)) .collect::>(); let full_router = Router::new() - .nest("/api/v1", api_routes) - .merge(Scalar::with_url("/api/v1/scalar", openapi.clone())) - .route("/api/v1/openapi.json", get(|| async move { Json(openapi) })) + .nest("/api", api_routes) + .merge(Scalar::with_url("/api/scalar", openapi.clone())) + .route("/api/openapi.json", get(|| async move { Json(openapi) })) .with_state(state); // Apply middleware diff --git a/src/state.rs b/src/state.rs index 6eed555..73bd475 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,8 +1,14 @@ -use crate::{config::AppSettings, repository::PostgresRepository}; +use crate::{ + config::{AppSettings, BilibiliConfig, JwtConfig}, + repository::PostgresRepository, +}; #[derive(Debug, Clone)] pub struct AppState { pub repository: PostgresRepository, + pub bilibili_config: BilibiliConfig, + pub jwt_config: JwtConfig, + pub http_client: reqwest::Client, } pub async fn init_state_with_pg(config: &AppSettings) -> AppState { @@ -13,5 +19,8 @@ pub async fn init_state_with_pg(config: &AppSettings) -> AppState { AppState { repository: PostgresRepository { pool }, + bilibili_config: config.bilibili.clone(), + jwt_config: config.jwt.clone(), + http_client: reqwest::Client::new(), } }