From efa41b42c28e0dc96491ece49beeb06f6d4820f5 Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 11 Jun 2026 10:44:27 +0800 Subject: [PATCH] feat(auth): add loading state to login/register forms and update AGENTS.md --- AGENTS.md | 168 +++++++++++++++++++++++----------------- src/components/forms.rs | 5 +- src/pages/login.rs | 14 +++- src/pages/register.rs | 23 +++++- 4 files changed, 133 insertions(+), 77 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5a28232..9ccb0be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,25 +3,16 @@ ## Development Commands ```bash -# Dev server — runs tailwindcss watch + dx serve (needs PostgreSQL running) -make dev - -# Production build — builds editor, compiles CSS, then dx build --release -make build - -# Just CSS -make css # one-shot -make css-watch # watch mode - -# Rust only (no CSS/editor) -cargo build -cargo clippy -cargo test -dx check # Dioxus type-check -dx serve # Dev server without Tailwind watch +make dev # tailwindcss watch + dx serve (needs PostgreSQL) +make build # build-editor → highlight-css → tailwindcss → dx build --release +make build-linux # same as build but targets x86_64-unknown-linux-musl +make css # one-shot CSS +make css-watch # watch mode +make test # cargo test +make clean # cargo clean + rm public/style.css ``` -**Command order matters for production**: `make build` internally runs `build-editor` → `tailwindcss` → `dx build --release`. Do not run `dx build --release` alone if you need the editor or CSS. +**Build order matters**: `make build` runs `build-editor` → `highlight-css` (`cargo run --bin generate_highlight_css`) → `tailwindcss --minify` → `dx build --release`. Do not run `dx build --release` alone. ## Prerequisites @@ -32,104 +23,139 @@ dx serve # Dev server without Tailwind watch ## Environment -Create `.env` (not committed — see `.env.example`): +Create `.env` (not committed): ``` DATABASE_URL=postgres://postgres:postgres@localhost:5432/yggdrasil RUST_LOG=info ``` +Optional tuning via env vars (all have sane defaults): + +``` +WEBP_QUALITY=85.0 # 0.0–100.0, clamped +WEBP_METHOD=2 # 0–6, clamped +RATE_LIMIT_STRICT_PER_SEC=1 +RATE_LIMIT_STRICT_BURST=5 +RATE_LIMIT_UPLOAD_PER_SEC=2 +RATE_LIMIT_UPLOAD_BURST=15 +RATE_LIMIT_IMAGE_PER_SEC=10 +RATE_LIMIT_IMAGE_BURST=50 +``` + Run migrations before first dev server start: ```bash -./migrate.sh # preferred: auto-creates DB, runs all migrations in order -# or manually: -psql $DATABASE_URL -f migrations/001_init.sql +./migrate.sh # auto-creates DB, runs migrations/ in order ``` ## Architecture: Conditional Compilation -This is a Dioxus 0.7 fullstack project with **two independent gates**. Getting this wrong is the most common source of compilation errors. +Dioxus 0.7 fullstack project with **two independent gates** — the most common source of compilation errors. | Gate | Applies to | Used for | |------|-----------|----------| -| `#[cfg(feature = "server")]` | Server binary only | Database, env loading, background tasks, server functions body | -| `#[cfg(target_arch = "wasm32")]` | WASM frontend only | localStorage, DOM APIs, cookie manipulation, web_sys calls | +| `#[cfg(feature = "server")]` | Server binary only | DB, env loading, background tasks, server function bodies, highlight, WebP, caching | +| `#[cfg(target_arch = "wasm32")]` | WASM frontend only | localStorage, DOM APIs, web_sys calls, theme detection | -**Critical**: `feature = "server"` and `target_arch = "wasm32"` are **mutually exclusive** at build time, but both default features (`web` + `server`) are enabled in `Cargo.toml`. The `dx` CLI handles feature selection during builds. +**Critical**: Both default features (`web` + `server`) are enabled in `Cargo.toml`. The `dx` CLI handles feature selection during builds. -**Stub pattern**: `src/db/mod.rs` provides a `DummyPool` when the `server` feature is disabled so the crate still compiles for WASM. Do not remove this. +**Stub pattern**: `src/db/mod.rs` provides a `DummyPool` when `server` feature is disabled — do not remove. -**Dead code allowances**: Auth utilities (`password.rs`, `session.rs`) carry `#[allow(dead_code)]` because they are imported by `api/auth.rs` but the compiler sees them as unused in WASM builds where server function bodies are stripped. +**Dead code allowances**: `src/auth/password.rs` and `src/auth/session.rs` carry `#[allow(dead_code)]` because the compiler sees them as unused in WASM builds where server function bodies are stripped. -## Server Functions +## Dual API Architecture -Server functions live in `src/api/auth.rs` and use the macro: +The server exposes two distinct API patterns: + +1. **Dioxus server functions** (`#[server(Name, "/api")]` in `src/api/`) — auto-routed, callable from both client and server Rust. Spread across `src/api/auth.rs` and `src/api/posts/`. + +2. **Axum routes** (registered in `src/main.rs`) — manual `axum::Router` for endpoints that don't fit the server-function model: + - `POST /api/upload` — image upload (multipart, auth-required, rate-limited) + - `GET /uploads/{*path}` — image serving with on-the-fly resize/rotate/convert (query params: `w`, `h`, `thumb`, `rotate`, `format`, `quality`) + +## Server Module Structure -```rust -#[server(Name, "/api")] -pub async fn fn_name(...) -> Result<..., ServerFnError> ``` - -These are callable from both client and server Rust code; Dioxus handles HTTP transport automatically. +src/api/ — server functions + Axum handlers + auth.rs — login, register, session validation + markdown.rs — Markdown→HTML rendering (pulldown-cmark + ammonia sanitization) + image.rs — image serving with processing pipeline + disk+memory cache + upload.rs — image upload, auto-converts to WebP + rate_limit.rs — governor-based rate limiting (3 tiers: strict/upload/image) + slug.rs — URL slug generation + posts/ — CRUD server functions for blog posts +src/auth/ — password hashing (Argon2) + session token management +src/bin/ — generate_highlight_css (build-time CSS generation) +src/cache.rs — moka future-based caches for posts, tags, stats +src/components/ — Dioxus UI components +src/db/ — PostgreSQL pool (deadpool-postgres, LazyLock global) +src/hooks/ — shared Dioxus hooks +src/models/ — Post, User, Tag data models +src/pages/ — route page components (frontend + admin) +src/tasks/ — background tokio tasks (session cleanup) +src/theme.rs — light/dark theme with SSR cookie + WASM localStorage +src/webp.rs — zenwebp encode/decode (image crate has no WebP) +``` ## Tiptap Editor Subproject -The rich-text editor is a separate Vite project in `libs/tiptap-editor/`. +Rich-text editor in `libs/tiptap-editor/`, built as an IIFE library exposing `window.TiptapEditor`. -- Built as an **IIFE** library exposing `window.TiptapEditor` -- Output goes to `public/tiptap/` -- `make build` runs `npm install && vite build` inside `libs/tiptap-editor` and renames the output file -- The write page (`src/pages/admin/write.rs`) initializes the editor via `js_sys::eval` and polls `window.__tiptap_ready` +- Output: `public/tiptap/` +- `make build` runs `npm install && npx vite build` inside `libs/tiptap-editor` +- `src/pages/admin/write.rs` initializes via `js_sys::eval`, polls `window.__tiptap_ready` -Do not edit files in `public/tiptap/` directly — they are build artifacts. +Do not edit `public/tiptap/` — they are build artifacts. + +## Syntax Highlighting Pipeline + +- `themes/` contains Catppuccin Latte (light) and Mocha (dark) `.tmTheme` files +- `syntaxes/` has custom Sublime syntax definitions (Kotlin, Swift) +- `src/bin/generate_highlight_css.rs` generates `public/highlight.css` with class-based rules scoped under `.md-content pre code`, with `.dark` prefix for dark mode +- `src/highlight.rs` uses syntect at runtime for code block highlighting +- All gated behind `#[cfg(feature = "server")]` ## Auth & Session -- **Registration**: first user becomes `admin`; subsequent registrations are rejected with `"Registration is closed"`. -- **Login**: sets an **HttpOnly** cookie via `FullstackContext::add_response_header` (server-side). -- **Session validation**: `get_current_user` reads the `session` cookie from the request headers and queries `sessions` + `users` tables. -- **Background cleanup**: `tasks::session_cleanup::run_cleanup()` deletes expired sessions every hour on a tokio task. +- **Registration**: first user becomes `admin`; subsequent registrations rejected with `"Registration is closed"` +- **Login**: sets an HttpOnly cookie via `FullstackContext::add_response_header` +- **Session validation**: `get_current_user` reads `session` cookie, queries `sessions` + `users` tables +- **Background cleanup**: `tasks::session_cleanup::run_cleanup()` deletes expired sessions every hour -## Database +## Caching -- PostgreSQL via `tokio-postgres` + `deadpool-postgres` -- Pool is a `LazyLock` global in `src/db/pool.rs`, initialized from `DATABASE_URL` -- Max pool size: 10 +- **Post/tag caches** (`src/cache.rs`): moka future-based, TTL varies by data type (60s–600s). Invalidated on writes. +- **Image processing cache** (`src/api/image.rs`): two-tier — in-memory moka cache + disk cache in `uploads/.cache/`. Keyed by path + query params. -## Testing & Verification +## Testing ```bash -# Standard Rust test suite -cargo test - -# Dioxus type-check (catches component/Router issues) -dx check - -# Lint -cargo clippy +cargo test # standard Rust test suite +dx check # Dioxus type-check (catches component/Router issues) +cargo clippy # lint ``` -There are currently no integration tests requiring a database connection. +Most tests use `#[cfg(all(test, feature = "server"))]` — they only run when the server feature is active (which is the default). No integration tests requiring a database connection. -## Build Artifacts to Ignore +## Image Processing Constraints -The following are generated and gitignored: +- The `image` crate is configured **without** WebP support (`default-features = false, features = ["jpeg", "png", "gif"]`). Do not add WebP to the image crate features. +- All WebP encode/decode goes through `zenwebp` via `src/webp.rs`. +- Upload pipeline auto-converts non-GIF/non-WebP images to WebP, keeping original format if WebP is larger. +- Image serving supports on-the-fly resize (`w`, `h`), thumbnail (`thumb=WxH`), rotation (90/180/270), and format conversion. -- `public/style.css` — generated by Tailwind -- `public/tiptap/` — generated by Vite from `libs/tiptap-editor/` +## Build Artifacts (gitignored) + +- `public/style.css` — Tailwind output +- `public/highlight.css` — generated by `generate_highlight_css` binary +- `public/tiptap/` — Vite build output - `/dist`, `/.dioxus`, `/target` - `node_modules` (inside `libs/tiptap-editor/`) - -## rust-analyzer - -`rust-analyzer.toml` excludes generated directories (`node_modules`, `public`, `.dioxus`, `.omc`, `dist`). If rust-analyzer shows spurious errors in generated files, check that the exclusion list is respected by your editor. - -## Image Processing - -- **WebP handling**: The `image` crate is configured without WebP support (`default-features = false, features = ["jpeg", "png", "gif"]`). All WebP encode/decode operations go through `zenwebp` via `src/webp.rs`. This ensures consistent WebP handling across the codebase. Do not add WebP to the image crate features. +- `uploads/.cache/` — image processing disk cache ## Notes - `rand` + `getrandom` with `js` feature are required for Argon2 salt generation in WASM builds. -- The `#[allow(unused_mut, unused_variables)]` on `Write` component is intentional — the `mut` signals are used inside `#[cfg(target_arch = "wasm32")]` blocks that are stripped in server builds. +- `#[allow(unused_mut, unused_variables)]` on `Write` component is intentional — `mut` signals are used in `#[cfg(target_arch = "wasm32")]` blocks stripped in server builds. +- Server uses incremental rendering with 300s cache (`IncrementalRendererConfig` in `src/main.rs`). diff --git a/src/components/forms.rs b/src/components/forms.rs index b815526..89b201d 100644 --- a/src/components/forms.rs +++ b/src/components/forms.rs @@ -9,15 +9,18 @@ pub fn FormInput( r#type: &'static str, placeholder: &'static str, value: String, + disabled: bool, oninput: EventHandler, onkeydown: Option>, ) -> Element { + let disabled_class = if disabled { "opacity-60 cursor-not-allowed" } else { "" }; rsx! { input { - class: "{INPUT_CLASS}", + class: "{INPUT_CLASS} {disabled_class}", r#type: "{r#type}", placeholder: "{placeholder}", value: "{value}", + disabled, oninput: move |e| oninput.call(e.value()), onkeydown: move |e| { if let Some(ref handler) = onkeydown { diff --git a/src/pages/login.rs b/src/pages/login.rs index 976ee17..8ea8b9d 100644 --- a/src/pages/login.rs +++ b/src/pages/login.rs @@ -11,10 +11,15 @@ pub fn Login() -> Element { let mut username = use_signal(|| "".to_string()); let mut password = use_signal(|| "".to_string()); let mut error = use_signal(|| None::); + let mut loading = use_signal(|| false); let mut ctx: UserContext = use_context(); let on_submit = Callback::new(move |_| { + if loading() { + return; + } error.set(None); + loading.set(true); let username_val = username(); let password_val = password(); @@ -47,9 +52,12 @@ pub fn Login() -> Element { error.set(Some(format!("请求失败: {}", e))); } } + loading.set(false); }); }); + let is_loading = loading(); + rsx! { div { class: "min-h-screen flex items-center justify-center bg-paper-theme", div { class: "w-full max-w-md p-8 bg-paper-entry rounded-2xl border border-paper-border shadow-sm", @@ -68,6 +76,7 @@ pub fn Login() -> Element { r#type: "text", placeholder: "用户名或邮箱", value: username(), + disabled: is_loading, oninput: move |v: String| username.set(v), onkeydown: Some(EventHandler::new(move |e: KeyboardEvent| if e.key() == Key::Enter { on_submit(()) })), } @@ -78,14 +87,17 @@ pub fn Login() -> Element { r#type: "password", placeholder: "密码", value: password(), + disabled: is_loading, oninput: move |v: String| password.set(v), onkeydown: Some(EventHandler::new(move |e: KeyboardEvent| if e.key() == Key::Enter { on_submit(()) })), } } button { class: "{BUTTON_PRIMARY_CLASS}", + class: if is_loading { "opacity-60 cursor-not-allowed" }, + disabled: is_loading, onclick: move |_| on_submit(()), - "登录" + if is_loading { "登录中..." } else { "登录" } } Link { class: "block w-full py-2 px-4 text-center text-paper-secondary hover:text-paper-accent font-medium rounded-lg transition-all duration-200 cursor-pointer", diff --git a/src/pages/register.rs b/src/pages/register.rs index 9f27ec8..3780485 100644 --- a/src/pages/register.rs +++ b/src/pages/register.rs @@ -13,8 +13,12 @@ pub fn Register() -> Element { let mut confirm_password = use_signal(|| "".to_string()); let mut error = use_signal(|| None::); let mut success = use_signal(|| false); + let mut loading = use_signal(|| false); - let on_submit = move |_| { + let on_submit = Callback::new(move |_| { + if loading() { + return; + } error.set(None); success.set(false); @@ -31,6 +35,8 @@ pub fn Register() -> Element { let email_val = email(); let password_val = password(); + loading.set(true); + spawn(async move { match register(username_val, email_val, password_val).await { Ok(AuthResponse { success: true, .. }) => { @@ -47,8 +53,11 @@ pub fn Register() -> Element { error.set(Some(format!("请求失败: {}", e))); } } + loading.set(false); }); - }; + }); + + let is_loading = loading(); rsx! { div { class: "min-h-screen flex items-center justify-center bg-paper-theme", @@ -81,6 +90,7 @@ pub fn Register() -> Element { r#type: "text", placeholder: "3-50 位字符", value: username(), + disabled: is_loading, oninput: move |v: String| username.set(v), onkeydown: None, } @@ -91,6 +101,7 @@ pub fn Register() -> Element { r#type: "email", placeholder: "your@email.com", value: email(), + disabled: is_loading, oninput: move |v: String| email.set(v), onkeydown: None, } @@ -101,6 +112,7 @@ pub fn Register() -> Element { r#type: "password", placeholder: "至少 8 位", value: password(), + disabled: is_loading, oninput: move |v: String| password.set(v), onkeydown: None, } @@ -111,14 +123,17 @@ pub fn Register() -> Element { r#type: "password", placeholder: "再次输入密码", value: confirm_password(), + disabled: is_loading, oninput: move |v: String| confirm_password.set(v), onkeydown: None, } } button { class: "{BUTTON_PRIMARY_CLASS}", - onclick: on_submit, - "注册" + class: if is_loading { "opacity-60 cursor-not-allowed" }, + disabled: is_loading, + onclick: move |_| on_submit(()), + if is_loading { "注册中..." } else { "注册" } } } p { class: "mt-4 text-center text-sm text-paper-secondary",