From 969fbf8925be958b381194ec76ed98aca21e6734 Mon Sep 17 00:00:00 2001 From: messica Date: Tue, 19 May 2026 22:41:55 +0800 Subject: [PATCH 01/77] docs: add design spec + Plan 1 (Rust backend implementation) - specs/2026-05-19-rust-navigation-platform-design.md Architectural redesign: Rust (Axum + SQLite + tower-sessions) backend, SvelteKit SPA front, single Docker image. New design system, i18n, 3NF data model, edit mode UX. All open questions resolved per industry best practice. - plans/2026-05-19-plan-1-rust-backend.md 33-task TDD plan for the server/ crate end-to-end. Plans 2-5 (frontend foundation, read-only, edit mode, deploy) to follow after Plan 1 lands. --- .../plans/2026-05-19-plan-1-rust-backend.md | 4803 +++++++++++++++++ ...6-05-19-rust-navigation-platform-design.md | 791 +++ 2 files changed, 5594 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-19-plan-1-rust-backend.md create mode 100644 docs/superpowers/specs/2026-05-19-rust-navigation-platform-design.md diff --git a/docs/superpowers/plans/2026-05-19-plan-1-rust-backend.md b/docs/superpowers/plans/2026-05-19-plan-1-rust-backend.md new file mode 100644 index 0000000..77ef5c1 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-plan-1-rust-backend.md @@ -0,0 +1,4803 @@ +# Rust Backend Implementation Plan (Plan 1 of 5) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stand up the Rust backend (`server/` crate) end-to-end — from repo restructure through schema, repos, public read API, password-based auth, full CRUD, CLI password reset, and SPA fallback — so that `cargo run` produces a working API at `:8080` that the frontend (built later) can consume. + +**Architecture:** Single binary using Axum 0.7 + Tokio + SQLx (SQLite). 3NF schema migrated via `sqlx::migrate!`. Repository trait pattern (`NavRepo`, `ConfigRepo`) decouples handlers from SQL. Sessions stored in SQLite via `tower-sessions-sqlx-store`. Public read endpoint `/api/nav` returns the full bundle in one round-trip; writes are fine-grained behind a `RequireAuth` extractor. Static frontend bundle served by `tower_http::services::ServeDir` with SPA fallback. CLI subcommand for password reset (`navsrv reset-password`). Tests are integration-first using an in-memory SQLite per test. + +**Tech Stack:** Rust 1.79 · Axum 0.7 · Tokio 1 · SQLx 0.7 (sqlite + chrono + migrate) · tower-sessions 0.10 + sqlx-store · tower-governor 0.3 · tower-http 0.5 (compression + ServeDir + headers) · bcrypt 0.15 · clap 4 (derive) · figment 0.10 + dotenvy · serde 1 / serde_json 1 · validator 0.16 · thiserror 1 · anyhow 1 · tracing + tracing-subscriber · reqwest 0.12 (favicon proxy) · multer 3 (multipart) · rand 0.8 · chrono 0.4 + +**Spec reference:** `docs/superpowers/specs/2026-05-19-rust-navigation-platform-design.md` + +--- + +## Conventions + +- **Working directory** for every `cargo` command: `server/`. Every `pnpm` command: `web/`. +- **Branch / commit style:** Conventional Commits. Each task ends with one commit; commit subject ≤ 72 chars; body explains *why*. No `Co-Authored-By` trailer. +- **Test layout:** `cargo test` only — unit tests beside source via `#[cfg(test)] mod tests`, integration tests under `server/tests/`. +- **Error policy:** All fallible code returns `Result`. Handlers `?` propagate; `AppError: IntoResponse` produces JSON `{ "error": "...", "message": "..."? }`. +- **No `unwrap` / `expect`** in non-test code, except at well-justified panics (e.g. router builder constants). +- **Time:** epoch milliseconds (`i64`) at the storage boundary; `chrono::DateTime` only when serializing. + +--- + +## Phase 0: Repository Restructure (Tasks 1–4) + +Goal: get the existing SvelteKit project into `web/` so the new `server/` can sit beside it cleanly. Frontend must still `pnpm dev` after the move. + +### Task 1: Snapshot pre-move state + +**Files:** +- Read-only: `package.json`, `svelte.config.js`, `vite.config.ts`, `tsconfig.json`, `playwright.config.js`, `src/`, `static/` + +- [ ] **Step 1: Verify clean working tree** + +```bash +git status +``` + +Expected: `nothing to commit, working tree clean`. If not, stop and resolve. + +- [ ] **Step 2: Confirm current dev server still runs (baseline)** + +```bash +pnpm install --frozen-lockfile +pnpm dev --host 127.0.0.1 --port 5173 & +sleep 4 +curl -sf http://127.0.0.1:5173 | head -c 200 +kill %1 +``` + +Expected: HTML containing `` of the existing site. + +- [ ] **Step 3: Record the file list that will move** + +```bash +git ls-files | grep -E '^(src/|static/|package\.json$|pnpm-lock\.yaml$|svelte\.config\.js$|vite\.config\.ts$|tsconfig\.json$|playwright\.config\.js$|\.eslintrc\.cjs$|\.eslintignore$|\.prettierrc$|\.prettierignore$|\.npmrc$)' > /tmp/web-files.txt +wc -l /tmp/web-files.txt +``` + +Expected: a numeric count > 10. This list drives Task 2. + +### Task 2: Move frontend into `web/` + +**Files:** +- Create dir: `web/` +- Move: every path in `/tmp/web-files.txt` → `web/<same-relative-path>` + +- [ ] **Step 1: Create the target directory** + +```bash +mkdir -p web +``` + +- [ ] **Step 2: `git mv` each tracked frontend path** + +```bash +while read -r path; do + mkdir -p "web/$(dirname "$path")" + git mv "$path" "web/$path" +done < /tmp/web-files.txt +``` + +Expected: no errors. Run `git status` — only `R` (renamed) entries. + +- [ ] **Step 3: Move untracked but project-relevant files** + +```bash +[ -d node_modules ] && rm -rf node_modules +[ -d build ] && rm -rf build +[ -d .svelte-kit ] && rm -rf .svelte-kit +[ -f .eslintignore ] && git mv .eslintignore web/.eslintignore 2>/dev/null || true +``` + +(The `git mv` calls above already handled tracked dotfiles; this step just sweeps build artifacts.) + +- [ ] **Step 4: Verify root has no leftover frontend files** + +```bash +ls -la +``` + +Expected: only `web/`, `config/` (existing nginx), `Dockerfile` (will be replaced), `README.md`, `.git`, `.gitignore`, `.dockerignore`, `.editorconfig`, `snapshot_*.png`, `docs/`, `.github/`. No `src/`, no `package.json`, no `node_modules`. + +- [ ] **Step 5: Commit the move (rename-only commit)** + +```bash +git add -A +git commit -m "$(cat <<'EOF' +chore: move SvelteKit project into web/ subdirectory + +Prepares for split-repo layout (web/ + server/) per spec §9.3. +Pure rename — no logic changes. +EOF +)" +``` + +### Task 3: Adjust `web/vite.config.ts` to proxy `/api` to backend + +**Files:** +- Modify: `web/vite.config.ts` + +- [ ] **Step 1: Read current config** + +```bash +cat web/vite.config.ts +``` + +- [ ] **Step 2: Replace contents** + +```typescript +// web/vite.config.ts +import { sveltekit } from '@sveltejs/kit/vite'; +import type { UserConfig } from 'vite'; + +const config: UserConfig = { + plugins: [sveltekit()], + server: { + port: 5173, + strictPort: true, + proxy: { + '/api': { + target: 'http://127.0.0.1:8080', + changeOrigin: false, + }, + }, + }, +}; + +export default config; +``` + +- [ ] **Step 3: Verify dev server still boots** + +```bash +cd web +pnpm install --frozen-lockfile +pnpm dev --port 5173 & +sleep 4 +curl -sf http://127.0.0.1:5173/ | head -c 200 +curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5173/api/nav +kill %1 +cd .. +``` + +Expected: HTML on `/`. `/api/nav` returns either `502` (no backend yet) or connection refused — both prove the proxy is active. Anything 2xx would mean the proxy is wrong. + +- [ ] **Step 4: Commit** + +```bash +git add web/vite.config.ts +git commit -m "$(cat <<'EOF' +chore(web): proxy /api → 127.0.0.1:8080 in vite dev server + +Backend will listen on :8080 (Plan 1, Phase 1). Same-origin in +production via ServeDir; this proxy only affects dev mode. +EOF +)" +``` + +### Task 4: Update root `.gitignore` and `.dockerignore` for the new layout + +**Files:** +- Modify: `.gitignore`, `.dockerignore` + +- [ ] **Step 1: Update `.gitignore`** + +Replace contents with: + +```gitignore +# OS +.DS_Store +Thumbs.db + +# Editors +.vscode/ +.idea/ + +# Frontend +web/node_modules/ +web/.svelte-kit/ +web/build/ +web/.env +web/.env.* +!web/.env.example + +# Backend +server/target/ +server/.env +server/.env.* +!server/.env.example +server/dev-data/ + +# Local volumes / runtime +data/ +*.log +*.pid + +# Worktrees (per ~/.claude/rules/worktree-location.md) +.worktrees/ + +# Tests +test-results/ +playwright-report/ +``` + +- [ ] **Step 2: Update `.dockerignore`** + +``` +# Build artifacts +**/node_modules +**/.svelte-kit +**/build +**/target + +# Local data / env +**/.env +**/.env.* +!**/.env.example +**/dev-data +data/ + +# Git / dev +.git +.gitignore +.github +.worktrees +.vscode +.idea +docs +tests +*.md +!README.md +snapshot_*.png +``` + +- [ ] **Step 3: Commit** + +```bash +git add .gitignore .dockerignore +git commit -m "chore: ignore web/ + server/ artefacts and local data dir" +``` + +--- + +## Phase 1: Backend Scaffold (Tasks 5–9) + +Goal: a `server/` crate that compiles, runs, and exposes `GET /api/health`. Lays down `AppError`, `Settings`, and integration-test infrastructure. + +### Task 5: Create `server/Cargo.toml` + +**Files:** +- Create: `server/Cargo.toml` +- Create: `server/.gitignore` +- Create: `server/rust-toolchain.toml` + +- [ ] **Step 1: Make the crate directory** + +```bash +mkdir -p server/src server/tests server/migrations +``` + +- [ ] **Step 2: Write `server/Cargo.toml`** + +```toml +[package] +name = "navsrv" +version = "0.1.0" +edition = "2021" +rust-version = "1.79" +default-run = "navsrv" + +[[bin]] +name = "navsrv" +path = "src/main.rs" + +[dependencies] +axum = { version = "0.7", features = ["macros", "multipart"] } +tokio = { version = "1", features = ["full"] } +tower = "0.4" +tower-http = { version = "0.5", features = ["fs", "trace", "compression-gzip", "set-header"] } +tower-sessions = "0.10" +tower-sessions-sqlx-store = { version = "0.10", features = ["sqlite"] } +tower_governor = "0.3" + +sqlx = { version = "0.7", default-features = false, features = ["runtime-tokio-rustls", "sqlite", "chrono", "macros", "migrate"] } + +serde = { version = "1", features = ["derive"] } +serde_json = "1" +validator = { version = "0.16", features = ["derive"] } + +bcrypt = "0.15" +rand = "0.8" + +clap = { version = "4", features = ["derive"] } +figment = { version = "0.10", features = ["toml", "env"] } +dotenvy = "0.15" + +thiserror = "1" +anyhow = "1" + +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } + +chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } + +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +mime = "0.3" +multer = "3" +bytes = "1" +async-trait = "0.1" + +[dev-dependencies] +axum-test = "15" +tempfile = "3" +serde_json = "1" + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = true +``` + +- [ ] **Step 3: Write `server/rust-toolchain.toml`** + +```toml +[toolchain] +channel = "1.79" +components = ["rustfmt", "clippy"] +profile = "minimal" +``` + +- [ ] **Step 4: Write `server/.gitignore`** + +```gitignore +target/ +dev-data/ +.env +.env.* +!.env.example +*.log +``` + +- [ ] **Step 5: Stub a no-op `src/main.rs` so the crate compiles** + +```rust +// server/src/main.rs +fn main() {} +``` + +- [ ] **Step 6: Verify it builds (cold compile is slow; that's expected)** + +```bash +cd server +cargo build --quiet +cd .. +``` + +Expected: compiles without errors. + +- [ ] **Step 7: Commit** + +```bash +git add server/Cargo.toml server/Cargo.lock server/.gitignore server/rust-toolchain.toml server/src/main.rs +git commit -m "feat(server): scaffold navsrv crate with locked toolchain and deps" +``` + +### Task 6: `AppError` + `IntoResponse` + +**Files:** +- Create: `server/src/error.rs` +- Create: `server/src/lib.rs` +- Modify: `server/src/main.rs` + +- [ ] **Step 1: Write a failing test** + +Create `server/src/error.rs`: + +```rust +//! Unified application error type. + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::json; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AppError { + #[error("not found")] + NotFound, + + #[error("unauthenticated")] + Unauthenticated, + + #[error("forbidden")] + Forbidden, + + #[error("validation failed: {0}")] + Validation(String), + + #[error("conflict: {0}")] + Conflict(String), + + #[error("rate limited")] + RateLimited, + + #[error(transparent)] + Sqlx(#[from] sqlx::Error), + + #[error(transparent)] + Bcrypt(#[from] bcrypt::BcryptError), + + #[error(transparent)] + Io(#[from] std::io::Error), + + #[error(transparent)] + Json(#[from] serde_json::Error), + + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +impl AppError { + pub fn status(&self) -> StatusCode { + match self { + AppError::NotFound => StatusCode::NOT_FOUND, + AppError::Unauthenticated => StatusCode::UNAUTHORIZED, + AppError::Forbidden => StatusCode::FORBIDDEN, + AppError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY, + AppError::Conflict(_) => StatusCode::CONFLICT, + AppError::RateLimited => StatusCode::TOO_MANY_REQUESTS, + AppError::Sqlx(sqlx::Error::RowNotFound) => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn code(&self) -> &'static str { + match self { + AppError::NotFound => "not_found", + AppError::Unauthenticated => "unauthenticated", + AppError::Forbidden => "forbidden", + AppError::Validation(_) => "validation_failed", + AppError::Conflict(_) => "conflict", + AppError::RateLimited => "rate_limited", + _ => "internal", + } + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let status = self.status(); + let code = self.code(); + // Internal errors must not leak details to clients. + let message = match &self { + AppError::Validation(m) | AppError::Conflict(m) => Some(m.clone()), + AppError::NotFound + | AppError::Unauthenticated + | AppError::Forbidden + | AppError::RateLimited => None, + other => { + tracing::error!(error = %other, "internal error"); + None + } + }; + let body = match message { + Some(m) => json!({ "error": code, "message": m }), + None => json!({ "error": code }), + }; + (status, Json(body)).into_response() + } +} + +pub type Result<T> = std::result::Result<T, AppError>; + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::StatusCode; + + #[test] + fn validation_error_is_422_with_message() { + let err = AppError::Validation("name too long".into()); + assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY); + let resp = err.into_response(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); + } + + #[test] + fn not_found_is_404_no_message() { + let err = AppError::NotFound; + assert_eq!(err.status(), StatusCode::NOT_FOUND); + } + + #[test] + fn sqlx_row_not_found_maps_to_404() { + let err: AppError = sqlx::Error::RowNotFound.into(); + assert_eq!(err.status(), StatusCode::NOT_FOUND); + } +} +``` + +- [ ] **Step 2: Add a library entry so tests can find it** + +Create `server/src/lib.rs`: + +```rust +//! navsrv internal library — exposed for integration tests. +pub mod error; +``` + +- [ ] **Step 3: Update `main.rs` to use the lib** + +```rust +// server/src/main.rs +fn main() {} +``` + +(unchanged for now; we just need the library exposed.) + +- [ ] **Step 4: Add `[lib]` to `Cargo.toml`** (insert after `[[bin]]`) + +```toml +[lib] +name = "navsrv" +path = "src/lib.rs" +``` + +- [ ] **Step 5: Run tests** + +```bash +cd server +cargo test --lib error::tests +cd .. +``` + +Expected: 3 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add server/Cargo.toml server/src/lib.rs server/src/error.rs +git commit -m "feat(server): AppError with IntoResponse and unit tests" +``` + +### Task 7: `Settings` from env (figment + dotenvy) + +**Files:** +- Create: `server/src/config.rs` +- Create: `server/.env.example` +- Modify: `server/src/lib.rs` + +- [ ] **Step 1: Define `Settings`** + +Create `server/src/config.rs`: + +```rust +//! Runtime configuration loaded from env (with optional .env). + +use figment::providers::Env; +use figment::Figment; +use serde::Deserialize; +use std::path::PathBuf; + +#[derive(Debug, Clone, Deserialize)] +pub struct Settings { + /// TCP port to listen on. + #[serde(default = "default_port")] + pub port: u16, + /// Directory holding `data.db`, `INITIAL_PASSWORD.txt`, and the icon cache. + #[serde(default = "default_data_dir")] + pub data_dir: PathBuf, + /// Directory holding the SvelteKit build output (HTML/JS/CSS). + #[serde(default = "default_static_dir")] + pub static_dir: PathBuf, + /// Initial admin password, if provided. When None, a random one is generated on first boot. + pub bootstrap_admin_password: Option<String>, + /// Set true on prod deployments to require Secure cookies. + #[serde(default)] + pub secure_cookies: bool, + /// `tracing-subscriber` env filter, e.g. "info,sqlx=warn". + #[serde(default = "default_log")] + pub rust_log: String, +} + +fn default_port() -> u16 { 8080 } +fn default_data_dir() -> PathBuf { PathBuf::from("./dev-data") } +fn default_static_dir() -> PathBuf { PathBuf::from("../web/build") } +fn default_log() -> String { "info,sqlx=warn,tower_http=info".into() } + +impl Settings { + pub fn load() -> anyhow::Result<Self> { + // Best-effort .env loading; ignore if missing. + let _ = dotenvy::dotenv(); + let s: Settings = Figment::new() + .merge(Env::raw().split("__")) + .extract()?; + std::fs::create_dir_all(&s.data_dir)?; + Ok(s) + } + + pub fn db_url(&self) -> String { + let path = self.data_dir.join("data.db"); + format!("sqlite://{}?mode=rwc", path.display()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_are_sane() { + // SAFETY: this is single-threaded test code; std::env safe in tests. + std::env::remove_var("PORT"); + std::env::remove_var("DATA_DIR"); + std::env::remove_var("STATIC_DIR"); + std::env::remove_var("BOOTSTRAP_ADMIN_PASSWORD"); + std::env::remove_var("SECURE_COOKIES"); + std::env::remove_var("RUST_LOG"); + let s = Settings::load().unwrap(); + assert_eq!(s.port, 8080); + assert!(s.data_dir.ends_with("dev-data")); + assert!(!s.secure_cookies); + assert!(s.bootstrap_admin_password.is_none()); + } + + #[test] + fn db_url_uses_data_dir() { + let s = Settings { + port: 8080, + data_dir: PathBuf::from("/tmp/x"), + static_dir: PathBuf::from("/tmp/static"), + bootstrap_admin_password: None, + secure_cookies: false, + rust_log: "info".into(), + }; + assert_eq!(s.db_url(), "sqlite:///tmp/x/data.db?mode=rwc"); + } +} +``` + +- [ ] **Step 2: Write `server/.env.example`** + +```env +# Server +PORT=8080 +DATA_DIR=./dev-data +STATIC_DIR=../web/build +RUST_LOG=info,sqlx=warn,tower_http=info + +# Auth +# Set on first boot only. Leave empty to have one generated and written +# to ${DATA_DIR}/INITIAL_PASSWORD.txt. +BOOTSTRAP_ADMIN_PASSWORD= + +# Security +# Set true behind HTTPS terminator. Cookies become Secure-only. +SECURE_COOKIES=false +``` + +- [ ] **Step 3: Expose module from `lib.rs`** + +```rust +// server/src/lib.rs +pub mod config; +pub mod error; +``` + +- [ ] **Step 4: Run tests** + +```bash +cd server +cargo test --lib config::tests +cd .. +``` + +Expected: 2 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add server/src/config.rs server/src/lib.rs server/.env.example +git commit -m "feat(server): Settings loader from env with safe defaults" +``` + +### Task 8: Axum app with `/api/health` + +**Files:** +- Create: `server/src/app.rs` +- Create: `server/src/routes/mod.rs` +- Create: `server/src/routes/health.rs` +- Modify: `server/src/lib.rs`, `server/src/main.rs` +- Create: `server/tests/health.rs` + +- [ ] **Step 1: Write the failing integration test first** + +Create `server/tests/health.rs`: + +```rust +use axum_test::TestServer; +use navsrv::app::build_app_for_tests; + +#[tokio::test] +async fn health_returns_ok_json() { + let app = build_app_for_tests().await.expect("app builds"); + let server = TestServer::new(app).expect("server"); + let res = server.get("/api/health").await; + res.assert_status_ok(); + res.assert_json(&serde_json::json!({ "status": "ok" })); +} +``` + +- [ ] **Step 2: Run the test — expect compile error (no `app` module yet)** + +```bash +cd server +cargo test --test health -- --nocapture +``` + +Expected: error referencing missing `navsrv::app`. + +- [ ] **Step 3: Write `routes/health.rs`** + +```rust +// server/src/routes/health.rs +use axum::{routing::get, Json, Router}; +use serde_json::{json, Value}; + +pub fn router() -> Router { + Router::new().route("/health", get(health)) +} + +async fn health() -> Json<Value> { + Json(json!({ "status": "ok" })) +} +``` + +- [ ] **Step 4: Write `routes/mod.rs`** + +```rust +// server/src/routes/mod.rs +use axum::Router; + +pub mod health; + +pub fn api() -> Router { + Router::new().nest("/api", Router::new().merge(health::router())) +} +``` + +- [ ] **Step 5: Write `app.rs`** + +```rust +// server/src/app.rs +use axum::Router; +use tower_http::compression::CompressionLayer; +use tower_http::set_header::SetResponseHeaderLayer; +use axum::http::{header, HeaderValue}; + +use crate::routes; + +/// Build the full HTTP application (without listener). +pub fn build_app() -> Router { + Router::new() + .merge(routes::api()) + .layer(CompressionLayer::new()) + .layer(SetResponseHeaderLayer::if_not_present( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + header::REFERRER_POLICY, + HeaderValue::from_static("same-origin"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + header::X_FRAME_OPTIONS, + HeaderValue::from_static("DENY"), + )) +} + +/// Test variant: no DB / sessions yet — just the bare router. +pub async fn build_app_for_tests() -> anyhow::Result<Router> { + Ok(build_app()) +} +``` + +- [ ] **Step 6: Update `lib.rs`** + +```rust +// server/src/lib.rs +pub mod app; +pub mod config; +pub mod error; +pub mod routes; +``` + +- [ ] **Step 7: Update `main.rs`** + +```rust +// server/src/main.rs +use anyhow::Context; +use navsrv::{app::build_app, config::Settings}; +use std::net::SocketAddr; +use tokio::net::TcpListener; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let settings = Settings::load().context("load settings")?; + init_tracing(&settings.rust_log); + + let app = build_app(); + let addr = SocketAddr::from(([0, 0, 0, 0], settings.port)); + let listener = TcpListener::bind(addr).await.context("bind")?; + tracing::info!(%addr, "navsrv listening"); + axum::serve(listener, app).await.context("serve")?; + Ok(()) +} + +fn init_tracing(filter: &str) { + use tracing_subscriber::{fmt, EnvFilter}; + let env = EnvFilter::try_new(filter).unwrap_or_else(|_| EnvFilter::new("info")); + fmt().with_env_filter(env).with_target(false).compact().init(); +} +``` + +- [ ] **Step 8: Run the integration test** + +```bash +cd server +cargo test --test health +cd .. +``` + +Expected: PASS. + +- [ ] **Step 9: Smoke-run the binary** + +```bash +cd server +PORT=8081 cargo run & +SERVER_PID=$! +sleep 2 +curl -sf http://127.0.0.1:8081/api/health +kill $SERVER_PID +cd .. +``` + +Expected: `{"status":"ok"}`. + +- [ ] **Step 10: Commit** + +```bash +git add server/src/app.rs server/src/lib.rs server/src/main.rs server/src/routes server/tests/health.rs +git commit -m "feat(server): axum app with /api/health and security headers" +``` + +### Task 9: Tracing-aware request layer + +**Files:** +- Modify: `server/src/app.rs` + +- [ ] **Step 1: Add a `TraceLayer` so each request gets logged** + +Update `build_app()`: + +```rust +// server/src/app.rs (replace prior contents) +use axum::http::{header, HeaderValue, Request}; +use axum::Router; +use tower_http::compression::CompressionLayer; +use tower_http::set_header::SetResponseHeaderLayer; +use tower_http::trace::{DefaultOnResponse, TraceLayer}; +use tracing::Level; + +use crate::routes; + +pub fn build_app() -> Router { + let trace = TraceLayer::new_for_http() + .make_span_with(|req: &Request<_>| { + let method = req.method(); + let uri = req.uri().path(); + tracing::info_span!("http", %method, %uri) + }) + .on_response(DefaultOnResponse::new().level(Level::INFO)); + + Router::new() + .merge(routes::api()) + .layer(trace) + .layer(CompressionLayer::new()) + .layer(SetResponseHeaderLayer::if_not_present( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + header::REFERRER_POLICY, + HeaderValue::from_static("same-origin"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + header::X_FRAME_OPTIONS, + HeaderValue::from_static("DENY"), + )) +} + +pub async fn build_app_for_tests() -> anyhow::Result<Router> { + Ok(build_app()) +} +``` + +- [ ] **Step 2: Re-run tests** + +```bash +cd server +cargo test +cd .. +``` + +Expected: all green. + +- [ ] **Step 3: Commit** + +```bash +git add server/src/app.rs +git commit -m "feat(server): structured request logging via TraceLayer" +``` + +--- + +## Phase 2: Data Layer (Tasks 10–19) + +Goal: SQLite pool with WAL pragmas, migration `0001_init.sql`, `NavRepo` + `ConfigRepo` traits with `sqlx` impls, all unit-tested against in-memory SQLite. + +### Task 10: SQLite pool helper + +**Files:** +- Create: `server/src/db.rs` +- Modify: `server/src/lib.rs` + +- [ ] **Step 1: Write tests first** + +Create `server/src/db.rs`: + +```rust +//! SQLite connection pool with safe defaults (WAL, foreign keys, busy timeout). + +use sqlx::sqlite::{SqlitePoolOptions, SqliteConnectOptions, SqliteJournalMode, SqliteSynchronous}; +use sqlx::{ConnectOptions, SqlitePool}; +use std::str::FromStr; +use std::time::Duration; + +/// Build a pool against the given URL, applying recommended pragmas. +pub async fn connect(url: &str) -> sqlx::Result<SqlitePool> { + let opts = SqliteConnectOptions::from_str(url)? + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Wal) + .synchronous(SqliteSynchronous::Normal) + .foreign_keys(true) + .busy_timeout(Duration::from_secs(5)) + .log_statements(tracing::log::LevelFilter::Debug); + + SqlitePoolOptions::new() + .max_connections(5) + .acquire_timeout(Duration::from_secs(5)) + .connect_with(opts) + .await +} + +/// Run all bundled migrations against the pool. +pub async fn migrate(pool: &SqlitePool) -> sqlx::Result<()> { + sqlx::migrate!("./migrations").run(pool).await?; + Ok(()) +} + +#[cfg(test)] +pub async fn connect_in_memory() -> sqlx::Result<SqlitePool> { + let pool = connect("sqlite::memory:").await?; + migrate(&pool).await?; + Ok(pool) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn pool_runs_pragmas() { + let pool = connect("sqlite::memory:").await.unwrap(); + let row: (String,) = sqlx::query_as("PRAGMA journal_mode") + .fetch_one(&pool) + .await + .unwrap(); + // SQLite returns "memory" for in-memory DBs even after WAL request, so just sanity-check connectivity. + assert!(!row.0.is_empty()); + let row: (i64,) = sqlx::query_as("PRAGMA foreign_keys") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(row.0, 1); + } +} +``` + +- [ ] **Step 2: Re-export from lib** + +```rust +// server/src/lib.rs +pub mod app; +pub mod config; +pub mod db; +pub mod error; +pub mod routes; +``` + +- [ ] **Step 3: Run tests (the migrate test will fail until Task 11 — skip for now)** + +```bash +cd server +cargo test --lib db::tests::pool_runs_pragmas +cd .. +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add server/src/db.rs server/src/lib.rs +git commit -m "feat(server): SQLite pool with WAL/foreign-keys/busy-timeout" +``` + +### Task 11: Migration `0001_init.sql` + +**Files:** +- Create: `server/migrations/0001_init.sql` + +- [ ] **Step 1: Write the migration** + +```sql +-- server/migrations/0001_init.sql + +PRAGMA foreign_keys = ON; + +CREATE TABLE sites ( + id INTEGER PRIMARY KEY, + value TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + name_i18n TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + is_default INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE groups ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + name_i18n TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + collapsed_default INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE items ( + id INTEGER PRIMARY KEY, + group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL, + name TEXT NOT NULL, + name_i18n TEXT, + description TEXT, + description_i18n TEXT, + icon_kind TEXT NOT NULL CHECK (icon_kind IN ('asset','url','auto-favicon')), + icon_value TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE item_links ( + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE, + url TEXT NOT NULL, + PRIMARY KEY (item_id, site_id) +); + +CREATE TABLE tags ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + name_i18n TEXT +); + +CREATE TABLE item_tags ( + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (item_id, tag_id) +); + +CREATE TABLE config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE INDEX idx_items_group_sort ON items(group_id, sort_order); +CREATE INDEX idx_item_links_site ON item_links(site_id); +CREATE INDEX idx_item_tags_tag ON item_tags(tag_id); + +INSERT INTO config (key, value) VALUES ('schema_version', '1'); +``` + +- [ ] **Step 2: Add a migrate test in `db.rs`** + +Append to the `tests` module: + +```rust + #[tokio::test] + async fn migrate_creates_tables() { + let pool = connect_in_memory().await.unwrap(); + let row: (i64,) = sqlx::query_as( + "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='items'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(row.0, 1); + } +``` + +- [ ] **Step 3: Run** + +```bash +cd server +cargo test --lib db::tests +cd .. +``` + +Expected: 2 PASS. + +- [ ] **Step 4: Commit** + +```bash +git add server/migrations/0001_init.sql server/src/db.rs +git commit -m "feat(server): initial schema migration (sites/groups/items/links/tags/config)" +``` + +### Task 12: `NavRepo` trait and DTOs + +**Files:** +- Create: `server/src/repo/mod.rs` +- Create: `server/src/repo/nav.rs` +- Create: `server/src/dto.rs` +- Modify: `server/src/lib.rs` + +- [ ] **Step 1: Define DTOs** + +```rust +// server/src/dto.rs +use serde::{Deserialize, Serialize}; + +fn empty() -> String { String::new() } +fn zero() -> i64 { 0 } + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Site { + pub id: i64, + pub value: String, + pub name: String, + pub name_i18n: Option<serde_json::Value>, + pub sort_order: i64, + pub is_default: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Group { + pub id: i64, + pub slug: String, + pub name: String, + pub name_i18n: Option<serde_json::Value>, + pub sort_order: i64, + pub collapsed_default: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Tag { + pub id: i64, + pub slug: String, + pub name: String, + pub name_i18n: Option<serde_json::Value>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum IconKind { Asset, Url, AutoFavicon } + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Item { + pub id: i64, + pub group_id: Option<i64>, + pub name: String, + pub name_i18n: Option<serde_json::Value>, + pub description: Option<String>, + pub description_i18n: Option<serde_json::Value>, + pub icon_kind: IconKind, + pub icon_value: String, + pub sort_order: i64, + pub links: std::collections::BTreeMap<String, String>, // site.value -> URL + pub tag_slugs: Vec<String>, + #[serde(default = "zero")] + pub created_at: i64, + #[serde(default = "zero")] + pub updated_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Meta { + pub site_name: String, + pub site_avatar_path: Option<String>, + pub site_copyright: String, + pub site_icp: Option<Link>, + pub site_police: Option<Link>, + pub default_theme: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Link { pub text: String, pub url: String } + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct NavBundle { + pub schema_version: i64, + pub meta: Meta, + pub sites: Vec<Site>, + pub groups: Vec<Group>, + pub items: Vec<Item>, + pub tags: Vec<Tag>, +} + +// ----- Write payloads ----- + +#[derive(Debug, Deserialize)] +pub struct ItemPayload { + pub group_id: Option<i64>, + pub name: String, + #[serde(default)] pub name_i18n: Option<serde_json::Value>, + #[serde(default)] pub description: Option<String>, + #[serde(default)] pub description_i18n: Option<serde_json::Value>, + pub icon_kind: IconKind, + pub icon_value: String, + #[serde(default)] pub links: std::collections::BTreeMap<String, String>, + #[serde(default)] pub tag_slugs: Vec<String>, +} + +#[derive(Debug, Deserialize, Default)] +pub struct ItemPatch { + #[serde(default)] pub group_id: Option<Option<i64>>, + #[serde(default)] pub name: Option<String>, + #[serde(default)] pub name_i18n: Option<Option<serde_json::Value>>, + #[serde(default)] pub description: Option<Option<String>>, + #[serde(default)] pub description_i18n: Option<Option<serde_json::Value>>, + #[serde(default)] pub icon_kind: Option<IconKind>, + #[serde(default)] pub icon_value: Option<String>, + #[serde(default)] pub links: Option<std::collections::BTreeMap<String, String>>, + #[serde(default)] pub tag_slugs: Option<Vec<String>>, +} + +#[derive(Debug, Deserialize)] +pub struct ReorderEntry { pub id: i64, pub sort_order: i64, #[serde(default)] pub group_id: Option<Option<i64>> } + +#[derive(Debug, Deserialize)] +pub struct GroupPayload { + pub slug: String, + pub name: String, + #[serde(default)] pub name_i18n: Option<serde_json::Value>, + #[serde(default)] pub collapsed_default: bool, +} + +#[derive(Debug, Deserialize, Default)] +pub struct GroupPatch { + #[serde(default)] pub slug: Option<String>, + #[serde(default)] pub name: Option<String>, + #[serde(default)] pub name_i18n: Option<Option<serde_json::Value>>, + #[serde(default)] pub collapsed_default: Option<bool>, +} + +#[derive(Debug, Deserialize)] +pub struct SitePayload { + pub value: String, + pub name: String, + #[serde(default)] pub name_i18n: Option<serde_json::Value>, + #[serde(default)] pub is_default: bool, +} + +#[derive(Debug, Deserialize, Default)] +pub struct SitePatch { + #[serde(default)] pub value: Option<String>, + #[serde(default)] pub name: Option<String>, + #[serde(default)] pub name_i18n: Option<Option<serde_json::Value>>, + #[serde(default)] pub is_default: Option<bool>, +} + +#[derive(Debug, Deserialize)] +pub struct TagPayload { + pub slug: String, + pub name: String, + #[serde(default)] pub name_i18n: Option<serde_json::Value>, +} + +#[derive(Debug, Deserialize, Default)] +pub struct TagPatch { + #[serde(default)] pub slug: Option<String>, + #[serde(default)] pub name: Option<String>, + #[serde(default)] pub name_i18n: Option<Option<serde_json::Value>>, +} +``` + +- [ ] **Step 2: Write the trait** + +```rust +// server/src/repo/nav.rs +use crate::dto::*; +use crate::error::Result; +use async_trait::async_trait; + +#[async_trait] +pub trait NavRepo: Send + Sync { + // ----- Bundle ----- + async fn get_bundle(&self) -> Result<(Vec<Site>, Vec<Group>, Vec<Item>, Vec<Tag>)>; + + // ----- Sites ----- + async fn list_sites(&self) -> Result<Vec<Site>>; + async fn create_site(&self, p: SitePayload) -> Result<Site>; + async fn patch_site(&self, id: i64, p: SitePatch) -> Result<Site>; + async fn delete_site(&self, id: i64) -> Result<()>; + async fn reorder_sites(&self, entries: Vec<ReorderEntry>) -> Result<()>; + + // ----- Groups ----- + async fn list_groups(&self) -> Result<Vec<Group>>; + async fn create_group(&self, p: GroupPayload) -> Result<Group>; + async fn patch_group(&self, id: i64, p: GroupPatch) -> Result<Group>; + async fn delete_group(&self, id: i64) -> Result<()>; + async fn reorder_groups(&self, entries: Vec<ReorderEntry>) -> Result<()>; + + // ----- Items ----- + async fn create_item(&self, p: ItemPayload) -> Result<Item>; + async fn patch_item(&self, id: i64, p: ItemPatch) -> Result<Item>; + async fn delete_item(&self, id: i64) -> Result<()>; + async fn reorder_items(&self, entries: Vec<ReorderEntry>) -> Result<()>; + + // ----- Tags ----- + async fn list_tags(&self) -> Result<Vec<Tag>>; + async fn create_tag(&self, p: TagPayload) -> Result<Tag>; + async fn patch_tag(&self, id: i64, p: TagPatch) -> Result<Tag>; + async fn delete_tag(&self, id: i64) -> Result<()>; +} +``` + +- [ ] **Step 3: Re-export modules** + +```rust +// server/src/repo/mod.rs +pub mod nav; +pub use nav::NavRepo; +``` + +```rust +// server/src/lib.rs +pub mod app; +pub mod config; +pub mod db; +pub mod dto; +pub mod error; +pub mod repo; +pub mod routes; +``` + +- [ ] **Step 4: Compile-check** + +```bash +cd server +cargo check --lib +cd .. +``` + +Expected: clean. + +- [ ] **Step 5: Commit** + +```bash +git add server/src/repo server/src/dto.rs server/src/lib.rs +git commit -m "feat(server): NavRepo trait and DTOs (read + write payloads)" +``` + +### Task 13: `SqlxNavRepo` — sites + groups + tags + +**Files:** +- Create: `server/src/repo/sqlx_impl.rs` +- Modify: `server/src/repo/mod.rs` +- Create: `server/tests/repo_sites.rs` + +- [ ] **Step 1: Write the sites integration test first** + +```rust +// server/tests/repo_sites.rs +use navsrv::db::connect_in_memory; +use navsrv::dto::{SitePatch, SitePayload}; +use navsrv::repo::{NavRepo, SqlxNavRepo}; + +#[tokio::test] +async fn create_list_patch_delete_site() { + let pool = connect_in_memory().await.unwrap(); + let repo = SqlxNavRepo::new(pool); + + let s = repo.create_site(SitePayload { + value: "shangHai".into(), + name: "上海".into(), + name_i18n: Some(serde_json::json!({"en":"Shanghai"})), + is_default: true, + }).await.unwrap(); + assert_eq!(s.value, "shangHai"); + assert!(s.is_default); + + let s2 = repo.create_site(SitePayload { + value: "beiJing".into(), name: "北京".into(), name_i18n: None, is_default: false, + }).await.unwrap(); + + let all = repo.list_sites().await.unwrap(); + assert_eq!(all.len(), 2); + + let patched = repo.patch_site(s2.id, SitePatch { + name: Some("Beijing".into()), ..Default::default() + }).await.unwrap(); + assert_eq!(patched.name, "Beijing"); + + repo.delete_site(s.id).await.unwrap(); + assert_eq!(repo.list_sites().await.unwrap().len(), 1); +} + +#[tokio::test] +async fn unique_value_constraint_returns_conflict() { + let pool = connect_in_memory().await.unwrap(); + let repo = SqlxNavRepo::new(pool); + repo.create_site(SitePayload { + value: "x".into(), name: "X".into(), name_i18n: None, is_default: false, + }).await.unwrap(); + let err = repo.create_site(SitePayload { + value: "x".into(), name: "Y".into(), name_i18n: None, is_default: false, + }).await.unwrap_err(); + use navsrv::error::AppError; + assert!(matches!(err, AppError::Conflict(_)), "got {err:?}"); +} +``` + +- [ ] **Step 2: Stub the impl so the test compiles** + +```rust +// server/src/repo/sqlx_impl.rs +use crate::dto::*; +use crate::error::{AppError, Result}; +use crate::repo::nav::NavRepo; +use async_trait::async_trait; +use sqlx::SqlitePool; + +pub struct SqlxNavRepo { pool: SqlitePool } + +impl SqlxNavRepo { + pub fn new(pool: SqlitePool) -> Self { Self { pool } } +} + +fn now_ms() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0) +} + +fn map_unique_violation(err: sqlx::Error) -> AppError { + if let Some(db_err) = err.as_database_error() { + // SQLite returns "UNIQUE constraint failed: ..." in the error message. + let msg = db_err.message(); + if msg.contains("UNIQUE constraint failed") { + return AppError::Conflict(msg.to_string()); + } + } + AppError::Sqlx(err) +} + +fn json_opt(v: Option<sqlx::types::JsonValue>) -> Option<serde_json::Value> { v.map(|x| x) } + +#[async_trait] +impl NavRepo for SqlxNavRepo { + async fn get_bundle(&self) -> Result<(Vec<Site>, Vec<Group>, Vec<Item>, Vec<Tag>)> { + let sites = self.list_sites().await?; + let groups = self.list_groups().await?; + let tags = self.list_tags().await?; + let items = self.list_items_full().await?; + Ok((sites, groups, items, tags)) + } + + async fn list_sites(&self) -> Result<Vec<Site>> { + let rows = sqlx::query!( + r#"SELECT id as "id!: i64", value, name, + name_i18n as "name_i18n: serde_json::Value", + sort_order as "sort_order!: i64", + is_default + FROM sites ORDER BY sort_order, id"# + ).fetch_all(&self.pool).await?; + Ok(rows.into_iter().map(|r| Site { + id: r.id, value: r.value, name: r.name, + name_i18n: r.name_i18n, + sort_order: r.sort_order, is_default: r.is_default != 0, + }).collect()) + } + + async fn create_site(&self, p: SitePayload) -> Result<Site> { + let res = sqlx::query!( + "INSERT INTO sites (value, name, name_i18n, is_default) VALUES (?, ?, ?, ?)", + p.value, p.name, p.name_i18n, p.is_default + ).execute(&self.pool).await.map_err(map_unique_violation)?; + let id = res.last_insert_rowid(); + // refetch + let sites = self.list_sites().await?; + sites.into_iter().find(|s| s.id == id).ok_or(AppError::NotFound) + } + + async fn patch_site(&self, id: i64, p: SitePatch) -> Result<Site> { + let mut tx = self.pool.begin().await?; + if let Some(v) = p.value { sqlx::query!("UPDATE sites SET value=? WHERE id=?", v, id).execute(&mut *tx).await.map_err(map_unique_violation)?; } + if let Some(v) = p.name { sqlx::query!("UPDATE sites SET name=? WHERE id=?", v, id).execute(&mut *tx).await?; } + if let Some(v) = p.name_i18n { sqlx::query!("UPDATE sites SET name_i18n=? WHERE id=?", v, id).execute(&mut *tx).await?; } + if let Some(v) = p.is_default { sqlx::query!("UPDATE sites SET is_default=? WHERE id=?", v, id).execute(&mut *tx).await?; } + tx.commit().await?; + let sites = self.list_sites().await?; + sites.into_iter().find(|s| s.id == id).ok_or(AppError::NotFound) + } + + async fn delete_site(&self, id: i64) -> Result<()> { + let res = sqlx::query!("DELETE FROM sites WHERE id=?", id).execute(&self.pool).await?; + if res.rows_affected() == 0 { return Err(AppError::NotFound); } + Ok(()) + } + + async fn reorder_sites(&self, entries: Vec<ReorderEntry>) -> Result<()> { + let mut tx = self.pool.begin().await?; + for e in entries { + sqlx::query!("UPDATE sites SET sort_order=? WHERE id=?", e.sort_order, e.id).execute(&mut *tx).await?; + } + tx.commit().await?; Ok(()) + } + + // ---- Groups ---- + + async fn list_groups(&self) -> Result<Vec<Group>> { + let rows = sqlx::query!( + r#"SELECT id as "id!: i64", slug, name, + name_i18n as "name_i18n: serde_json::Value", + sort_order as "sort_order!: i64", + collapsed_default + FROM groups ORDER BY sort_order, id"# + ).fetch_all(&self.pool).await?; + Ok(rows.into_iter().map(|r| Group { + id: r.id, slug: r.slug, name: r.name, + name_i18n: r.name_i18n, + sort_order: r.sort_order, + collapsed_default: r.collapsed_default != 0, + }).collect()) + } + + async fn create_group(&self, p: GroupPayload) -> Result<Group> { + let res = sqlx::query!( + "INSERT INTO groups (slug, name, name_i18n, collapsed_default) VALUES (?, ?, ?, ?)", + p.slug, p.name, p.name_i18n, p.collapsed_default + ).execute(&self.pool).await.map_err(map_unique_violation)?; + let id = res.last_insert_rowid(); + self.list_groups().await?.into_iter().find(|g| g.id == id).ok_or(AppError::NotFound) + } + + async fn patch_group(&self, id: i64, p: GroupPatch) -> Result<Group> { + let mut tx = self.pool.begin().await?; + if let Some(v) = p.slug { sqlx::query!("UPDATE groups SET slug=? WHERE id=?", v, id).execute(&mut *tx).await.map_err(map_unique_violation)?; } + if let Some(v) = p.name { sqlx::query!("UPDATE groups SET name=? WHERE id=?", v, id).execute(&mut *tx).await?; } + if let Some(v) = p.name_i18n { sqlx::query!("UPDATE groups SET name_i18n=? WHERE id=?", v, id).execute(&mut *tx).await?; } + if let Some(v) = p.collapsed_default { sqlx::query!("UPDATE groups SET collapsed_default=? WHERE id=?", v, id).execute(&mut *tx).await?; } + tx.commit().await?; + self.list_groups().await?.into_iter().find(|g| g.id == id).ok_or(AppError::NotFound) + } + + async fn delete_group(&self, id: i64) -> Result<()> { + let res = sqlx::query!("DELETE FROM groups WHERE id=?", id).execute(&self.pool).await?; + if res.rows_affected() == 0 { return Err(AppError::NotFound); } + Ok(()) + } + + async fn reorder_groups(&self, entries: Vec<ReorderEntry>) -> Result<()> { + let mut tx = self.pool.begin().await?; + for e in entries { + sqlx::query!("UPDATE groups SET sort_order=? WHERE id=?", e.sort_order, e.id).execute(&mut *tx).await?; + } + tx.commit().await?; Ok(()) + } + + // ---- Tags ---- + + async fn list_tags(&self) -> Result<Vec<Tag>> { + let rows = sqlx::query!( + r#"SELECT id as "id!: i64", slug, name, + name_i18n as "name_i18n: serde_json::Value" + FROM tags ORDER BY id"# + ).fetch_all(&self.pool).await?; + Ok(rows.into_iter().map(|r| Tag { + id: r.id, slug: r.slug, name: r.name, name_i18n: r.name_i18n, + }).collect()) + } + + async fn create_tag(&self, p: TagPayload) -> Result<Tag> { + let res = sqlx::query!( + "INSERT INTO tags (slug, name, name_i18n) VALUES (?, ?, ?)", + p.slug, p.name, p.name_i18n + ).execute(&self.pool).await.map_err(map_unique_violation)?; + let id = res.last_insert_rowid(); + self.list_tags().await?.into_iter().find(|t| t.id == id).ok_or(AppError::NotFound) + } + + async fn patch_tag(&self, id: i64, p: TagPatch) -> Result<Tag> { + let mut tx = self.pool.begin().await?; + if let Some(v) = p.slug { sqlx::query!("UPDATE tags SET slug=? WHERE id=?", v, id).execute(&mut *tx).await.map_err(map_unique_violation)?; } + if let Some(v) = p.name { sqlx::query!("UPDATE tags SET name=? WHERE id=?", v, id).execute(&mut *tx).await?; } + if let Some(v) = p.name_i18n { sqlx::query!("UPDATE tags SET name_i18n=? WHERE id=?", v, id).execute(&mut *tx).await?; } + tx.commit().await?; + self.list_tags().await?.into_iter().find(|t| t.id == id).ok_or(AppError::NotFound) + } + + async fn delete_tag(&self, id: i64) -> Result<()> { + let res = sqlx::query!("DELETE FROM tags WHERE id=?", id).execute(&self.pool).await?; + if res.rows_affected() == 0 { return Err(AppError::NotFound); } + Ok(()) + } + + // ---- Items: stubbed for Task 14/15 ---- + + async fn create_item(&self, _p: ItemPayload) -> Result<Item> { unimplemented!("Task 14") } + async fn patch_item(&self, _id: i64, _p: ItemPatch) -> Result<Item> { unimplemented!("Task 14") } + async fn delete_item(&self, _id: i64) -> Result<()> { unimplemented!("Task 14") } + async fn reorder_items(&self, _entries: Vec<ReorderEntry>) -> Result<()> { unimplemented!("Task 14") } +} + +impl SqlxNavRepo { + async fn list_items_full(&self) -> Result<Vec<Item>> { + // Implemented in Task 14. + let _ = &self.pool; + Ok(Vec::new()) + } +} +``` + +- [ ] **Step 3: Re-export from `repo/mod.rs`** + +```rust +// server/src/repo/mod.rs +pub mod nav; +pub mod sqlx_impl; +pub use nav::NavRepo; +pub use sqlx_impl::SqlxNavRepo; +``` + +- [ ] **Step 4: Run tests (offline mode for sqlx macros)** + +`sqlx::query!` requires `DATABASE_URL` at build time *or* `SQLX_OFFLINE=true` with prepared metadata. We use offline metadata after generating it once: + +```bash +cd server +DATABASE_URL=sqlite::memory: SQLX_OFFLINE=false cargo test --test repo_sites +cd .. +``` + +Expected: 2 PASS. + +- [ ] **Step 5: Generate `.sqlx` metadata for offline builds** + +```bash +cd server +cargo install sqlx-cli --no-default-features --features sqlite --version ^0.7 || true +DATABASE_URL=sqlite:./dev-data/data.db cargo sqlx prepare -- --bin navsrv +ls -la .sqlx | head +cd .. +``` + +Expected: `.sqlx/` directory with `query-*.json` files. + +- [ ] **Step 6: Commit** + +```bash +git add server/src/repo server/tests/repo_sites.rs server/.sqlx +git commit -m "feat(server): NavRepo sqlx impl for sites/groups/tags" +``` + +### Task 14: `SqlxNavRepo` — items + item_links + item_tags + +**Files:** +- Modify: `server/src/repo/sqlx_impl.rs` +- Create: `server/tests/repo_items.rs` + +- [ ] **Step 1: Write the failing test** + +```rust +// server/tests/repo_items.rs +use navsrv::db::connect_in_memory; +use navsrv::dto::*; +use navsrv::repo::{NavRepo, SqlxNavRepo}; + +async fn make_repo_with_seed() -> (SqlxNavRepo, i64, i64) { + let pool = connect_in_memory().await.unwrap(); + let repo = SqlxNavRepo::new(pool); + let g = repo.create_group(GroupPayload { slug: "tools".into(), name: "Tools".into(), name_i18n: None, collapsed_default: false }).await.unwrap(); + let s = repo.create_site(SitePayload { value: "shangHai".into(), name: "上海".into(), name_i18n: None, is_default: true }).await.unwrap(); + (repo, g.id, s.id) +} + +#[tokio::test] +async fn create_item_with_links_and_tags() { + let (repo, gid, _sid) = make_repo_with_seed().await; + let _t = repo.create_tag(TagPayload { slug: "fav".into(), name: "Favorite".into(), name_i18n: None }).await.unwrap(); + let mut links = std::collections::BTreeMap::new(); + links.insert("shangHai".into(), "http://10.0.0.1".into()); + let item = repo.create_item(ItemPayload { + group_id: Some(gid), + name: "RouterOS".into(), + name_i18n: None, + description: None, + description_i18n: None, + icon_kind: IconKind::Asset, + icon_value: "routerOS.png".into(), + links, + tag_slugs: vec!["fav".into()], + }).await.unwrap(); + assert_eq!(item.name, "RouterOS"); + assert_eq!(item.links.get("shangHai").map(String::as_str), Some("http://10.0.0.1")); + assert_eq!(item.tag_slugs, vec!["fav"]); +} + +#[tokio::test] +async fn patch_item_replaces_links_and_tags() { + let (repo, gid, _sid) = make_repo_with_seed().await; + repo.create_tag(TagPayload { slug: "a".into(), name: "A".into(), name_i18n: None }).await.unwrap(); + repo.create_tag(TagPayload { slug: "b".into(), name: "B".into(), name_i18n: None }).await.unwrap(); + let mut links = std::collections::BTreeMap::new(); + links.insert("shangHai".into(), "http://1".into()); + let item = repo.create_item(ItemPayload { + group_id: Some(gid), name: "x".into(), name_i18n: None, description: None, description_i18n: None, + icon_kind: IconKind::Asset, icon_value: "x.png".into(), + links: links.clone(), tag_slugs: vec!["a".into()], + }).await.unwrap(); + let mut new_links = std::collections::BTreeMap::new(); + new_links.insert("shangHai".into(), "http://2".into()); + let p = ItemPatch { links: Some(new_links), tag_slugs: Some(vec!["b".into()]), ..Default::default() }; + let updated = repo.patch_item(item.id, p).await.unwrap(); + assert_eq!(updated.links.get("shangHai").map(String::as_str), Some("http://2")); + assert_eq!(updated.tag_slugs, vec!["b"]); +} + +#[tokio::test] +async fn delete_item_cascades_links_and_tags() { + let (repo, gid, _sid) = make_repo_with_seed().await; + let mut links = std::collections::BTreeMap::new(); + links.insert("shangHai".into(), "http://1".into()); + let item = repo.create_item(ItemPayload { + group_id: Some(gid), name: "x".into(), name_i18n: None, description: None, description_i18n: None, + icon_kind: IconKind::Asset, icon_value: "x.png".into(), + links, tag_slugs: vec![], + }).await.unwrap(); + repo.delete_item(item.id).await.unwrap(); + let (_, _, items, _) = repo.get_bundle().await.unwrap(); + assert!(items.is_empty()); +} + +#[tokio::test] +async fn reorder_items_writes_sort_order() { + let (repo, gid, _sid) = make_repo_with_seed().await; + let a = repo.create_item(ItemPayload { group_id: Some(gid), name: "a".into(), name_i18n:None, description:None, description_i18n:None, icon_kind: IconKind::Asset, icon_value:"a.png".into(), links: Default::default(), tag_slugs: vec![]}).await.unwrap(); + let b = repo.create_item(ItemPayload { group_id: Some(gid), name: "b".into(), name_i18n:None, description:None, description_i18n:None, icon_kind: IconKind::Asset, icon_value:"b.png".into(), links: Default::default(), tag_slugs: vec![]}).await.unwrap(); + repo.reorder_items(vec![ + ReorderEntry { id: b.id, sort_order: 0, group_id: Some(Some(gid)) }, + ReorderEntry { id: a.id, sort_order: 1, group_id: Some(Some(gid)) }, + ]).await.unwrap(); + let (_, _, items, _) = repo.get_bundle().await.unwrap(); + assert_eq!(items[0].id, b.id); + assert_eq!(items[1].id, a.id); +} +``` + +- [ ] **Step 2: Implement items in `sqlx_impl.rs`** + +Replace the four `unimplemented!` stubs and the `list_items_full` body with: + +```rust + async fn create_item(&self, p: ItemPayload) -> Result<Item> { + let now = now_ms(); + let kind_str = match p.icon_kind { IconKind::Asset => "asset", IconKind::Url => "url", IconKind::AutoFavicon => "auto-favicon" }; + + let mut tx = self.pool.begin().await?; + let res = sqlx::query!( + r#"INSERT INTO items + (group_id, name, name_i18n, description, description_i18n, + icon_kind, icon_value, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT MAX(sort_order)+1 FROM items WHERE group_id IS ?), 0), ?, ?)"#, + p.group_id, p.name, p.name_i18n, p.description, p.description_i18n, + kind_str, p.icon_value, p.group_id, now, now + ).execute(&mut *tx).await?; + let id = res.last_insert_rowid(); + + for (site_value, url) in &p.links { + sqlx::query!( + r#"INSERT INTO item_links (item_id, site_id, url) + SELECT ?, sites.id, ? FROM sites WHERE sites.value = ?"#, + id, url, site_value + ).execute(&mut *tx).await?; + } + + for slug in &p.tag_slugs { + sqlx::query!( + r#"INSERT INTO item_tags (item_id, tag_id) + SELECT ?, tags.id FROM tags WHERE tags.slug = ?"#, + id, slug + ).execute(&mut *tx).await?; + } + tx.commit().await?; + self.fetch_item(id).await + } + + async fn patch_item(&self, id: i64, p: ItemPatch) -> Result<Item> { + let now = now_ms(); + let mut tx = self.pool.begin().await?; + if let Some(v) = p.group_id { sqlx::query!("UPDATE items SET group_id=?, updated_at=? WHERE id=?", v, now, id).execute(&mut *tx).await?; } + if let Some(v) = p.name { sqlx::query!("UPDATE items SET name=?, updated_at=? WHERE id=?", v, now, id).execute(&mut *tx).await?; } + if let Some(v) = p.name_i18n { sqlx::query!("UPDATE items SET name_i18n=?, updated_at=? WHERE id=?", v, now, id).execute(&mut *tx).await?; } + if let Some(v) = p.description { sqlx::query!("UPDATE items SET description=?, updated_at=? WHERE id=?", v, now, id).execute(&mut *tx).await?; } + if let Some(v) = p.description_i18n { sqlx::query!("UPDATE items SET description_i18n=?, updated_at=? WHERE id=?", v, now, id).execute(&mut *tx).await?; } + if let Some(v) = p.icon_kind { + let s = match v { IconKind::Asset => "asset", IconKind::Url => "url", IconKind::AutoFavicon => "auto-favicon" }; + sqlx::query!("UPDATE items SET icon_kind=?, updated_at=? WHERE id=?", s, now, id).execute(&mut *tx).await?; + } + if let Some(v) = p.icon_value { sqlx::query!("UPDATE items SET icon_value=?, updated_at=? WHERE id=?", v, now, id).execute(&mut *tx).await?; } + + if let Some(links) = p.links { + sqlx::query!("DELETE FROM item_links WHERE item_id=?", id).execute(&mut *tx).await?; + for (site_value, url) in links { + sqlx::query!( + r#"INSERT INTO item_links (item_id, site_id, url) + SELECT ?, sites.id, ? FROM sites WHERE sites.value = ?"#, + id, url, site_value + ).execute(&mut *tx).await?; + } + } + if let Some(tag_slugs) = p.tag_slugs { + sqlx::query!("DELETE FROM item_tags WHERE item_id=?", id).execute(&mut *tx).await?; + for slug in tag_slugs { + sqlx::query!( + r#"INSERT INTO item_tags (item_id, tag_id) + SELECT ?, tags.id FROM tags WHERE tags.slug = ?"#, + id, slug + ).execute(&mut *tx).await?; + } + } + tx.commit().await?; + self.fetch_item(id).await + } + + async fn delete_item(&self, id: i64) -> Result<()> { + let res = sqlx::query!("DELETE FROM items WHERE id=?", id).execute(&self.pool).await?; + if res.rows_affected() == 0 { return Err(AppError::NotFound); } + Ok(()) + } + + async fn reorder_items(&self, entries: Vec<ReorderEntry>) -> Result<()> { + let now = now_ms(); + let mut tx = self.pool.begin().await?; + for e in entries { + match e.group_id { + Some(gid) => { sqlx::query!("UPDATE items SET sort_order=?, group_id=?, updated_at=? WHERE id=?", e.sort_order, gid, now, e.id).execute(&mut *tx).await?; } + None => { sqlx::query!("UPDATE items SET sort_order=?, updated_at=? WHERE id=?", e.sort_order, now, e.id).execute(&mut *tx).await?; } + } + } + tx.commit().await?; Ok(()) + } +``` + +- [ ] **Step 3: Replace `list_items_full`** + +```rust +impl SqlxNavRepo { + pub(crate) async fn fetch_item(&self, id: i64) -> Result<Item> { + self.list_items_full().await?.into_iter().find(|i| i.id == id).ok_or(AppError::NotFound) + } + + pub(crate) async fn list_items_full(&self) -> Result<Vec<Item>> { + let item_rows = sqlx::query!( + r#"SELECT id as "id!: i64", group_id, + name, name_i18n as "name_i18n: serde_json::Value", + description, description_i18n as "description_i18n: serde_json::Value", + icon_kind, icon_value, + sort_order as "sort_order!: i64", + created_at as "created_at!: i64", + updated_at as "updated_at!: i64" + FROM items + ORDER BY group_id, sort_order, id"# + ).fetch_all(&self.pool).await?; + + let link_rows = sqlx::query!( + r#"SELECT il.item_id as "item_id!: i64", s.value as "site_value!", il.url as "url!" + FROM item_links il JOIN sites s ON s.id = il.site_id"# + ).fetch_all(&self.pool).await?; + + let tag_rows = sqlx::query!( + r#"SELECT it.item_id as "item_id!: i64", t.slug as "slug!" + FROM item_tags it JOIN tags t ON t.id = it.tag_id"# + ).fetch_all(&self.pool).await?; + + let mut items: Vec<Item> = item_rows.into_iter().map(|r| { + let kind = match r.icon_kind.as_str() { + "url" => IconKind::Url, + "auto-favicon" => IconKind::AutoFavicon, + _ => IconKind::Asset, + }; + Item { + id: r.id, group_id: r.group_id, + name: r.name, name_i18n: r.name_i18n, + description: r.description, description_i18n: r.description_i18n, + icon_kind: kind, icon_value: r.icon_value, + sort_order: r.sort_order, + links: Default::default(), tag_slugs: Vec::new(), + created_at: r.created_at, updated_at: r.updated_at, + } + }).collect(); + + let by_id: std::collections::HashMap<i64, usize> = + items.iter().enumerate().map(|(i, it)| (it.id, i)).collect(); + + for r in link_rows { + if let Some(&idx) = by_id.get(&r.item_id) { + items[idx].links.insert(r.site_value, r.url); + } + } + for r in tag_rows { + if let Some(&idx) = by_id.get(&r.item_id) { + items[idx].tag_slugs.push(r.slug); + } + } + Ok(items) + } +} +``` + +- [ ] **Step 4: Re-prepare sqlx metadata and run tests** + +```bash +cd server +DATABASE_URL=sqlite:./dev-data/data.db cargo sqlx prepare -- --bin navsrv --tests +cargo test --test repo_items +cd .. +``` + +Expected: 4 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/src/repo/sqlx_impl.rs server/tests/repo_items.rs server/.sqlx +git commit -m "feat(server): NavRepo sqlx impl for items + links + tags" +``` + +### Task 15: `ConfigRepo` (kv access for site meta + admin hash) + +**Files:** +- Create: `server/src/repo/config.rs` +- Modify: `server/src/repo/mod.rs` +- Create: `server/tests/repo_config.rs` + +- [ ] **Step 1: Test first** + +```rust +// server/tests/repo_config.rs +use navsrv::db::connect_in_memory; +use navsrv::repo::{ConfigRepo, SqlxConfigRepo}; + +#[tokio::test] +async fn upsert_get_delete_config() { + let pool = connect_in_memory().await.unwrap(); + let repo = SqlxConfigRepo::new(pool); + repo.upsert("site_name", "My Site").await.unwrap(); + assert_eq!(repo.get("site_name").await.unwrap().as_deref(), Some("My Site")); + assert_eq!(repo.get("missing").await.unwrap(), None); + + repo.upsert("site_name", "Renamed").await.unwrap(); + assert_eq!(repo.get("site_name").await.unwrap().as_deref(), Some("Renamed")); + + repo.delete("site_name").await.unwrap(); + assert_eq!(repo.get("site_name").await.unwrap(), None); +} + +#[tokio::test] +async fn many_pairs() { + let pool = connect_in_memory().await.unwrap(); + let repo = SqlxConfigRepo::new(pool); + repo.upsert_many(&[("a", "1"), ("b", "2")]).await.unwrap(); + let all = repo.get_many(&["a", "b", "c"]).await.unwrap(); + assert_eq!(all.get("a").map(String::as_str), Some("1")); + assert_eq!(all.get("b").map(String::as_str), Some("2")); + assert!(all.get("c").is_none()); +} +``` + +- [ ] **Step 2: Trait + impl** + +```rust +// server/src/repo/config.rs +use crate::error::Result; +use async_trait::async_trait; +use sqlx::SqlitePool; +use std::collections::BTreeMap; + +#[async_trait] +pub trait ConfigRepo: Send + Sync { + async fn get(&self, key: &str) -> Result<Option<String>>; + async fn get_many(&self, keys: &[&str]) -> Result<BTreeMap<String, String>>; + async fn upsert(&self, key: &str, value: &str) -> Result<()>; + async fn upsert_many(&self, pairs: &[(&str, &str)]) -> Result<()>; + async fn delete(&self, key: &str) -> Result<()>; +} + +pub struct SqlxConfigRepo { pool: SqlitePool } + +impl SqlxConfigRepo { + pub fn new(pool: SqlitePool) -> Self { Self { pool } } +} + +#[async_trait] +impl ConfigRepo for SqlxConfigRepo { + async fn get(&self, key: &str) -> Result<Option<String>> { + let row = sqlx::query_scalar!("SELECT value FROM config WHERE key = ?", key) + .fetch_optional(&self.pool).await?; + Ok(row) + } + + async fn get_many(&self, keys: &[&str]) -> Result<BTreeMap<String, String>> { + let mut out = BTreeMap::new(); + for k in keys { + if let Some(v) = self.get(k).await? { out.insert((*k).to_string(), v); } + } + Ok(out) + } + + async fn upsert(&self, key: &str, value: &str) -> Result<()> { + sqlx::query!( + "INSERT INTO config (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value", + key, value + ).execute(&self.pool).await?; + Ok(()) + } + + async fn upsert_many(&self, pairs: &[(&str, &str)]) -> Result<()> { + let mut tx = self.pool.begin().await?; + for (k, v) in pairs { + sqlx::query!( + "INSERT INTO config (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value", + k, v + ).execute(&mut *tx).await?; + } + tx.commit().await?; Ok(()) + } + + async fn delete(&self, key: &str) -> Result<()> { + sqlx::query!("DELETE FROM config WHERE key = ?", key).execute(&self.pool).await?; + Ok(()) + } +} +``` + +- [ ] **Step 3: Re-export** + +```rust +// server/src/repo/mod.rs +pub mod config; +pub mod nav; +pub mod sqlx_impl; +pub use config::{ConfigRepo, SqlxConfigRepo}; +pub use nav::NavRepo; +pub use sqlx_impl::SqlxNavRepo; +``` + +- [ ] **Step 4: Re-prepare and test** + +```bash +cd server +DATABASE_URL=sqlite:./dev-data/data.db cargo sqlx prepare -- --bin navsrv --tests +cargo test --test repo_config +cd .. +``` + +Expected: 2 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/src/repo/config.rs server/src/repo/mod.rs server/tests/repo_config.rs server/.sqlx +git commit -m "feat(server): ConfigRepo (kv) sqlx impl with batch upsert" +``` + +### Task 16: AppState + wire repos into Axum + +**Files:** +- Create: `server/src/state.rs` +- Modify: `server/src/app.rs`, `server/src/main.rs`, `server/src/lib.rs` + +- [ ] **Step 1: Define `AppState`** + +```rust +// server/src/state.rs +use crate::repo::{ConfigRepo, NavRepo}; +use std::sync::Arc; + +#[derive(Clone)] +pub struct AppState { + pub nav: Arc<dyn NavRepo>, + pub config: Arc<dyn ConfigRepo>, +} + +impl AppState { + pub fn new(nav: Arc<dyn NavRepo>, config: Arc<dyn ConfigRepo>) -> Self { + Self { nav, config } + } +} +``` + +- [ ] **Step 2: Make `app.rs` accept state** + +```rust +// server/src/app.rs (replace previous) +use axum::http::{header, HeaderValue, Request}; +use axum::Router; +use tower_http::compression::CompressionLayer; +use tower_http::set_header::SetResponseHeaderLayer; +use tower_http::trace::{DefaultOnResponse, TraceLayer}; +use tracing::Level; + +use crate::routes; +use crate::state::AppState; + +pub fn build_app(state: AppState) -> Router { + let trace = TraceLayer::new_for_http() + .make_span_with(|req: &Request<_>| { + let method = req.method(); + let uri = req.uri().path(); + tracing::info_span!("http", %method, %uri) + }) + .on_response(DefaultOnResponse::new().level(Level::INFO)); + + Router::new() + .merge(routes::api(state.clone())) + .with_state(state) + .layer(trace) + .layer(CompressionLayer::new()) + .layer(SetResponseHeaderLayer::if_not_present( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + header::REFERRER_POLICY, + HeaderValue::from_static("same-origin"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + header::X_FRAME_OPTIONS, + HeaderValue::from_static("DENY"), + )) +} + +pub async fn build_app_for_tests() -> anyhow::Result<Router> { + use crate::db::connect_in_memory; + use crate::repo::{SqlxConfigRepo, SqlxNavRepo}; + use std::sync::Arc; + let pool = connect_in_memory().await?; + let nav = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg = Arc::new(SqlxConfigRepo::new(pool)); + let state = AppState::new(nav, cfg); + Ok(build_app(state)) +} +``` + +- [ ] **Step 3: Update `routes/mod.rs` to take state** + +```rust +// server/src/routes/mod.rs +use axum::Router; + +pub mod health; + +use crate::state::AppState; + +pub fn api(state: AppState) -> Router { + Router::new() + .nest("/api", Router::new() + .merge(health::router())) + .with_state(state) +} +``` + +(Health does not need state yet — but the wiring is here for later routes.) + +- [ ] **Step 4: Update `main.rs`** + +```rust +// server/src/main.rs +use anyhow::Context; +use navsrv::{ + app::build_app, + config::Settings, + db::{connect, migrate}, + repo::{SqlxConfigRepo, SqlxNavRepo}, + state::AppState, +}; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::net::TcpListener; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let settings = Settings::load().context("load settings")?; + init_tracing(&settings.rust_log); + + let pool = connect(&settings.db_url()).await.context("db connect")?; + migrate(&pool).await.context("migrate")?; + + let nav = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg = Arc::new(SqlxConfigRepo::new(pool)); + let state = AppState::new(nav, cfg); + + let app = build_app(state); + let addr = SocketAddr::from(([0, 0, 0, 0], settings.port)); + let listener = TcpListener::bind(addr).await.context("bind")?; + tracing::info!(%addr, "navsrv listening"); + axum::serve(listener, app).await.context("serve")?; + Ok(()) +} + +fn init_tracing(filter: &str) { + use tracing_subscriber::{fmt, EnvFilter}; + let env = EnvFilter::try_new(filter).unwrap_or_else(|_| EnvFilter::new("info")); + fmt().with_env_filter(env).with_target(false).compact().init(); +} +``` + +- [ ] **Step 5: Re-export** + +```rust +// server/src/lib.rs +pub mod app; +pub mod config; +pub mod db; +pub mod dto; +pub mod error; +pub mod repo; +pub mod routes; +pub mod state; +``` + +- [ ] **Step 6: Verify** + +```bash +cd server +cargo test +cd .. +``` + +Expected: all green. + +- [ ] **Step 7: Commit** + +```bash +git add server/src/state.rs server/src/app.rs server/src/main.rs server/src/routes/mod.rs server/src/lib.rs +git commit -m "refactor(server): introduce AppState carrying repo trait objects" +``` + +--- + +## Phase 3: Public Read API (Tasks 17–18) + +Goal: `GET /api/nav` returns the full bundle from the seeded DB. + +### Task 17: Build `NavBundle` from repos + +**Files:** +- Create: `server/src/services/mod.rs` +- Create: `server/src/services/bundle.rs` +- Modify: `server/src/lib.rs` + +- [ ] **Step 1: Create the assembler** + +```rust +// server/src/services/mod.rs +pub mod bundle; +``` + +```rust +// server/src/services/bundle.rs +use crate::dto::*; +use crate::error::Result; +use crate::repo::{ConfigRepo, NavRepo}; +use std::sync::Arc; + +pub async fn assemble_bundle( + nav: Arc<dyn NavRepo>, + config: Arc<dyn ConfigRepo>, +) -> Result<NavBundle> { + let (sites, groups, items, tags) = nav.get_bundle().await?; + + let cfg = config.get_many(&[ + "site_name", "site_avatar_path", "site_copyright", + "site_icp_text", "site_icp_url", + "site_police_text", "site_police_url", + "default_theme", + ]).await?; + + let icp = match (cfg.get("site_icp_text").cloned(), cfg.get("site_icp_url").cloned()) { + (Some(text), Some(url)) => Some(Link { text, url }), + _ => None, + }; + let police = match (cfg.get("site_police_text").cloned(), cfg.get("site_police_url").cloned()) { + (Some(text), Some(url)) => Some(Link { text, url }), + _ => None, + }; + + let meta = Meta { + site_name: cfg.get("site_name").cloned().unwrap_or_else(|| "Navigation".into()), + site_avatar_path: cfg.get("site_avatar_path").cloned(), + site_copyright: cfg.get("site_copyright").cloned().unwrap_or_default(), + site_icp: icp, + site_police: police, + default_theme: cfg.get("default_theme").cloned().unwrap_or_else(|| "system".into()), + }; + + Ok(NavBundle { + schema_version: 1, + meta, + sites, + groups, + items, + tags, + }) +} +``` + +- [ ] **Step 2: Re-export** + +```rust +// server/src/lib.rs +pub mod app; +pub mod config; +pub mod db; +pub mod dto; +pub mod error; +pub mod repo; +pub mod routes; +pub mod services; +pub mod state; +``` + +- [ ] **Step 3: Compile-check** + +```bash +cd server +cargo check +cd .. +``` + +Expected: clean. + +- [ ] **Step 4: Commit** + +```bash +git add server/src/services server/src/lib.rs +git commit -m "feat(server): assemble_bundle service composing nav + config" +``` + +### Task 18: `GET /api/nav` handler + +**Files:** +- Create: `server/src/routes/nav.rs` +- Modify: `server/src/routes/mod.rs` +- Create: `server/tests/api_nav.rs` + +- [ ] **Step 1: Write test first** + +```rust +// server/tests/api_nav.rs +use axum_test::TestServer; +use navsrv::app::build_app_for_tests; + +#[tokio::test] +async fn nav_endpoint_returns_empty_bundle_initially() { + let app = build_app_for_tests().await.unwrap(); + let server = TestServer::new(app).unwrap(); + let res = server.get("/api/nav").await; + res.assert_status_ok(); + let body: serde_json::Value = res.json(); + assert_eq!(body["schemaVersion"], 1); + assert!(body["sites"].as_array().unwrap().is_empty()); + assert!(body["items"].as_array().unwrap().is_empty()); + assert_eq!(body["meta"]["defaultTheme"], "system"); +} +``` + +- [ ] **Step 2: Use camelCase serialization** + +Modify `server/src/dto.rs` — add `#[serde(rename_all = "camelCase")]` to every struct that ends up in JSON. Apply to `Site`, `Group`, `Tag`, `Item`, `Meta`, `Link`, `NavBundle` (and write payloads): + +```rust +// At top of every relevant struct, add the attribute. Example: +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Site { /* ... */ } +``` + +Apply to all DTOs uniformly. + +- [ ] **Step 3: Write the handler** + +```rust +// server/src/routes/nav.rs +use axum::{extract::State, routing::get, Json, Router}; + +use crate::dto::NavBundle; +use crate::error::Result; +use crate::services::bundle::assemble_bundle; +use crate::state::AppState; + +pub fn router() -> Router<AppState> { + Router::new().route("/nav", get(get_nav)) +} + +async fn get_nav(State(s): State<AppState>) -> Result<Json<NavBundle>> { + let bundle = assemble_bundle(s.nav.clone(), s.config.clone()).await?; + Ok(Json(bundle)) +} +``` + +- [ ] **Step 4: Wire into `routes/mod.rs`** + +```rust +// server/src/routes/mod.rs +use axum::Router; + +pub mod health; +pub mod nav; + +use crate::state::AppState; + +pub fn api(state: AppState) -> Router { + Router::new() + .nest("/api", Router::new() + .merge(health::router()) + .merge(nav::router())) + .with_state(state) +} +``` + +- [ ] **Step 5: Run tests** + +```bash +cd server +cargo test --test api_nav +cd .. +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add server/src/routes server/src/dto.rs server/tests/api_nav.rs +git commit -m "feat(server): GET /api/nav returns full NavBundle (camelCase)" +``` + +--- + +## Phase 4: Authentication and Bootstrap (Tasks 19–28) + +Goal: single-admin password auth backed by bcrypt + signed session cookies stored in SQLite. First boot generates a password if env doesn't supply one and writes it to `INITIAL_PASSWORD.txt`. CLI subcommand `navsrv reset-password` for recovery. + +### Task 19: Password hashing utility + +**Files:** +- Create: `server/src/auth/mod.rs` +- Create: `server/src/auth/password.rs` +- Modify: `server/src/lib.rs` + +- [ ] **Step 1: Test first** + +Create `server/src/auth/password.rs`: + +```rust +//! bcrypt-based password hashing. + +use crate::error::{AppError, Result}; + +pub const BCRYPT_COST: u32 = 12; + +pub fn hash(plain: &str) -> Result<String> { + if plain.is_empty() { + return Err(AppError::Validation("password must not be empty".into())); + } + Ok(bcrypt::hash(plain, BCRYPT_COST)?) +} + +pub fn verify(plain: &str, hashed: &str) -> Result<bool> { + Ok(bcrypt::verify(plain, hashed)?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hash_then_verify_roundtrip() { + let h = hash("hunter2").unwrap(); + assert!(verify("hunter2", &h).unwrap()); + assert!(!verify("wrong", &h).unwrap()); + } + + #[test] + fn empty_password_rejected() { + let err = hash("").unwrap_err(); + assert!(matches!(err, AppError::Validation(_))); + } +} +``` + +- [ ] **Step 2: Module file** + +```rust +// server/src/auth/mod.rs +pub mod password; +``` + +- [ ] **Step 3: Re-export** + +```rust +// server/src/lib.rs +pub mod app; +pub mod auth; +pub mod config; +pub mod db; +pub mod dto; +pub mod error; +pub mod repo; +pub mod routes; +pub mod services; +pub mod state; +``` + +- [ ] **Step 4: Run** + +```bash +cd server +cargo test --lib auth::password::tests +cd .. +``` + +Expected: 2 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/src/auth server/src/lib.rs +git commit -m "feat(server): bcrypt password hash/verify utility" +``` + +### Task 20: Bootstrap service — env or random + INITIAL_PASSWORD.txt + +**Files:** +- Create: `server/src/services/bootstrap.rs` +- Modify: `server/src/services/mod.rs` +- Create: `server/tests/bootstrap.rs` + +- [ ] **Step 1: Write the failing test** + +```rust +// server/tests/bootstrap.rs +use navsrv::db::connect_in_memory; +use navsrv::repo::{ConfigRepo, SqlxConfigRepo}; +use navsrv::services::bootstrap::{ensure_admin_password, BootstrapOutcome}; +use std::sync::Arc; +use tempfile::TempDir; + +#[tokio::test] +async fn first_boot_with_env_writes_hash_only() { + let pool = connect_in_memory().await.unwrap(); + let cfg: Arc<dyn ConfigRepo> = Arc::new(SqlxConfigRepo::new(pool)); + let dir = TempDir::new().unwrap(); + + let out = ensure_admin_password(cfg.clone(), dir.path(), Some("env-pw".into())) + .await + .unwrap(); + assert!(matches!(out, BootstrapOutcome::SetFromEnv)); + assert!(cfg.get("admin_password_hash").await.unwrap().is_some()); + assert!(!dir.path().join("INITIAL_PASSWORD.txt").exists()); +} + +#[tokio::test] +async fn first_boot_without_env_generates_and_writes_file() { + let pool = connect_in_memory().await.unwrap(); + let cfg: Arc<dyn ConfigRepo> = Arc::new(SqlxConfigRepo::new(pool)); + let dir = TempDir::new().unwrap(); + + let out = ensure_admin_password(cfg.clone(), dir.path(), None).await.unwrap(); + let pw = match out { + BootstrapOutcome::Generated(pw) => pw, + _ => panic!("expected Generated"), + }; + assert_eq!(pw.len(), 24); + let path = dir.path().join("INITIAL_PASSWORD.txt"); + assert!(path.exists()); + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains(&pw)); +} + +#[tokio::test] +async fn second_boot_is_idempotent() { + let pool = connect_in_memory().await.unwrap(); + let cfg: Arc<dyn ConfigRepo> = Arc::new(SqlxConfigRepo::new(pool)); + let dir = TempDir::new().unwrap(); + + ensure_admin_password(cfg.clone(), dir.path(), Some("first".into())).await.unwrap(); + let out = ensure_admin_password(cfg.clone(), dir.path(), Some("ignored".into())).await.unwrap(); + assert!(matches!(out, BootstrapOutcome::AlreadySet)); +} +``` + +- [ ] **Step 2: Implement service** + +```rust +// server/src/services/bootstrap.rs +use crate::auth::password; +use crate::error::Result; +use crate::repo::ConfigRepo; +use rand::distributions::{Alphanumeric, DistString}; +use std::path::Path; +use std::sync::Arc; + +const PASSWORD_LEN: usize = 24; + +pub enum BootstrapOutcome { + AlreadySet, + SetFromEnv, + Generated(String), +} + +pub async fn ensure_admin_password( + config: Arc<dyn ConfigRepo>, + data_dir: &Path, + env_password: Option<String>, +) -> Result<BootstrapOutcome> { + if config.get("admin_password_hash").await?.is_some() { + return Ok(BootstrapOutcome::AlreadySet); + } + + let now = chrono::Utc::now().timestamp_millis().to_string(); + + if let Some(pw) = env_password.filter(|s| !s.is_empty()) { + let hash = password::hash(&pw)?; + config.upsert_many(&[ + ("admin_password_hash", &hash), + ("admin_password_updated_at", &now), + ]).await?; + tracing::warn!( + "Admin password set from env. Please change it via UI immediately." + ); + return Ok(BootstrapOutcome::SetFromEnv); + } + + let pw = Alphanumeric.sample_string(&mut rand::thread_rng(), PASSWORD_LEN); + let hash = password::hash(&pw)?; + config.upsert_many(&[ + ("admin_password_hash", &hash), + ("admin_password_updated_at", &now), + ]).await?; + + let path = data_dir.join("INITIAL_PASSWORD.txt"); + let body = format!( + "Initial admin password (single use):\n{pw}\n\n\ + After logging in and changing the password via UI, this file is deleted automatically.\n" + ); + std::fs::write(&path, body)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + std::fs::set_permissions(&path, perms)?; + } + tracing::warn!(?path, "Generated initial admin password — see file above"); + tracing::warn!("INITIAL ADMIN PASSWORD: {pw}"); + + Ok(BootstrapOutcome::Generated(pw)) +} +``` + +- [ ] **Step 3: Re-export** + +```rust +// server/src/services/mod.rs +pub mod bootstrap; +pub mod bundle; +``` + +- [ ] **Step 4: Run tests** + +```bash +cd server +cargo test --test bootstrap +cd .. +``` + +Expected: 3 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/src/services server/tests/bootstrap.rs +git commit -m "feat(server): bootstrap admin password (env or random+INITIAL_PASSWORD.txt)" +``` + +### Task 21: Wire `tower-sessions` with SQLite store + +**Files:** +- Create: `server/src/auth/session.rs` +- Modify: `server/src/auth/mod.rs`, `server/src/app.rs`, `server/src/main.rs`, `server/migrations/0001_init.sql` + +- [ ] **Step 1: Add the `tower_sessions_sqlx_store` table to migration** + +Append to `server/migrations/0001_init.sql`: + +```sql +CREATE TABLE tower_sessions ( + id TEXT PRIMARY KEY NOT NULL, + data BLOB NOT NULL, + expiry_date INTEGER NOT NULL +); +``` + +- [ ] **Step 2: Session helper** + +```rust +// server/src/auth/session.rs +use sqlx::SqlitePool; +use std::time::Duration; +use tower_sessions::cookie::time::Duration as CookieDur; +use tower_sessions::cookie::SameSite; +use tower_sessions::{Expiry, SessionManagerLayer}; +use tower_sessions_sqlx_store::SqliteStore; + +pub const SESSION_KEY_AUTHED: &str = "authed"; + +pub fn layer(pool: SqlitePool, secure: bool) -> SessionManagerLayer<SqliteStore> { + let store = SqliteStore::new(pool).with_table_name("tower_sessions").expect("valid table"); + SessionManagerLayer::new(store) + .with_name("__Host-sid") + .with_secure(secure) + .with_http_only(true) + .with_same_site(SameSite::Lax) + .with_expiry(Expiry::OnInactivity(CookieDur::days(30))) +} + +/// Background task: prune expired sessions every hour. +pub async fn run_pruner(pool: SqlitePool) { + let mut tick = tokio::time::interval(Duration::from_secs(3600)); + loop { + tick.tick().await; + let now = chrono::Utc::now().timestamp_millis(); + if let Err(e) = sqlx::query("DELETE FROM tower_sessions WHERE expiry_date < ?") + .bind(now) + .execute(&pool) + .await + { + tracing::warn!(error = %e, "session pruner failed"); + } + } +} +``` + +- [ ] **Step 3: Re-export** + +```rust +// server/src/auth/mod.rs +pub mod password; +pub mod session; +``` + +- [ ] **Step 4: Update `app.rs` to accept the session layer** + +```rust +// server/src/app.rs (replace previous) +use axum::http::{header, HeaderValue, Request}; +use axum::Router; +use tower_http::compression::CompressionLayer; +use tower_http::set_header::SetResponseHeaderLayer; +use tower_http::trace::{DefaultOnResponse, TraceLayer}; +use tower_sessions::SessionManagerLayer; +use tower_sessions_sqlx_store::SqliteStore; +use tracing::Level; + +use crate::routes; +use crate::state::AppState; + +pub fn build_app( + state: AppState, + session_layer: SessionManagerLayer<SqliteStore>, +) -> Router { + let trace = TraceLayer::new_for_http() + .make_span_with(|req: &Request<_>| { + let method = req.method(); + let uri = req.uri().path(); + tracing::info_span!("http", %method, %uri) + }) + .on_response(DefaultOnResponse::new().level(Level::INFO)); + + Router::new() + .merge(routes::api(state.clone())) + .with_state(state) + .layer(session_layer) + .layer(trace) + .layer(CompressionLayer::new()) + .layer(SetResponseHeaderLayer::if_not_present( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + header::REFERRER_POLICY, + HeaderValue::from_static("same-origin"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + header::X_FRAME_OPTIONS, + HeaderValue::from_static("DENY"), + )) +} + +pub async fn build_app_for_tests() -> anyhow::Result<Router> { + use crate::auth::session::layer as session_layer; + use crate::db::connect_in_memory; + use crate::repo::{SqlxConfigRepo, SqlxNavRepo}; + use std::sync::Arc; + let pool = connect_in_memory().await?; + let nav = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg = Arc::new(SqlxConfigRepo::new(pool.clone())); + let state = AppState::new(nav, cfg); + Ok(build_app(state, session_layer(pool, false))) +} +``` + +- [ ] **Step 5: Update `main.rs`** + +```rust +// server/src/main.rs (extend after `migrate(...)`) +use anyhow::Context; +use navsrv::{ + app::build_app, + auth::session::{layer as session_layer, run_pruner}, + config::Settings, + db::{connect, migrate}, + repo::{SqlxConfigRepo, SqlxNavRepo}, + services::bootstrap::ensure_admin_password, + state::AppState, +}; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::net::TcpListener; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let settings = Settings::load().context("load settings")?; + init_tracing(&settings.rust_log); + + let pool = connect(&settings.db_url()).await.context("db connect")?; + migrate(&pool).await.context("migrate")?; + + let nav = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg = Arc::new(SqlxConfigRepo::new(pool.clone())); + + ensure_admin_password(cfg.clone(), &settings.data_dir, settings.bootstrap_admin_password.clone()).await?; + + let state = AppState::new(nav, cfg); + let app = build_app(state, session_layer(pool.clone(), settings.secure_cookies)); + tokio::spawn(run_pruner(pool)); + + let addr = SocketAddr::from(([0, 0, 0, 0], settings.port)); + let listener = TcpListener::bind(addr).await.context("bind")?; + tracing::info!(%addr, "navsrv listening"); + axum::serve(listener, app).await.context("serve")?; + Ok(()) +} + +fn init_tracing(filter: &str) { + use tracing_subscriber::{fmt, EnvFilter}; + let env = EnvFilter::try_new(filter).unwrap_or_else(|_| EnvFilter::new("info")); + fmt().with_env_filter(env).with_target(false).compact().init(); +} +``` + +- [ ] **Step 6: Re-prepare and test** + +```bash +cd server +DATABASE_URL=sqlite:./dev-data/data.db cargo sqlx prepare -- --bin navsrv --tests +cargo test +cd .. +``` + +Expected: all green. + +- [ ] **Step 7: Commit** + +```bash +git add server/src/auth server/src/app.rs server/src/main.rs server/migrations/0001_init.sql server/.sqlx +git commit -m "feat(server): tower-sessions wired with SQLite store + 30d sliding cookie" +``` + +### Task 22: `POST /api/auth/login` + rate limit + +**Files:** +- Create: `server/src/routes/auth.rs` +- Modify: `server/src/routes/mod.rs` +- Create: `server/tests/api_auth.rs` + +- [ ] **Step 1: Write failing test** + +```rust +// server/tests/api_auth.rs +use axum_test::TestServer; +use navsrv::app::build_app_for_tests; +use navsrv::auth::password; +use navsrv::repo::{ConfigRepo, SqlxConfigRepo}; +use navsrv::db::connect_in_memory; + +async fn server_with_password(pw: &str) -> (TestServer, std::sync::Arc<dyn ConfigRepo>) { + use navsrv::app::build_app; + use navsrv::auth::session::layer as session_layer; + use navsrv::repo::SqlxNavRepo; + use navsrv::state::AppState; + use std::sync::Arc; + let pool = connect_in_memory().await.unwrap(); + let nav = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg: Arc<dyn ConfigRepo> = Arc::new(SqlxConfigRepo::new(pool.clone())); + cfg.upsert("admin_password_hash", &password::hash(pw).unwrap()).await.unwrap(); + let state = AppState::new(nav, cfg.clone()); + let app = build_app(state, session_layer(pool, false)); + (TestServer::new(app).unwrap(), cfg) +} + +#[tokio::test] +async fn login_with_correct_password_returns_204_and_cookie() { + let (server, _cfg) = server_with_password("hunter2").await; + let res = server.post("/api/auth/login").json(&serde_json::json!({"password":"hunter2"})).await; + res.assert_status(axum::http::StatusCode::NO_CONTENT); + assert!(res.cookie("__Host-sid").value().len() > 0); +} + +#[tokio::test] +async fn login_with_wrong_password_returns_401() { + let (server, _) = server_with_password("hunter2").await; + let res = server.post("/api/auth/login").json(&serde_json::json!({"password":"nope"})).await; + res.assert_status(axum::http::StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn me_unauth_returns_false() { + let app = build_app_for_tests().await.unwrap(); + let server = TestServer::new(app).unwrap(); + let res = server.get("/api/auth/me").await; + res.assert_status_ok(); + res.assert_json(&serde_json::json!({"authenticated": false})); +} + +#[tokio::test] +async fn logout_clears_session() { + let (mut server, _) = server_with_password("hunter2").await; + server.do_save_cookies(); + server.post("/api/auth/login").json(&serde_json::json!({"password":"hunter2"})).await + .assert_status(axum::http::StatusCode::NO_CONTENT); + server.get("/api/auth/me").await.assert_json(&serde_json::json!({"authenticated": true})); + server.post("/api/auth/logout").await.assert_status(axum::http::StatusCode::NO_CONTENT); + server.get("/api/auth/me").await.assert_json(&serde_json::json!({"authenticated": false})); +} +``` + +- [ ] **Step 2: Implement handlers** + +```rust +// server/src/routes/auth.rs +use axum::{extract::State, http::StatusCode, routing::{get, post}, Json, Router}; +use serde::Deserialize; +use serde_json::json; +use tower_sessions::Session; + +use crate::auth::password; +use crate::auth::session::SESSION_KEY_AUTHED; +use crate::error::{AppError, Result}; +use crate::state::AppState; + +pub fn router() -> Router<AppState> { + Router::new() + .route("/auth/login", post(login)) + .route("/auth/logout", post(logout)) + .route("/auth/me", get(me)) +} + +#[derive(Deserialize)] +struct LoginBody { password: String } + +async fn login( + State(s): State<AppState>, + session: Session, + Json(body): Json<LoginBody>, +) -> Result<StatusCode> { + let hashed = s.config.get("admin_password_hash").await? + .ok_or(AppError::Unauthenticated)?; + if !password::verify(&body.password, &hashed)? { + return Err(AppError::Unauthenticated); + } + session.insert(SESSION_KEY_AUTHED, true).await + .map_err(|e| AppError::Other(anyhow::anyhow!(e)))?; + Ok(StatusCode::NO_CONTENT) +} + +async fn logout(session: Session) -> Result<StatusCode> { + session.flush().await + .map_err(|e| AppError::Other(anyhow::anyhow!(e)))?; + Ok(StatusCode::NO_CONTENT) +} + +async fn me(session: Session) -> Result<Json<serde_json::Value>> { + let authed = session.get::<bool>(SESSION_KEY_AUTHED).await + .map_err(|e| AppError::Other(anyhow::anyhow!(e)))? + .unwrap_or(false); + Ok(Json(json!({ "authenticated": authed }))) +} +``` + +- [ ] **Step 3: Mount router** + +```rust +// server/src/routes/mod.rs (replace) +use axum::Router; + +pub mod auth; +pub mod health; +pub mod nav; + +use crate::state::AppState; + +pub fn api(state: AppState) -> Router { + Router::new() + .nest("/api", Router::new() + .merge(health::router()) + .merge(nav::router()) + .merge(auth::router())) + .with_state(state) +} +``` + +- [ ] **Step 4: Re-prepare and test** + +```bash +cd server +DATABASE_URL=sqlite:./dev-data/data.db cargo sqlx prepare -- --bin navsrv --tests +cargo test --test api_auth +cd .. +``` + +Expected: 4 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/src/routes server/tests/api_auth.rs server/.sqlx +git commit -m "feat(server): /api/auth login/logout/me with bcrypt + session" +``` + +### Task 23: `RequireAuth` extractor + +**Files:** +- Create: `server/src/auth/middleware.rs` +- Modify: `server/src/auth/mod.rs` + +- [ ] **Step 1: Write extractor** + +```rust +// server/src/auth/middleware.rs +use axum::async_trait; +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use tower_sessions::Session; + +use crate::auth::session::SESSION_KEY_AUTHED; +use crate::error::AppError; + +pub struct RequireAuth; + +#[async_trait] +impl<S> FromRequestParts<S> for RequireAuth +where + S: Send + Sync, +{ + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { + let session = Session::from_request_parts(parts, state) + .await + .map_err(|_| AppError::Unauthenticated)?; + let authed = session.get::<bool>(SESSION_KEY_AUTHED).await + .map_err(|_| AppError::Unauthenticated)? + .unwrap_or(false); + if authed { Ok(Self) } else { Err(AppError::Unauthenticated) } + } +} +``` + +- [ ] **Step 2: Re-export** + +```rust +// server/src/auth/mod.rs +pub mod middleware; +pub mod password; +pub mod session; +pub use middleware::RequireAuth; +``` + +- [ ] **Step 3: Compile-check** + +```bash +cd server +cargo check +cd .. +``` + +Expected: clean. + +- [ ] **Step 4: Commit** + +```bash +git add server/src/auth +git commit -m "feat(server): RequireAuth extractor returning 401 when unauthenticated" +``` + +### Task 24: Login rate limit (`tower-governor`) + +**Files:** +- Modify: `server/src/routes/auth.rs`, `server/src/routes/mod.rs` + +- [ ] **Step 1: Wrap login route with governor** + +```rust +// server/src/routes/auth.rs (top of file, replace router()) +use std::sync::Arc; +use tower_governor::governor::GovernorConfigBuilder; +use tower_governor::GovernorLayer; + +pub fn router() -> Router<AppState> { + let conf = GovernorConfigBuilder::default() + .per_second(180) // 5 attempts per 15 minutes ≈ 1 per 180s + .burst_size(5) + .finish() + .expect("governor config"); + let governor = GovernorLayer { config: Arc::new(conf) }; + + Router::new() + .route("/auth/login", post(login).layer(governor)) + .route("/auth/logout", post(logout)) + .route("/auth/me", get(me)) +} +``` + +- [ ] **Step 2: Map governor's response to `AppError::RateLimited`** + +`tower-governor` returns `429 Too Many Requests` automatically with a `text/plain` body. We accept that as-is (matches our error code semantically); no extra mapping needed. + +- [ ] **Step 3: Run tests (rate limit test would be flaky in CI; we keep it manual)** + +```bash +cd server +cargo test --test api_auth +cd .. +``` + +Expected: still PASS. + +- [ ] **Step 4: Commit** + +```bash +git add server/src/routes/auth.rs +git commit -m "feat(server): rate-limit /api/auth/login (5 burst per 15 min)" +``` + +### Task 25: `POST /api/config/password` + auto-delete `INITIAL_PASSWORD.txt` + +**Files:** +- Create: `server/src/routes/config.rs` +- Modify: `server/src/routes/mod.rs` +- Modify: `server/src/state.rs` (add data_dir reference) +- Create: `server/tests/api_config_password.rs` + +- [ ] **Step 1: Extend `AppState` to know `data_dir`** + +```rust +// server/src/state.rs +use crate::repo::{ConfigRepo, NavRepo}; +use std::path::PathBuf; +use std::sync::Arc; + +#[derive(Clone)] +pub struct AppState { + pub nav: Arc<dyn NavRepo>, + pub config: Arc<dyn ConfigRepo>, + pub data_dir: PathBuf, +} + +impl AppState { + pub fn new(nav: Arc<dyn NavRepo>, config: Arc<dyn ConfigRepo>, data_dir: PathBuf) -> Self { + Self { nav, config, data_dir } + } +} +``` + +- [ ] **Step 2: Update construction sites** + +In `server/src/main.rs` change `AppState::new(nav, cfg)` → `AppState::new(nav, cfg, settings.data_dir.clone())`. + +In `server/src/app.rs::build_app_for_tests`, set `data_dir = std::env::temp_dir()`: + +```rust + let state = AppState::new(nav, cfg.clone(), std::env::temp_dir()); +``` + +- [ ] **Step 3: Test first** + +```rust +// server/tests/api_config_password.rs +use axum_test::TestServer; +use navsrv::app::build_app; +use navsrv::auth::{password, session::layer as session_layer}; +use navsrv::db::connect_in_memory; +use navsrv::repo::{ConfigRepo, SqlxConfigRepo, SqlxNavRepo}; +use navsrv::state::AppState; +use std::sync::Arc; +use tempfile::TempDir; + +async fn boot() -> (TestServer, std::sync::Arc<dyn ConfigRepo>, TempDir) { + let pool = connect_in_memory().await.unwrap(); + let nav = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg: Arc<dyn ConfigRepo> = Arc::new(SqlxConfigRepo::new(pool.clone())); + cfg.upsert("admin_password_hash", &password::hash("old").unwrap()).await.unwrap(); + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("INITIAL_PASSWORD.txt"), "old").unwrap(); + let state = AppState::new(nav, cfg.clone(), dir.path().to_path_buf()); + let app = build_app(state, session_layer(pool, false)); + let mut server = TestServer::new(app).unwrap(); + server.do_save_cookies(); + server.post("/api/auth/login").json(&serde_json::json!({"password":"old"})).await + .assert_status_success(); + (server, cfg, dir) +} + +#[tokio::test] +async fn change_password_replaces_hash_and_deletes_initial_file() { + let (server, cfg, dir) = boot().await; + let res = server.post("/api/config/password") + .json(&serde_json::json!({"current":"old","next":"newPwLongEnough"})) + .await; + res.assert_status(axum::http::StatusCode::NO_CONTENT); + + let h = cfg.get("admin_password_hash").await.unwrap().unwrap(); + assert!(password::verify("newPwLongEnough", &h).unwrap()); + assert!(!password::verify("old", &h).unwrap()); + + assert!(!dir.path().join("INITIAL_PASSWORD.txt").exists(), + "INITIAL_PASSWORD.txt should be deleted after first successful change"); +} + +#[tokio::test] +async fn change_password_with_wrong_current_returns_401() { + let (server, _cfg, _dir) = boot().await; + server.post("/api/config/password") + .json(&serde_json::json!({"current":"WRONG","next":"newPwLongEnough"})) + .await + .assert_status(axum::http::StatusCode::UNAUTHORIZED); +} +``` + +- [ ] **Step 4: Implement** + +```rust +// server/src/routes/config.rs +use axum::{extract::State, http::StatusCode, routing::{patch, post}, Json, Router}; +use serde::Deserialize; + +use crate::auth::{password, RequireAuth}; +use crate::error::{AppError, Result}; +use crate::state::AppState; + +pub fn router() -> Router<AppState> { + Router::new() + .route("/config", patch(patch_config)) + .route("/config/password", post(change_password)) +} + +#[derive(Deserialize)] +struct ConfigEntry { key: String, value: String } + +async fn patch_config( + _auth: RequireAuth, + State(s): State<AppState>, + Json(items): Json<Vec<ConfigEntry>>, +) -> Result<StatusCode> { + let pairs: Vec<(&str, &str)> = items.iter().map(|i| (i.key.as_str(), i.value.as_str())).collect(); + s.config.upsert_many(&pairs).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Deserialize)] +struct ChangePassword { current: String, next: String } + +async fn change_password( + _auth: RequireAuth, + State(s): State<AppState>, + Json(body): Json<ChangePassword>, +) -> Result<StatusCode> { + if body.next.len() < 8 { + return Err(AppError::Validation("password must be at least 8 chars".into())); + } + let current_hash = s.config.get("admin_password_hash").await? + .ok_or(AppError::Unauthenticated)?; + if !password::verify(&body.current, ¤t_hash)? { + return Err(AppError::Unauthenticated); + } + let new_hash = password::hash(&body.next)?; + let now = chrono::Utc::now().timestamp_millis().to_string(); + s.config.upsert_many(&[ + ("admin_password_hash", &new_hash), + ("admin_password_updated_at", &now), + ]).await?; + + let initial = s.data_dir.join("INITIAL_PASSWORD.txt"); + if initial.exists() { + if let Err(e) = std::fs::remove_file(&initial) { + tracing::warn!(error=%e, ?initial, "failed to remove INITIAL_PASSWORD.txt"); + } + } + Ok(StatusCode::NO_CONTENT) +} +``` + +- [ ] **Step 5: Mount** + +```rust +// server/src/routes/mod.rs (replace) +use axum::Router; + +pub mod auth; +pub mod config; +pub mod health; +pub mod nav; + +use crate::state::AppState; + +pub fn api(state: AppState) -> Router { + Router::new() + .nest("/api", Router::new() + .merge(health::router()) + .merge(nav::router()) + .merge(auth::router()) + .merge(config::router())) + .with_state(state) +} +``` + +- [ ] **Step 6: Run tests** + +```bash +cd server +cargo test --test api_config_password +cd .. +``` + +Expected: 2 PASS. + +- [ ] **Step 7: Commit** + +```bash +git add server/src/routes server/src/state.rs server/src/main.rs server/src/app.rs server/tests/api_config_password.rs +git commit -m "feat(server): change-password endpoint deletes INITIAL_PASSWORD.txt on success" +``` + +### Task 26: CLI `navsrv reset-password` (clap-derive) + +**Files:** +- Create: `server/src/cli.rs` +- Modify: `server/src/main.rs`, `server/src/lib.rs` +- Create: `server/tests/cli.rs` + +- [ ] **Step 1: Write the CLI module** + +```rust +// server/src/cli.rs +use anyhow::Context; +use clap::{Parser, Subcommand}; +use std::io::{self, Write}; + +use crate::auth::password; +use crate::config::Settings; +use crate::db::{connect, migrate}; +use crate::repo::{ConfigRepo, SqlxConfigRepo}; + +#[derive(Parser, Debug)] +#[command(name = "navsrv", version)] +pub struct Cli { + #[command(subcommand)] + pub command: Option<Command>, +} + +#[derive(Subcommand, Debug)] +pub enum Command { + /// Reset the admin password. Reads new password from --password or stdin. + ResetPassword { + /// New password. If omitted, prompted on stdin. + #[arg(long)] + password: Option<String>, + }, +} + +pub async fn run_command(cmd: Command) -> anyhow::Result<()> { + match cmd { + Command::ResetPassword { password: maybe } => { + let settings = Settings::load()?; + let pool = connect(&settings.db_url()).await.context("db connect")?; + migrate(&pool).await.context("migrate")?; + let cfg: std::sync::Arc<dyn ConfigRepo> = + std::sync::Arc::new(SqlxConfigRepo::new(pool.clone())); + let pw = match maybe { + Some(p) => p, + None => { + let mut buf = String::new(); + print!("New admin password: "); io::stdout().flush().ok(); + io::stdin().read_line(&mut buf).context("read stdin")?; + buf.trim().to_string() + } + }; + if pw.len() < 8 { + anyhow::bail!("password must be at least 8 chars"); + } + let hash = password::hash(&pw)?; + let now = chrono::Utc::now().timestamp_millis().to_string(); + cfg.upsert_many(&[ + ("admin_password_hash", &hash), + ("admin_password_updated_at", &now), + ]).await?; + + // Invalidate all sessions. + sqlx::query("DELETE FROM tower_sessions").execute(&pool).await?; + + // Remove leftover INITIAL_PASSWORD.txt if any. + let initial = settings.data_dir.join("INITIAL_PASSWORD.txt"); + if initial.exists() { let _ = std::fs::remove_file(&initial); } + + println!("Admin password reset; all sessions invalidated."); + Ok(()) + } + } +} +``` + +- [ ] **Step 2: Re-export** + +```rust +// server/src/lib.rs (add `cli`) +pub mod app; +pub mod auth; +pub mod cli; +pub mod config; +pub mod db; +pub mod dto; +pub mod error; +pub mod repo; +pub mod routes; +pub mod services; +pub mod state; +``` + +- [ ] **Step 3: Branch in `main.rs`** + +```rust +// server/src/main.rs (replace) +use anyhow::Context; +use clap::Parser; +use navsrv::{ + app::build_app, + auth::session::{layer as session_layer, run_pruner}, + cli::{Cli, Command, run_command}, + config::Settings, + db::{connect, migrate}, + repo::{SqlxConfigRepo, SqlxNavRepo}, + services::bootstrap::ensure_admin_password, + state::AppState, +}; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::net::TcpListener; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + let settings = Settings::load().context("load settings")?; + init_tracing(&settings.rust_log); + + if let Some(cmd) = cli.command { + return run_command(cmd).await; + } + + let pool = connect(&settings.db_url()).await.context("db connect")?; + migrate(&pool).await.context("migrate")?; + + let nav = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg = Arc::new(SqlxConfigRepo::new(pool.clone())); + + ensure_admin_password(cfg.clone(), &settings.data_dir, settings.bootstrap_admin_password.clone()).await?; + + let state = AppState::new(nav, cfg, settings.data_dir.clone()); + let app = build_app(state, session_layer(pool.clone(), settings.secure_cookies)); + tokio::spawn(run_pruner(pool)); + + let addr = SocketAddr::from(([0, 0, 0, 0], settings.port)); + let listener = TcpListener::bind(addr).await.context("bind")?; + tracing::info!(%addr, "navsrv listening"); + axum::serve(listener, app).await.context("serve")?; + Ok(()) +} + +fn init_tracing(filter: &str) { + use tracing_subscriber::{fmt, EnvFilter}; + let env = EnvFilter::try_new(filter).unwrap_or_else(|_| EnvFilter::new("info")); + fmt().with_env_filter(env).with_target(false).compact().init(); +} +``` + +- [ ] **Step 4: Integration test driving the CLI directly** + +```rust +// server/tests/cli.rs +use navsrv::auth::password; +use navsrv::cli::{run_command, Command}; +use navsrv::config::Settings; + +#[tokio::test] +async fn reset_password_flow() { + let dir = tempfile::TempDir::new().unwrap(); + std::env::set_var("DATA_DIR", dir.path()); + std::env::remove_var("BOOTSTRAP_ADMIN_PASSWORD"); + std::env::set_var("RUST_LOG", "warn"); + // Touch a fake initial password file to ensure it gets removed. + std::fs::write(dir.path().join("INITIAL_PASSWORD.txt"), "old").unwrap(); + + run_command(Command::ResetPassword { password: Some("brand-new-pw".into()) }).await.unwrap(); + + let s = Settings::load().unwrap(); + let pool = navsrv::db::connect(&s.db_url()).await.unwrap(); + let h: (String,) = sqlx::query_as("SELECT value FROM config WHERE key='admin_password_hash'") + .fetch_one(&pool).await.unwrap(); + assert!(password::verify("brand-new-pw", &h.0).unwrap()); + assert!(!dir.path().join("INITIAL_PASSWORD.txt").exists()); + + // cleanup env + std::env::remove_var("DATA_DIR"); +} +``` + +> Note: this test mutates process env; mark it `#[serial]` if more env-touching tests are added later. + +- [ ] **Step 5: Run** + +```bash +cd server +cargo test --test cli +cd .. +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add server/src/cli.rs server/src/main.rs server/src/lib.rs server/tests/cli.rs +git commit -m "feat(server): CLI 'reset-password' subcommand invalidates sessions" +``` + +--- + +## Phase 5: CRUD Endpoints (Tasks 27–32) + +Goal: write endpoints for items / groups / sites / tags / icons / favicon, all gated by `RequireAuth`. + +### Task 27: items CRUD + reorder endpoints + +**Files:** +- Create: `server/src/routes/items.rs` +- Modify: `server/src/routes/mod.rs` +- Create: `server/tests/api_items.rs` + +- [ ] **Step 1: Test first** + +```rust +// server/tests/api_items.rs +use axum_test::TestServer; +use navsrv::app::build_app; +use navsrv::auth::{password, session::layer as session_layer}; +use navsrv::db::connect_in_memory; +use navsrv::repo::{ConfigRepo, SqlxConfigRepo, SqlxNavRepo, NavRepo}; +use navsrv::state::AppState; +use navsrv::dto::{GroupPayload, SitePayload}; +use std::sync::Arc; + +async fn auth_server() -> (TestServer, i64) { + let pool = connect_in_memory().await.unwrap(); + let nav: Arc<dyn NavRepo> = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg: Arc<dyn ConfigRepo> = Arc::new(SqlxConfigRepo::new(pool.clone())); + cfg.upsert("admin_password_hash", &password::hash("pw").unwrap()).await.unwrap(); + let g = nav.create_group(GroupPayload { slug: "tools".into(), name:"Tools".into(), name_i18n: None, collapsed_default: false }).await.unwrap(); + nav.create_site(SitePayload { value: "shangHai".into(), name:"上海".into(), name_i18n: None, is_default: true }).await.unwrap(); + + let state = AppState::new(nav, cfg, std::env::temp_dir()); + let app = build_app(state, session_layer(pool, false)); + let mut server = TestServer::new(app).unwrap(); + server.do_save_cookies(); + server.post("/api/auth/login").json(&serde_json::json!({"password":"pw"})).await + .assert_status_success(); + (server, g.id) +} + +#[tokio::test] +async fn create_then_list_via_bundle() { + let (server, gid) = auth_server().await; + let body = serde_json::json!({ + "groupId": gid, + "name": "Router", + "iconKind": "asset", + "iconValue": "router.png", + "links": { "shangHai": "http://10.0.0.1" }, + "tagSlugs": [] + }); + let res = server.post("/api/items").json(&body).await; + res.assert_status(axum::http::StatusCode::CREATED); + let v: serde_json::Value = res.json(); + let id = v["id"].as_i64().unwrap(); + assert_eq!(v["name"], "Router"); + + let bundle: serde_json::Value = server.get("/api/nav").await.json(); + let item = bundle["items"].as_array().unwrap().iter().find(|i| i["id"].as_i64() == Some(id)).unwrap(); + assert_eq!(item["links"]["shangHai"], "http://10.0.0.1"); +} + +#[tokio::test] +async fn patch_changes_name() { + let (server, gid) = auth_server().await; + let id = server.post("/api/items").json(&serde_json::json!({ + "groupId": gid, "name": "old", "iconKind":"asset", "iconValue":"x.png" + })).await.json::<serde_json::Value>()["id"].as_i64().unwrap(); + + let res = server.patch(&format!("/api/items/{id}")) + .json(&serde_json::json!({ "name": "new" })).await; + res.assert_status_ok(); + let v: serde_json::Value = res.json(); + assert_eq!(v["name"], "new"); +} + +#[tokio::test] +async fn delete_removes_item() { + let (server, gid) = auth_server().await; + let id = server.post("/api/items").json(&serde_json::json!({ + "groupId": gid, "name": "x", "iconKind":"asset", "iconValue":"x.png" + })).await.json::<serde_json::Value>()["id"].as_i64().unwrap(); + server.delete(&format!("/api/items/{id}")).await.assert_status(axum::http::StatusCode::NO_CONTENT); + + let bundle: serde_json::Value = server.get("/api/nav").await.json(); + assert!(bundle["items"].as_array().unwrap().iter().all(|i| i["id"].as_i64() != Some(id))); +} + +#[tokio::test] +async fn unauthed_returns_401() { + let pool = connect_in_memory().await.unwrap(); + let nav: Arc<dyn NavRepo> = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg: Arc<dyn ConfigRepo> = Arc::new(SqlxConfigRepo::new(pool.clone())); + let state = AppState::new(nav, cfg, std::env::temp_dir()); + let app = build_app(state, session_layer(pool, false)); + let server = TestServer::new(app).unwrap(); + server.post("/api/items").json(&serde_json::json!({ + "name":"x","iconKind":"asset","iconValue":"x.png" + })).await.assert_status(axum::http::StatusCode::UNAUTHORIZED); +} +``` + +- [ ] **Step 2: Implement handlers** + +```rust +// server/src/routes/items.rs +use axum::{extract::{Path, State}, http::StatusCode, routing::{delete, patch, post}, Json, Router}; + +use crate::auth::RequireAuth; +use crate::dto::{Item, ItemPatch, ItemPayload, ReorderEntry}; +use crate::error::Result; +use crate::state::AppState; + +pub fn router() -> Router<AppState> { + Router::new() + .route("/items", post(create)) + .route("/items/reorder", post(reorder)) + .route("/items/:id", patch(update).delete(remove)) +} + +async fn create(_auth: RequireAuth, State(s): State<AppState>, Json(body): Json<ItemPayload>) -> Result<(StatusCode, Json<Item>)> { + let item = s.nav.create_item(body).await?; + Ok((StatusCode::CREATED, Json(item))) +} + +async fn update(_auth: RequireAuth, State(s): State<AppState>, Path(id): Path<i64>, Json(body): Json<ItemPatch>) -> Result<Json<Item>> { + Ok(Json(s.nav.patch_item(id, body).await?)) +} + +async fn remove(_auth: RequireAuth, State(s): State<AppState>, Path(id): Path<i64>) -> Result<StatusCode> { + s.nav.delete_item(id).await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn reorder(_auth: RequireAuth, State(s): State<AppState>, Json(entries): Json<Vec<ReorderEntry>>) -> Result<StatusCode> { + s.nav.reorder_items(entries).await?; + Ok(StatusCode::NO_CONTENT) +} +``` + +- [ ] **Step 3: Mount** + +```rust +// server/src/routes/mod.rs (replace) +use axum::Router; + +pub mod auth; +pub mod config; +pub mod health; +pub mod items; +pub mod nav; + +use crate::state::AppState; + +pub fn api(state: AppState) -> Router { + Router::new() + .nest("/api", Router::new() + .merge(health::router()) + .merge(nav::router()) + .merge(auth::router()) + .merge(config::router()) + .merge(items::router())) + .with_state(state) +} +``` + +- [ ] **Step 4: Run** + +```bash +cd server +cargo test --test api_items +cd .. +``` + +Expected: 4 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/src/routes server/tests/api_items.rs +git commit -m "feat(server): /api/items CRUD + reorder behind RequireAuth" +``` + +### Task 28: groups, sites, tags CRUD endpoints + +**Files:** +- Create: `server/src/routes/groups.rs`, `server/src/routes/sites.rs`, `server/src/routes/tags.rs` +- Modify: `server/src/routes/mod.rs` +- Create: `server/tests/api_groups_sites_tags.rs` + +- [ ] **Step 1: groups.rs** + +```rust +// server/src/routes/groups.rs +use axum::{extract::{Path, State}, http::StatusCode, routing::{patch, post}, Json, Router}; + +use crate::auth::RequireAuth; +use crate::dto::{Group, GroupPatch, GroupPayload, ReorderEntry}; +use crate::error::Result; +use crate::state::AppState; + +pub fn router() -> Router<AppState> { + Router::new() + .route("/groups", post(create)) + .route("/groups/reorder", post(reorder)) + .route("/groups/:id", patch(update).delete(remove)) +} + +async fn create(_a: RequireAuth, State(s): State<AppState>, Json(body): Json<GroupPayload>) -> Result<(StatusCode, Json<Group>)> { + Ok((StatusCode::CREATED, Json(s.nav.create_group(body).await?))) +} +async fn update(_a: RequireAuth, State(s): State<AppState>, Path(id): Path<i64>, Json(body): Json<GroupPatch>) -> Result<Json<Group>> { + Ok(Json(s.nav.patch_group(id, body).await?)) +} +async fn remove(_a: RequireAuth, State(s): State<AppState>, Path(id): Path<i64>) -> Result<StatusCode> { + s.nav.delete_group(id).await?; Ok(StatusCode::NO_CONTENT) +} +async fn reorder(_a: RequireAuth, State(s): State<AppState>, Json(entries): Json<Vec<ReorderEntry>>) -> Result<StatusCode> { + s.nav.reorder_groups(entries).await?; Ok(StatusCode::NO_CONTENT) +} +``` + +- [ ] **Step 2: sites.rs** + +```rust +// server/src/routes/sites.rs +use axum::{extract::{Path, State}, http::StatusCode, routing::{patch, post}, Json, Router}; + +use crate::auth::RequireAuth; +use crate::dto::{ReorderEntry, Site, SitePatch, SitePayload}; +use crate::error::Result; +use crate::state::AppState; + +pub fn router() -> Router<AppState> { + Router::new() + .route("/sites", post(create)) + .route("/sites/reorder", post(reorder)) + .route("/sites/:id", patch(update).delete(remove)) +} + +async fn create(_a: RequireAuth, State(s): State<AppState>, Json(body): Json<SitePayload>) -> Result<(StatusCode, Json<Site>)> { + Ok((StatusCode::CREATED, Json(s.nav.create_site(body).await?))) +} +async fn update(_a: RequireAuth, State(s): State<AppState>, Path(id): Path<i64>, Json(body): Json<SitePatch>) -> Result<Json<Site>> { + Ok(Json(s.nav.patch_site(id, body).await?)) +} +async fn remove(_a: RequireAuth, State(s): State<AppState>, Path(id): Path<i64>) -> Result<StatusCode> { + s.nav.delete_site(id).await?; Ok(StatusCode::NO_CONTENT) +} +async fn reorder(_a: RequireAuth, State(s): State<AppState>, Json(entries): Json<Vec<ReorderEntry>>) -> Result<StatusCode> { + s.nav.reorder_sites(entries).await?; Ok(StatusCode::NO_CONTENT) +} +``` + +- [ ] **Step 3: tags.rs** + +```rust +// server/src/routes/tags.rs +use axum::{extract::{Path, State}, http::StatusCode, routing::{patch, post}, Json, Router}; + +use crate::auth::RequireAuth; +use crate::dto::{Tag, TagPatch, TagPayload}; +use crate::error::Result; +use crate::state::AppState; + +pub fn router() -> Router<AppState> { + Router::new() + .route("/tags", post(create)) + .route("/tags/:id", patch(update).delete(remove)) +} + +async fn create(_a: RequireAuth, State(s): State<AppState>, Json(body): Json<TagPayload>) -> Result<(StatusCode, Json<Tag>)> { + Ok((StatusCode::CREATED, Json(s.nav.create_tag(body).await?))) +} +async fn update(_a: RequireAuth, State(s): State<AppState>, Path(id): Path<i64>, Json(body): Json<TagPatch>) -> Result<Json<Tag>> { + Ok(Json(s.nav.patch_tag(id, body).await?)) +} +async fn remove(_a: RequireAuth, State(s): State<AppState>, Path(id): Path<i64>) -> Result<StatusCode> { + s.nav.delete_tag(id).await?; Ok(StatusCode::NO_CONTENT) +} +``` + +- [ ] **Step 4: Mount** + +```rust +// server/src/routes/mod.rs (replace) +use axum::Router; + +pub mod auth; +pub mod config; +pub mod groups; +pub mod health; +pub mod items; +pub mod nav; +pub mod sites; +pub mod tags; + +use crate::state::AppState; + +pub fn api(state: AppState) -> Router { + Router::new() + .nest("/api", Router::new() + .merge(health::router()) + .merge(nav::router()) + .merge(auth::router()) + .merge(config::router()) + .merge(items::router()) + .merge(groups::router()) + .merge(sites::router()) + .merge(tags::router())) + .with_state(state) +} +``` + +- [ ] **Step 5: Smoke-test** + +```rust +// server/tests/api_groups_sites_tags.rs +use axum_test::TestServer; +use navsrv::app::build_app; +use navsrv::auth::{password, session::layer as session_layer}; +use navsrv::db::connect_in_memory; +use navsrv::repo::{ConfigRepo, SqlxConfigRepo, SqlxNavRepo}; +use navsrv::state::AppState; +use std::sync::Arc; + +async fn boot() -> TestServer { + let pool = connect_in_memory().await.unwrap(); + let nav = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg: Arc<dyn ConfigRepo> = Arc::new(SqlxConfigRepo::new(pool.clone())); + cfg.upsert("admin_password_hash", &password::hash("pw").unwrap()).await.unwrap(); + let state = AppState::new(nav, cfg, std::env::temp_dir()); + let app = build_app(state, session_layer(pool, false)); + let mut server = TestServer::new(app).unwrap(); + server.do_save_cookies(); + server.post("/api/auth/login").json(&serde_json::json!({"password":"pw"})).await.assert_status_success(); + server +} + +#[tokio::test] +async fn group_lifecycle() { + let server = boot().await; + let g: serde_json::Value = server.post("/api/groups").json(&serde_json::json!({ + "slug":"net","name":"Network" + })).await.json(); + let id = g["id"].as_i64().unwrap(); + server.patch(&format!("/api/groups/{id}")).json(&serde_json::json!({"name":"NET"})).await.assert_status_ok(); + server.delete(&format!("/api/groups/{id}")).await.assert_status(axum::http::StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn site_lifecycle() { + let server = boot().await; + let s: serde_json::Value = server.post("/api/sites").json(&serde_json::json!({ + "value":"sh","name":"Shanghai" + })).await.json(); + let id = s["id"].as_i64().unwrap(); + server.patch(&format!("/api/sites/{id}")).json(&serde_json::json!({"name":"SH"})).await.assert_status_ok(); + server.delete(&format!("/api/sites/{id}")).await.assert_status(axum::http::StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn tag_lifecycle() { + let server = boot().await; + let t: serde_json::Value = server.post("/api/tags").json(&serde_json::json!({ + "slug":"fav","name":"Favorite" + })).await.json(); + let id = t["id"].as_i64().unwrap(); + server.patch(&format!("/api/tags/{id}")).json(&serde_json::json!({"name":"⭐"})).await.assert_status_ok(); + server.delete(&format!("/api/tags/{id}")).await.assert_status(axum::http::StatusCode::NO_CONTENT); +} +``` + +- [ ] **Step 6: Run** + +```bash +cd server +cargo test --test api_groups_sites_tags +cd .. +``` + +Expected: 3 PASS. + +- [ ] **Step 7: Commit** + +```bash +git add server/src/routes server/tests/api_groups_sites_tags.rs +git commit -m "feat(server): groups/sites/tags CRUD endpoints" +``` + +### Task 29: Icon upload endpoint (multipart) + +**Files:** +- Create: `server/src/routes/icons.rs` +- Modify: `server/src/routes/mod.rs` +- Create: `server/tests/api_icons.rs` + +- [ ] **Step 1: Implement** + +```rust +// server/src/routes/icons.rs +use axum::{ + extract::{Multipart, State}, + http::StatusCode, + routing::post, + Json, Router, +}; +use rand::distributions::{Alphanumeric, DistString}; +use serde_json::{json, Value}; + +use crate::auth::RequireAuth; +use crate::error::{AppError, Result}; +use crate::state::AppState; + +pub fn router() -> Router<AppState> { + Router::new().route("/icons/upload", post(upload)) +} + +async fn upload( + _auth: RequireAuth, + State(s): State<AppState>, + mut form: Multipart, +) -> Result<(StatusCode, Json<Value>)> { + let icons_dir = s.data_dir.join("icons"); + std::fs::create_dir_all(&icons_dir)?; + + while let Some(field) = form.next_field().await + .map_err(|e| AppError::Validation(format!("multipart: {e}")))? + { + if field.name() != Some("file") { continue; } + let filename = field.file_name().map(str::to_owned).unwrap_or_else(|| "icon".into()); + let ext = std::path::Path::new(&filename).extension() + .and_then(|s| s.to_str()).unwrap_or("png").to_lowercase(); + if !matches!(ext.as_str(), "png"|"jpg"|"jpeg"|"webp"|"svg"|"gif") { + return Err(AppError::Validation("unsupported icon type".into())); + } + let mime = field.content_type().map(str::to_owned); + let data = field.bytes().await + .map_err(|e| AppError::Validation(format!("multipart read: {e}")))?; + if data.len() > 1024 * 1024 { + return Err(AppError::Validation("icon larger than 1MiB".into())); + } + let nonce = Alphanumeric.sample_string(&mut rand::thread_rng(), 8); + let stored = format!("{nonce}.{ext}"); + let path = icons_dir.join(&stored); + std::fs::write(&path, &data)?; + let public_path = format!("/icons/{stored}"); + return Ok((StatusCode::CREATED, Json(json!({ + "path": public_path, + "size": data.len(), + "mime": mime + })))); + } + Err(AppError::Validation("no `file` field".into())) +} +``` + +- [ ] **Step 2: Mount + test** + +```rust +// server/src/routes/mod.rs add `pub mod icons;` and `.merge(icons::router())` +``` + +```rust +// server/tests/api_icons.rs +use axum_test::TestServer; +use navsrv::app::build_app; +use navsrv::auth::{password, session::layer as session_layer}; +use navsrv::db::connect_in_memory; +use navsrv::repo::{ConfigRepo, SqlxConfigRepo, SqlxNavRepo}; +use navsrv::state::AppState; +use std::sync::Arc; + +#[tokio::test] +async fn upload_writes_file_under_data_dir_icons() { + let pool = connect_in_memory().await.unwrap(); + let nav = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg: Arc<dyn ConfigRepo> = Arc::new(SqlxConfigRepo::new(pool.clone())); + cfg.upsert("admin_password_hash", &password::hash("pw").unwrap()).await.unwrap(); + let dir = tempfile::TempDir::new().unwrap(); + let state = AppState::new(nav, cfg, dir.path().to_path_buf()); + let app = build_app(state, session_layer(pool, false)); + let mut server = TestServer::new(app).unwrap(); + server.do_save_cookies(); + server.post("/api/auth/login").json(&serde_json::json!({"password":"pw"})).await.assert_status_success(); + + let res = server.post("/api/icons/upload") + .multipart(axum_test::multipart::MultipartForm::new() + .add_part( + "file", + axum_test::multipart::Part::bytes(b"\x89PNG\r\n\x1a\n".to_vec()) + .file_name("hello.png") + .mime_type("image/png"), + )).await; + res.assert_status(axum::http::StatusCode::CREATED); + let body: serde_json::Value = res.json(); + let path = body["path"].as_str().unwrap(); + assert!(path.starts_with("/icons/")); + assert!(dir.path().join("icons").join(path.trim_start_matches("/icons/")).exists()); +} +``` + +- [ ] **Step 3: Run** + +```bash +cd server +cargo test --test api_icons +cd .. +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add server/src/routes server/tests/api_icons.rs +git commit -m "feat(server): /api/icons/upload (multipart, ≤1MiB, ext-allow-list)" +``` + +### Task 30: Favicon proxy with cache + +**Files:** +- Create: `server/src/services/favicon.rs` +- Create: `server/src/routes/favicon.rs` +- Modify: `server/src/services/mod.rs`, `server/src/routes/mod.rs`, `server/src/state.rs` + +- [ ] **Step 1: Service** + +```rust +// server/src/services/favicon.rs +use crate::error::{AppError, Result}; +use bytes::Bytes; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +const TTL: Duration = Duration::from_secs(7 * 24 * 3600); // 7 days +const MAX_BYTES: u64 = 256 * 1024; // 256 KiB + +pub struct FaviconService { cache_dir: PathBuf, http: reqwest::Client } + +impl FaviconService { + pub fn new(cache_dir: PathBuf) -> Self { + std::fs::create_dir_all(&cache_dir).ok(); + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .user_agent("navsrv-favicon/0.1") + .build().expect("http client"); + Self { cache_dir, http } + } + + pub async fn fetch(&self, host: &str) -> Result<(String, Bytes)> { + if !is_safe_host(host) { + return Err(AppError::Validation("bad host".into())); + } + let cached = self.cache_dir.join(host); + if let Ok(meta) = std::fs::metadata(&cached) { + if let Ok(age) = SystemTime::now().duration_since(meta.modified()?) { + if age < TTL { + let bytes = Bytes::from(std::fs::read(&cached)?); + return Ok(("image/png".into(), bytes)); + } + } + } + let url = format!("https://www.google.com/s2/favicons?domain={host}&sz=64"); + let resp = self.http.get(&url).send().await + .map_err(|e| AppError::Other(anyhow::anyhow!(e)))?; + if !resp.status().is_success() { + return Err(AppError::NotFound); + } + let bytes = resp.bytes().await + .map_err(|e| AppError::Other(anyhow::anyhow!(e)))?; + if bytes.len() as u64 > MAX_BYTES { + return Err(AppError::Validation("favicon too large".into())); + } + std::fs::write(&cached, &bytes)?; + Ok(("image/png".into(), bytes)) + } +} + +fn is_safe_host(host: &str) -> bool { + !host.is_empty() + && host.len() <= 253 + && host.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') +} + +pub fn cache_dir(data_dir: &Path) -> PathBuf { data_dir.join("icons").join("_cache") } +``` + +- [ ] **Step 2: Route** + +```rust +// server/src/routes/favicon.rs +use axum::{ + extract::{Query, State}, + http::{header, HeaderMap, HeaderValue}, + response::IntoResponse, + routing::get, + Router, +}; +use serde::Deserialize; + +use crate::error::Result; +use crate::services::favicon::FaviconService; +use crate::state::AppState; +use std::sync::Arc; + +pub fn router() -> Router<AppState> { + Router::new().route("/favicon", get(favicon)) +} + +#[derive(Deserialize)] +struct Q { host: String } + +async fn favicon(State(s): State<AppState>, Query(q): Query<Q>) -> Result<impl IntoResponse> { + let svc: &Arc<FaviconService> = &s.favicon; + let (mime, bytes) = svc.fetch(&q.host).await?; + let mut h = HeaderMap::new(); + h.insert(header::CONTENT_TYPE, HeaderValue::from_str(&mime).unwrap()); + h.insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=86400")); + Ok((h, bytes)) +} +``` + +- [ ] **Step 3: Add `favicon` to `AppState`** + +```rust +// server/src/state.rs +use crate::repo::{ConfigRepo, NavRepo}; +use crate::services::favicon::FaviconService; +use std::path::PathBuf; +use std::sync::Arc; + +#[derive(Clone)] +pub struct AppState { + pub nav: Arc<dyn NavRepo>, + pub config: Arc<dyn ConfigRepo>, + pub data_dir: PathBuf, + pub favicon: Arc<FaviconService>, +} + +impl AppState { + pub fn new( + nav: Arc<dyn NavRepo>, + config: Arc<dyn ConfigRepo>, + data_dir: PathBuf, + ) -> Self { + let favicon = Arc::new(FaviconService::new( + crate::services::favicon::cache_dir(&data_dir), + )); + Self { nav, config, data_dir, favicon } + } +} +``` + +- [ ] **Step 4: Mount + re-export** + +```rust +// server/src/services/mod.rs +pub mod bootstrap; +pub mod bundle; +pub mod favicon; +``` + +```rust +// server/src/routes/mod.rs (add `pub mod favicon;` and `.merge(favicon::router())`) +``` + +- [ ] **Step 5: Compile-check (no network test — would be flaky)** + +```bash +cd server +cargo check +cd .. +``` + +Expected: clean. + +- [ ] **Step 6: Commit** + +```bash +git add server/src/services server/src/routes server/src/state.rs +git commit -m "feat(server): /api/favicon proxy with 7d disk cache (size+host guarded)" +``` + +--- + +## Phase 6: Static Frontend Hosting + Bootstrap Seed (Tasks 31–33) + +Goal: serve the SPA build, plus seed the DB from a `bootstrap.json` produced by the frontend pipeline. End state: `cargo run` produces a working app at `:8080` for the frontend (built later) to consume; first boot pre-populates the spec's default groups + items. + +### Task 31: `ServeDir` + SPA fallback + +**Files:** +- Modify: `server/src/app.rs`, `server/src/main.rs` + +- [ ] **Step 1: Add static file mount** + +```rust +// server/src/app.rs (replace) +use axum::http::{header, HeaderValue, Request}; +use axum::response::IntoResponse; +use axum::Router; +use std::path::PathBuf; +use tower_http::compression::CompressionLayer; +use tower_http::services::{ServeDir, ServeFile}; +use tower_http::set_header::SetResponseHeaderLayer; +use tower_http::trace::{DefaultOnResponse, TraceLayer}; +use tower_sessions::SessionManagerLayer; +use tower_sessions_sqlx_store::SqliteStore; +use tracing::Level; + +use crate::routes; +use crate::state::AppState; + +pub fn build_app( + state: AppState, + session_layer: SessionManagerLayer<SqliteStore>, + static_dir: PathBuf, +) -> Router { + let trace = TraceLayer::new_for_http() + .make_span_with(|req: &Request<_>| { + let method = req.method(); + let uri = req.uri().path(); + tracing::info_span!("http", %method, %uri) + }) + .on_response(DefaultOnResponse::new().level(Level::INFO)); + + let index_html = static_dir.join("index.html"); + let serve_dir = ServeDir::new(static_dir.clone()).fallback(ServeFile::new(index_html)); + + Router::new() + .merge(routes::api(state.clone())) + .with_state(state) + .nest_service("/icons", ServeDir::new(static_dir.parent().map(|p| p.join("data/icons")).unwrap_or_else(|| PathBuf::from("./data/icons")))) + .fallback_service(serve_dir) + .layer(session_layer) + .layer(trace) + .layer(CompressionLayer::new()) + .layer(SetResponseHeaderLayer::if_not_present( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + header::REFERRER_POLICY, + HeaderValue::from_static("same-origin"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + header::X_FRAME_OPTIONS, + HeaderValue::from_static("DENY"), + )) +} + +pub async fn build_app_for_tests() -> anyhow::Result<Router> { + use crate::auth::session::layer as session_layer; + use crate::db::connect_in_memory; + use crate::repo::{SqlxConfigRepo, SqlxNavRepo}; + use std::sync::Arc; + let pool = connect_in_memory().await?; + let nav = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg = Arc::new(SqlxConfigRepo::new(pool.clone())); + let dir = std::env::temp_dir(); + let state = AppState::new(nav, cfg, dir.clone()); + Ok(build_app(state, session_layer(pool, false), dir)) +} +``` + +> Note: `/icons` serves the user-uploaded files from `data_dir/icons`, while `static_dir/icons` (frontend bundled icons) is served by the SPA `ServeDir` fallback. Frontend chooses the URL per `iconKind`. + +- [ ] **Step 2: Pass `static_dir` from `main.rs`** + +```rust +// server/src/main.rs (in main fn, replace build_app call) +let app = build_app( + state, + session_layer(pool.clone(), settings.secure_cookies), + settings.static_dir.clone(), +); +``` + +- [ ] **Step 3: Smoke-test SPA fallback** + +Add `server/tests/spa_fallback.rs`: + +```rust +use axum_test::TestServer; +use navsrv::app::build_app; +use navsrv::auth::session::layer as session_layer; +use navsrv::db::connect_in_memory; +use navsrv::repo::{SqlxConfigRepo, SqlxNavRepo}; +use navsrv::state::AppState; +use std::sync::Arc; +use tempfile::TempDir; + +#[tokio::test] +async fn unknown_path_falls_back_to_index_html() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("index.html"), "<html><body>SPA</body></html>").unwrap(); + + let pool = connect_in_memory().await.unwrap(); + let nav = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg = Arc::new(SqlxConfigRepo::new(pool.clone())); + let state = AppState::new(nav, cfg, dir.path().to_path_buf()); + + let app = build_app(state, session_layer(pool, false), dir.path().to_path_buf()); + let server = TestServer::new(app).unwrap(); + let res = server.get("/some/spa/route").await; + res.assert_status_ok(); + assert!(res.text().contains("SPA")); +} +``` + +- [ ] **Step 4: Run** + +```bash +cd server +cargo test --test spa_fallback +cd .. +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/src/app.rs server/src/main.rs server/tests/spa_fallback.rs +git commit -m "feat(server): ServeDir + SPA fallback; user icons served from /icons" +``` + +### Task 32: `bootstrap.json` seed (build.rs + MigrationService) + +**Files:** +- Create: `server/build.rs` +- Create: `server/bootstrap.json` (placeholder for tests; real one comes from frontend stage in Plan 5) +- Create: `server/src/services/migration.rs` +- Modify: `server/src/services/mod.rs`, `server/src/main.rs` +- Create: `server/tests/seed.rs` + +- [ ] **Step 1: Write a placeholder `bootstrap.json` schema** + +```json +{ + "schemaVersion": 1, + "meta": { + "siteName": "Navigation", + "siteAvatarPath": "/avatar.png", + "siteCopyright": "Copyright © 2026 — All rights reserved.", + "siteIcp": null, + "sitePolice": null, + "defaultTheme": "system" + }, + "sites": [ + { "value": "shangHai", "name": "上海", "name_i18n": { "en": "Shanghai" }, "is_default": true, "sort_order": 0 }, + { "value": "beiJing", "name": "北京", "name_i18n": { "en": "Beijing" }, "is_default": false, "sort_order": 1 } + ], + "groups": [ + { "slug": "network", "name": "网络", "name_i18n": { "en": "Network" }, "sort_order": 0 }, + { "slug": "media", "name": "媒体", "name_i18n": { "en": "Media" }, "sort_order": 1 }, + { "slug": "nas", "name": "NAS", "name_i18n": { "en": "NAS" }, "sort_order": 2 }, + { "slug": "tools", "name": "工具", "name_i18n": { "en": "Tools" }, "sort_order": 3 } + ], + "tags": [], + "items": [] +} +``` + +(Real item list is produced by `scripts/dump-bootstrap.mjs` in Plan 5; this placeholder unblocks `cargo build`.) + +- [ ] **Step 2: `build.rs` to surface bootstrap to compile** + +```rust +// server/build.rs +fn main() { + println!("cargo:rerun-if-changed=bootstrap.json"); +} +``` + +- [ ] **Step 3: Migration service** + +```rust +// server/src/services/migration.rs +use crate::dto::*; +use crate::error::{AppError, Result}; +use crate::repo::{ConfigRepo, NavRepo}; +use serde::Deserialize; +use std::sync::Arc; + +const BOOTSTRAP_JSON: &str = include_str!("../../bootstrap.json"); + +#[derive(Deserialize)] +struct BootstrapDoc { + #[serde(rename = "schemaVersion")] + _schema_version: i64, + meta: BootstrapMeta, + sites: Vec<BootstrapSite>, + groups: Vec<BootstrapGroup>, + tags: Vec<BootstrapTag>, + items: Vec<BootstrapItem>, +} + +#[derive(Deserialize)] +struct BootstrapMeta { + #[serde(rename = "siteName")] site_name: String, + #[serde(rename = "siteAvatarPath")] site_avatar_path: Option<String>, + #[serde(rename = "siteCopyright")] site_copyright: String, + #[serde(rename = "siteIcp")] site_icp: Option<Link>, + #[serde(rename = "sitePolice")] site_police: Option<Link>, + #[serde(rename = "defaultTheme")] default_theme: String, +} + +#[derive(Deserialize)] +struct BootstrapSite { value: String, name: String, #[serde(default)] name_i18n: Option<serde_json::Value>, #[serde(default)] is_default: bool, #[serde(default)] sort_order: i64 } + +#[derive(Deserialize)] +struct BootstrapGroup { slug: String, name: String, #[serde(default)] name_i18n: Option<serde_json::Value>, #[serde(default)] sort_order: i64, #[serde(default)] collapsed_default: bool } + +#[derive(Deserialize)] +struct BootstrapTag { slug: String, name: String, #[serde(default)] name_i18n: Option<serde_json::Value> } + +#[derive(Deserialize)] +struct BootstrapItem { + name: String, + #[serde(default)] name_i18n: Option<serde_json::Value>, + #[serde(rename = "groupSlug")] group_slug: Option<String>, + #[serde(rename = "iconKind")] icon_kind: IconKind, + #[serde(rename = "iconValue")] icon_value: String, + #[serde(default)] links: std::collections::BTreeMap<String, String>, + #[serde(default, rename = "tagSlugs")] tag_slugs: Vec<String>, +} + +pub async fn seed_if_empty( + nav: Arc<dyn NavRepo>, + config: Arc<dyn ConfigRepo>, +) -> Result<()> { + let (sites, _, items, _) = nav.get_bundle().await?; + if !sites.is_empty() || !items.is_empty() { + return Ok(()); + } + let doc: BootstrapDoc = serde_json::from_str(BOOTSTRAP_JSON) + .map_err(|e| AppError::Other(anyhow::anyhow!(e)))?; + + // Meta → config kv + let mut pairs: Vec<(String, String)> = Vec::new(); + pairs.push(("site_name".into(), doc.meta.site_name)); + if let Some(p) = doc.meta.site_avatar_path { pairs.push(("site_avatar_path".into(), p)); } + pairs.push(("site_copyright".into(), doc.meta.site_copyright)); + pairs.push(("default_theme".into(), doc.meta.default_theme)); + if let Some(l) = doc.meta.site_icp { pairs.push(("site_icp_text".into(), l.text)); pairs.push(("site_icp_url".into(), l.url)); } + if let Some(l) = doc.meta.site_police { pairs.push(("site_police_text".into(), l.text)); pairs.push(("site_police_url".into(), l.url)); } + let pair_refs: Vec<(&str, &str)> = pairs.iter().map(|(k,v)| (k.as_str(), v.as_str())).collect(); + config.upsert_many(&pair_refs).await?; + + // Sites + for s in doc.sites { + nav.create_site(SitePayload { + value: s.value, name: s.name, name_i18n: s.name_i18n, is_default: s.is_default, + }).await?; + } + // Groups (track id by slug) + let mut group_id_by_slug = std::collections::HashMap::new(); + for g in doc.groups { + let created = nav.create_group(GroupPayload { + slug: g.slug.clone(), name: g.name, name_i18n: g.name_i18n, collapsed_default: g.collapsed_default, + }).await?; + group_id_by_slug.insert(g.slug, created.id); + } + // Tags + for t in doc.tags { + nav.create_tag(TagPayload { slug: t.slug, name: t.name, name_i18n: t.name_i18n }).await?; + } + // Items + for it in doc.items { + let group_id = it.group_slug.and_then(|s| group_id_by_slug.get(&s).copied()); + nav.create_item(ItemPayload { + group_id, + name: it.name, + name_i18n: it.name_i18n, + description: None, + description_i18n: None, + icon_kind: it.icon_kind, + icon_value: it.icon_value, + links: it.links, + tag_slugs: it.tag_slugs, + }).await?; + } + Ok(()) +} +``` + +- [ ] **Step 4: Re-export and call from main** + +```rust +// server/src/services/mod.rs +pub mod bootstrap; +pub mod bundle; +pub mod favicon; +pub mod migration; +``` + +```rust +// server/src/main.rs (in main fn, after ensure_admin_password call) +navsrv::services::migration::seed_if_empty(nav.clone() as _, cfg.clone() as _).await?; +``` + +- [ ] **Step 5: Test** + +```rust +// server/tests/seed.rs +use navsrv::db::connect_in_memory; +use navsrv::repo::{ConfigRepo, NavRepo, SqlxConfigRepo, SqlxNavRepo}; +use navsrv::services::migration::seed_if_empty; +use std::sync::Arc; + +#[tokio::test] +async fn seed_populates_sites_and_groups() { + let pool = connect_in_memory().await.unwrap(); + let nav: Arc<dyn NavRepo> = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg: Arc<dyn ConfigRepo> = Arc::new(SqlxConfigRepo::new(pool.clone())); + seed_if_empty(nav.clone(), cfg.clone()).await.unwrap(); + let (sites, groups, _items, _tags) = nav.get_bundle().await.unwrap(); + assert!(sites.iter().any(|s| s.value == "shangHai")); + assert!(groups.iter().any(|g| g.slug == "network")); + assert_eq!(cfg.get("site_name").await.unwrap().as_deref(), Some("Navigation")); +} + +#[tokio::test] +async fn seed_idempotent() { + let pool = connect_in_memory().await.unwrap(); + let nav: Arc<dyn NavRepo> = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg: Arc<dyn ConfigRepo> = Arc::new(SqlxConfigRepo::new(pool.clone())); + seed_if_empty(nav.clone(), cfg.clone()).await.unwrap(); + let count_before = nav.get_bundle().await.unwrap().0.len(); + seed_if_empty(nav.clone(), cfg.clone()).await.unwrap(); + let count_after = nav.get_bundle().await.unwrap().0.len(); + assert_eq!(count_before, count_after); +} +``` + +- [ ] **Step 6: Run** + +```bash +cd server +cargo test --test seed +cd .. +``` + +Expected: 2 PASS. + +- [ ] **Step 7: Commit** + +```bash +git add server/build.rs server/bootstrap.json server/src/services server/src/main.rs server/tests/seed.rs +git commit -m "feat(server): seed_if_empty migrates bootstrap.json into SQLite" +``` + +### Task 33: README + final lint pass + +**Files:** +- Create: `server/README.md` +- Modify: root `README.md` (add backend section) + +- [ ] **Step 1: `server/README.md`** + +````markdown +# navsrv + +Rust backend for the navigation site (Plan 1 of 5). Serves the SPA + JSON API at `:8080`. + +## Quickstart (dev) + +```bash +cd server +cp .env.example .env # edit if needed +cargo run # listens on :8080 +``` + +On first boot, an admin password is generated and printed. It's also written to +`./dev-data/INITIAL_PASSWORD.txt`. Log in via the SPA to change it; the file is +auto-deleted after a successful change. + +To start the SPA dev server (port 5173) talking to this backend: + +```bash +cd ../web +pnpm dev +# /api/* is proxied to localhost:8080 by web/vite.config.ts +``` + +## Configuration (env) + +| Var | Default | Notes | +|---------------------------|-----------------------|----------------------------------------| +| `PORT` | `8080` | | +| `DATA_DIR` | `./dev-data` | SQLite DB + uploads + INITIAL_PASSWORD | +| `STATIC_DIR` | `../web/build` | SvelteKit `pnpm build` output | +| `BOOTSTRAP_ADMIN_PASSWORD`| (unset) | First boot only; else random+file | +| `SECURE_COOKIES` | `false` | Set `true` behind HTTPS | +| `RUST_LOG` | `info,sqlx=warn,...` | tracing-subscriber filter | + +## CLI + +``` +navsrv reset-password [--password <new>] # invalidates all sessions; deletes INITIAL_PASSWORD.txt +``` + +## Tests + +```bash +cargo test +cargo clippy --all-targets -- -D warnings +cargo fmt --check +``` +```` + +- [ ] **Step 2: Append to root README** (just a pointer) + +Insert before the existing `## Pre-install` section: + +```markdown +## Project layout + +- `web/` — SvelteKit SPA (`pnpm dev` / `pnpm build`) +- `server/` — Rust backend (`cargo run`); see `server/README.md` +- `docs/superpowers/` — design specs and implementation plans + +The production deploy is a single Docker image bundling both (Plan 5). +``` + +- [ ] **Step 3: Final lint pass** + +```bash +cd server +cargo fmt +cargo clippy --all-targets -- -D warnings +cargo test +cd .. +``` + +Expected: +- `fmt` makes no changes (or only stable cosmetic ones) +- `clippy` is clean +- All tests pass + +- [ ] **Step 4: Commit** + +```bash +git add server/README.md README.md +git commit -m "docs: server README with quickstart + CLI; root README pointers" +``` + +--- + +## Self-Review Summary (filled by writer) + +**Spec coverage:** + +| Spec section | Where covered | +|---|---| +| §2.1 Topology (single process, port 8080) | Task 8, 31 | +| §2.2 module boundaries | Tasks 6–18 (one module per task) | +| §3.1 schema 3NF | Task 11 | +| §3.1 SQLite WAL pragmas | Task 10 | +| §3.2 API endpoints | Tasks 18, 22, 25, 27–30 | +| §3.3 NavBundle camelCase | Task 17–18 | +| §6.1 Bootstrap (env or random + INITIAL_PASSWORD.txt) | Task 20 | +| §6.2 Session 30d sliding | Task 21 | +| §6.3 CLI reset-password invalidates sessions | Task 26 | +| §6.4 rate limit on login | Task 24 | +| §6.4 security headers | Task 8 | +| §9.1 seed_if_empty from bootstrap.json | Task 32 | +| §9.3 web/ + server/ split | Tasks 1–4 | + +**Out of scope (in later plans):** +- Frontend (Plans 2–4) +- Real `bootstrap.json` produced from existing TS constants — `scripts/dump-bootstrap.mjs` is in Plan 5 +- Dockerfile + CI — Plan 5 +- Playwright e2e — Plan 5 + +**Type / signature consistency:** verified — +- `SqlxNavRepo::list_items_full` is `pub(crate)`; only callers are in the same crate (Task 14, 17) +- `AppState::new` signature evolves twice (Task 16 → Task 25 added `data_dir` → Task 30 added `favicon`); each change updates **every** call site listed in Files +- `NavRepo` trait is implemented exhaustively before any handler relies on a method +- `ItemPayload` / `ItemPatch` shape stays stable from Task 12 forward + +**Verification gates:** every task ends with at least one `cargo test` invocation. Final task runs `clippy -D warnings` + `fmt --check`. + diff --git a/docs/superpowers/specs/2026-05-19-rust-navigation-platform-design.md b/docs/superpowers/specs/2026-05-19-rust-navigation-platform-design.md new file mode 100644 index 0000000..209d2ae --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-rust-navigation-platform-design.md @@ -0,0 +1,791 @@ +# Navigation Platform — Architecture Redesign (Rust + SvelteKit) + +**Date:** 2026-05-19 +**Status:** Draft for review +**Owner:** Pico +**Supersedes:** existing static-only nav site (SvelteKit + adapter-static, hard-coded `nav.ts`) + +--- + +## 1. Overview + +### 1.1 Goal + +将当前纯静态、数据硬编码、单一视觉风格的导航站重构为:**数据驱动 + 在线编辑 + 鉴权 + 全新 design system + i18n + a11y** 的可演进平台。前端继续 SvelteKit(`adapter-static`),后端新增 Rust(Axum + SQLite)单进程统一 serve 静态产物与 API。 + +### 1.2 Why + +| 现状痛点 | 影响 | +|---|---| +| 导航项 / 区域 / 站点信息硬编码在 TypeScript 常量 | 改一项需改源码 + 重构建 + 重部署 | +| 无搜索 / 分组 / 标签 / 收藏 / 主题 | 项目数 ↑ 后体验下降 | +| 文案中英混排在常量里 | 违反 i18n 原则、不可切换 | +| `<div on:click>` 充当 button | a11y 不达 WCAG AA | +| `Record<string, IUrl>` 类型松散 | 新增 site 不会触发编译期错误 | +| 视觉风格固定(紫蓝渐变 + 圆角 + 抖动 hover) | 个性化能力差 | + +### 1.3 Non-Goals (out of scope) + +- 多用户 / OAuth / SSO(保持单管理员) +- 操作日志 / 审计 +- 实时多端同步(SSE / WebSocket) +- PWA / 离线 +- 健康检查 ping 链接 +- 远端备份(git / S3)—— 用户自行 `rsync /app/data` +- 邮件密码重置 —— 用 CLI 子命令替代 +- 多产物 build(同一份代码出多个独立站) +- SSR —— 前端继续 SPA `adapter-static` + +### 1.4 Hard Constraints + +- 单 Docker 镜像、单进程、单端口(默认 8080) +- 数据卷 `/app/data` 含 SQLite + 上传图标 + admin hash;删容器不丢数据 +- 不引入 Tailwind / 任何 UI 库 / 任何 i18n 库 —— 自建 design system 与 i18n +- 不破坏现有 Docker bind mount 用户的 `/app/data`(首次上线一次性迁移) +- 所有用户可见文案走 i18n +- 所有交互元素 `<button>`,键盘可达,焦点可见 +- TypeScript 全严格、零 `any`,Rust 全 `#[deny(warnings)]` 编译 + +--- + +## 2. Architecture + +### 2.1 Topology + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Docker container (single process, port 8080) │ +│ │ +│ ┌──────────────────────────┐ ┌──────────────────────┐ │ +│ │ Axum (Rust) │ │ SQLite (file) │ │ +│ │ / → static │ │ /app/data/data.db │ │ +│ │ /assets/* → static │◄───┤ ├─ sites │ │ +│ │ /icons/* → static │ │ ├─ groups │ │ +│ │ /api/nav read │ │ ├─ items │ │ +│ │ /api/items/* CRUD │ │ ├─ item_links │ │ +│ │ /api/groups/* CRUD │ │ ├─ tags │ │ +│ │ /api/sites/* CRUD │ │ ├─ item_tags │ │ +│ │ /api/tags/* CRUD │ │ ├─ config (kv) │ │ +│ │ /api/config CRUD │ │ └─ sessions │ │ +│ │ /api/auth/{login,...} │ └──────────────────────┘ │ +│ │ /api/favicon │ │ +│ │ │ /app/data/icons/_cache/ │ +│ │ middleware: │ (favicon cache blobs) │ +│ │ ─ tower-sessions │ │ +│ │ ─ tower-governor │ │ +│ │ ─ tower-http compress │ │ +│ │ ─ tracing │ │ +│ └──────────────────────────┘ │ +│ │ +│ /app/static ← frontend build artifacts (read-only) │ +│ /app/data ← writable volume │ +└──────────────────────────────────────────────────────────────┘ +``` + +- **TLS**:容器内 HTTP only。生产部署前置 caddy / traefik / nginx;提供 `docker-compose.yml` sample with caddy +- **Reverse proxy not required** for basic deploy(容器自己 serve 全部 + http 即可) +- **No SSR**: SvelteKit `adapter-static` 出 SPA;Axum 用 `tower_http::services::ServeDir` + fallback to `/index.html` 兜底前端路由 + +### 2.2 Rust crate 模块边界 + +| 模块 | 职责 | 公共接口 | +|---|---|---| +| `app::config` | env / .env 加载(`figment`) | `Settings::load()` | +| `app::error` | 统一错误 + `IntoResponse` | `AppError`, `Result<T>` | +| `app::auth` | 密码 / session / 中间件 | `RequireAuth` extractor、`hash_password`, `verify_password` | +| `app::repo` | 仓储模式 DB 访问 | `NavRepo`, `ConfigRepo`(trait + sqlx 实现) | +| `app::services` | favicon 抓取 / 迁移 / 密码 | `FaviconService`, `MigrationService` | +| `app::routes` | HTTP handler,仅做参数解析 + 调 services / repo | `pub fn router(state: AppState) -> Router` | +| `app::cli` | `reset-password`、`bootstrap-admin` 子命令 | clap-derive subcommand | +| `app::main` | 入口;启动迁移;构建 router;监听 | `fn main()` | + +**关键约束**: +- handler 不直接写 SQL;所有 DB 通过 `*Repo` trait +- service 只接 trait(不接具体 struct),便于测试用 mock +- `AppState { repo: Arc<dyn NavRepo>, config_repo: Arc<dyn ConfigRepo>, favicon: Arc<FaviconService>, settings: Arc<Settings> }` + +### 2.3 Frontend / Backend 协议 + +- 前端开发:vite dev server (port 5173) + proxy `/api/*` → `localhost:8080` +- 前端生产:build 产出 `/build`,被 Rust binary 嵌入式 serve(`ServeDir("./static")`) +- 数据契约:所有 API 返回 `application/json`;schema 由 zod (前端) ↔ serde + validator (Rust) 手维护一致;`schemaVersion: 1` 字段贯穿 +- 错误:4xx/5xx 全部 `{ error: string, message?: string, fields?: Record<string,string> }` + +--- + +## 3. Data Model + API Contract + +### 3.1 SQLite schema (3NF) + +```sql +-- migrations/0001_init.sql + +CREATE TABLE sites ( + id INTEGER PRIMARY KEY, + value TEXT NOT NULL UNIQUE, -- 'shangHai' (stable id used in URL/store) + name TEXT NOT NULL, -- '上海' (default locale) + name_i18n TEXT, -- JSON e.g. {"en":"Shanghai"} + sort_order INTEGER NOT NULL DEFAULT 0, + is_default INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE groups ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, -- 'network' + name TEXT NOT NULL, + name_i18n TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + collapsed_default INTEGER NOT NULL DEFAULT 0 -- 默认是否折叠(前端可被 uiPrefs 覆盖) +); + +CREATE TABLE items ( + id INTEGER PRIMARY KEY, + group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL, + name TEXT NOT NULL, + name_i18n TEXT, + description TEXT, -- 可选;hover tooltip / search 命中 + description_i18n TEXT, + icon_kind TEXT NOT NULL CHECK (icon_kind IN ('asset','url','auto-favicon')), + icon_value TEXT NOT NULL, -- asset 文件名 | https URL | hostname + sort_order INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE item_links ( -- item × site → URL + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE, + url TEXT NOT NULL, + PRIMARY KEY (item_id, site_id) +); + +CREATE TABLE tags ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + name_i18n TEXT +); + +CREATE TABLE item_tags ( + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (item_id, tag_id) +); + +CREATE TABLE config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +-- bootstrap keys: +-- site_name, site_copyright, site_icp_text, site_icp_url, +-- site_police_text, site_police_url, site_avatar_path, +-- admin_password_hash, admin_password_updated_at, +-- default_theme ('system'|'light'|'dark'), schema_version + +CREATE TABLE sessions ( -- tower-sessions sqlx-store + id TEXT PRIMARY KEY, + data BLOB NOT NULL, + expiry_date INTEGER NOT NULL +); + +-- indexes +CREATE INDEX idx_items_group_sort ON items(group_id, sort_order); +CREATE INDEX idx_item_links_site ON item_links(site_id); +CREATE INDEX idx_item_tags_tag ON item_tags(tag_id); +``` + +**Schema 演进**:sqlx `migrations/NNNN_*.sql`,启动时 `sqlx::migrate!()` 自动跑;版本号写到 `config.schema_version`。 + +**SQLite 运行模式**:连接 pragma `journal_mode=WAL` + `synchronous=NORMAL` + `foreign_keys=ON` + `busy_timeout=5000`;写并发安全 + 读不阻塞。 + +### 3.2 API endpoints + +| Method | Path | Auth | Body / Query | Response | +|---|---|---|---|---| +| `GET` | `/api/nav` | – | – | `NavBundle`(整包,见 3.3)| +| `GET` | `/api/auth/me` | – | – | `{ authenticated: boolean }` | +| `POST` | `/api/auth/login` | – | `{ password }` | `204` + `Set-Cookie: __Host-sid=...` | +| `POST` | `/api/auth/logout` | session | – | `204` | +| `POST` | `/api/items` | session | `ItemPayload` | `201 Item` | +| `PATCH` | `/api/items/:id` | session | `Partial<ItemPayload>` | `200 Item` | +| `DELETE` | `/api/items/:id` | session | – | `204` | +| `POST` | `/api/items/reorder` | session | `[{id, sort_order, group_id?}]` | `204` | +| `POST` | `/api/groups` | session | `GroupPayload` | `201 Group` | +| `PATCH` | `/api/groups/:id` | session | `Partial<GroupPayload>` | `200 Group` | +| `DELETE` | `/api/groups/:id` | session | – | `204` | +| `POST` | `/api/groups/reorder` | session | `[{id, sort_order}]` | `204` | +| `POST` | `/api/sites` | session | `SitePayload` | `201 Site` | +| `PATCH` | `/api/sites/:id` | session | `Partial<SitePayload>` | `200 Site` | +| `DELETE` | `/api/sites/:id` | session | – | `204` | +| `POST` | `/api/sites/reorder` | session | `[{id, sort_order}]` | `204` | +| `POST` | `/api/tags` | session | `TagPayload` | `201 Tag` | +| `PATCH` | `/api/tags/:id` | session | `Partial<TagPayload>` | `200 Tag` | +| `DELETE` | `/api/tags/:id` | session | – | `204` | +| `PATCH` | `/api/config` | session | `[{ key, value }]` | `204` | +| `POST` | `/api/config/password` | session | `{ current, next }` | `204` | +| `POST` | `/api/icons/upload` | session | `multipart/form-data` | `201 { path }` | +| `GET` | `/api/favicon` | – | `?host=…` | `image/*` (cached) | + +### 3.3 NavBundle TypeScript / Rust 契约(手维护一致) + +```ts +// src/lib/types/nav.ts +import { z } from 'zod'; + +export const NavBundleSchema = z.object({ + schemaVersion: z.literal(1), + meta: z.object({ + siteName: z.string(), + siteAvatarPath: z.string().optional(), + siteCopyright: z.string(), + siteIcp: z.object({ text: z.string(), url: z.string().url() }).nullable(), + sitePolice: z.object({ text: z.string(), url: z.string().url() }).nullable(), + defaultTheme: z.enum(['system','light','dark']), + }), + sites: z.array(SiteSchema), + groups: z.array(GroupSchema), + items: z.array(ItemSchema), // 含 links: Record<siteValue, url> + tagSlugs: string[] + tags: z.array(TagSchema), +}); +export type NavBundle = z.infer<typeof NavBundleSchema>; +``` + +**为什么整包返回**:导航站数据量极小(百级),整包 1 RTT 简化前端缓存语义;写操作走细粒度 endpoint,写完只 patch 本地 store,不重 fetch(除冲突)。 + +--- + +## 4. Frontend State + Components + +### 4.1 Store layering + +``` + ┌──────────────────────┐ + │ navDataStore │ ← async, GET /api/nav, zod-validated + │ NavBundle │ + └──────────┬───────────┘ + │ + ┌─────────────────┼─────────────────┐ + ▼ ▼ ▼ + ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ + │ uiPrefs (LS) │ │ session │ │ editMode │ + │ ─ siteValue │ │ ─ authed │ │ ─ on/off │ + │ ─ theme │ │ │ │ ─ dirty: Set │ + │ ─ favorites │ │ │ │ │ + │ ─ locale │ │ │ │ │ + │ ─ groupOpen │ │ │ │ │ + └──────────────┘ └──────────────┘ └──────────────┘ + + ┌────────── derived (pure) ──────────┐ + ▼ ▼ + visibleSectionsStore searchHitsStore + (current site × searchTerm (字符串模糊匹配命中) + × activeTagSlugs filter, + grouped + favorites first) +``` + +- `navDataStore`: 远端真理;接口:`load()`, `refetch()`, `applyPatch(itemId, patch)`, `applyReorder(...)`, `applyDelete(id)`... +- `uiPrefs`: 启动时从 LS hydrate;订阅自动写回;schema 版本号字段允许未来兼容迁移 +- `session`: 启动调一次 `/api/auth/me`;登录后置 true;401 自动置 false +- `editMode`: 仅 `session.authed === true` 时可置 on +- 所有 derived 是**纯函数**,不订阅任何 LS / API;测试零 I/O + +### 4.2 Component tree + +``` ++layout.svelte +├─ <AppShell> (新;toast + dialog portal + theme provider) +├─ <Header> +│ ├─ <Brand> (logo + site name) +│ ├─ <SearchBar> ("/" 聚焦, fuzzy, esc clear) +│ ├─ <TagFilterChips> +│ ├─ <SiteSelect> (refactor 现有) +│ ├─ <ThemeToggle> (新) +│ ├─ <LocaleToggle> (新) +│ └─ <AuthControls> +│ ├─ <LoginButton> (未登录) +│ └─ <EditToggle> + <UserMenu> (已登录) +├─ <main> +│ └─ +page.svelte +│ ├─ <FavoritesSection> (only if favorites.length > 0) +│ ├─ <GroupSection> ✕ N +│ │ ├─ <GroupHeader> (折叠 / 重命名 / 排序) +│ │ └─ <NavGrid> +│ │ └─ <NavItem> ✕ M +│ │ ├─ <NavIcon> (asset | url | auto-favicon) +│ │ ├─ <NavLabel> (双击编辑 in editMode) +│ │ ├─ <FavoriteStar> +│ │ └─ <ItemContextMenu> (editMode only) +│ ├─ <UngroupedSection> (group_id IS NULL) +│ ├─ <NewItemAffordance> (editMode only; "+" 卡片) +│ └─ <SearchEmptyState> | <DataEmptyState> +├─ <Footer> (重做) +├─ <ToastViewport> (aria-live) +├─ <DialogPortal> +│ ├─ <LoginDialog> +│ ├─ <ItemEditDialog> +│ ├─ <GroupEditDialog> +│ ├─ <SiteEditDialog> +│ ├─ <TagEditDialog> +│ └─ <SiteSettingsDialog> (站名 / 备案 / avatar / 主题默认) +``` + +### 4.3 类型严格 + +- 所有 props `export let x: T`,禁 `any` +- store 用 `Writable<T>` / `Readable<T>` 显式泛型 +- `apiClient<TReq, TRes>(method, path, body, resSchema): Promise<TRes>` 强制响应经 zod parse +- API 返回类型与 Rust serde struct **手维护一致**(小仓库;不引入 ts-rs / openapi) + +--- + +## 5. Edit Mode UX + +### 5.1 进入 / 退出 + +- 未登录 → Header 显示 `<LoginButton>` → 点击 → `<LoginDialog>` → 成功 → `session.authed = true` → 出现 `<EditToggle>` + `<UserMenu>` +- `<EditToggle>` 按下 → `editMode.on = true` → 整个 nav 区域加 `outline: 2px dashed var(--accent-500)` + 顶部 banner "编辑中" +- 退出:`<EditToggle>` 再按 / `<UserMenu>` → 退出登录 / Esc / 30 分钟无操作 toggle 自动关(不踢登录态) + +### 5.2 操作矩阵 + +| 操作 | 触发 | 即时反馈 | 后端调用 | 失败处理 | +|---|---|---|---|---| +| 重排 nav 项 | 拖拽 ≡ 手柄(`svelte-dnd-action`)| 本地 reorder 立即生效 | `POST /api/items/reorder` (debounce 300ms) | 回滚 + toast.error | +| 跨分组拖动 | 拖到另一 group container | 同上 | 同上(含 `group_id`) | 同上 | +| 重命名 item | 双击 `<NavLabel>` → contenteditable | onBlur / Enter 提交 | `PATCH /api/items/:id` | 回滚 + 保留焦点 | +| 详细编辑 item | 右键菜单 / 长按(移动)→ `<ItemEditDialog>` | 表单完整字段 | `PATCH /api/items/:id` | 表单 inline error | +| 删除 item | 右键 → "删除" → `<ConfirmToast>`(3s 撤销窗口) | optimistic 隐藏 | `DELETE /api/items/:id` after 3s | 撤销 / toast.error | +| 新增 item | grid 末尾 `+` 占位 | 弹 `<ItemEditDialog>` | `POST /api/items` | 表单 inline error | +| 重命名 group | header `✎` | inline editable | `PATCH /api/groups/:id` | 回滚 | +| 重排 group | 拖 group header | 本地 reorder | `POST /api/groups/reorder` | 同上 | +| 新增 group | "+ 新建分组" 按钮 | inline 创建 + 立刻可输入 | `POST /api/groups` | 表单 inline error | +| 删除 group | header 菜单 → 删除 | 项目落到"未分组" | `DELETE /api/groups/:id` (cascade `SET NULL`) | toast.error | +| 管理 sites | `<SiteSelect>` 末尾 "管理…" | `<SiteEditDialog>` | site CRUD endpoints | 表单 inline error | +| 管理 tags | Header tag chip 末尾 "管理…" | `<TagEditDialog>` | tag CRUD endpoints | 表单 inline error | +| 站点元信息 | `<UserMenu>` → "站点设置" | `<SiteSettingsDialog>`(站名 / 备案 / avatar 上传 / 默认主题) | `PATCH /api/config` + `POST /api/icons/upload` | 表单 inline error | +| 改密码 | `<UserMenu>` → "修改密码" | dialog | `POST /api/config/password` | 表单 inline error | + +### 5.3 一致性 / 失败 / 离线 + +- 所有写:optimistic update + 失败回滚 + toast;连续 2 次失败自动 `navDataStore.refetch()` +- 写操作发出前对比 `updated_at`(item / config)做乐观锁,409 冲突时拉新值 + diff 提示 +- `editMode.on` + 任意 dialog 有未保存改动 → `beforeunload` 提示 +- 网络断开:写入队列暂存;恢复后批量 retry;UI 显示 offline banner + +--- + +## 6. Auth + Security + Password Recovery + +### 6.1 Bootstrap(safe-by-default) + +首次启动按以下优先级解析管理员初始密码(业界做法,参考 GitLab Omnibus / Jenkins / Postgres 镜像): + +1. **env `BOOTSTRAP_ADMIN_PASSWORD` 已设** → bcrypt(cost=12) → 写 `config.admin_password_hash` + `admin_password_updated_at` +2. **env 未设** → 生成 24 字符 URL-safe 随机密码 → bcrypt 同上 → **同时**: + - 在启动日志打印一次(`⚠ INITIAL ADMIN PASSWORD: <pw>`) + - 写到 `/app/data/INITIAL_PASSWORD.txt`(mode 0600) +3. 启动日志统一强提示:`Please change the admin password via UI immediately and then delete /app/data/INITIAL_PASSWORD.txt` +4. 用户首次通过 UI 修改密码后,后端检测到 `admin_password_updated_at` 变化 → 自动删除 `INITIAL_PASSWORD.txt` +5. 之后启动 env 与文件均被忽略(hash 已存且已被用户修改过);密码丢失走 §6.3 CLI 重置 + +### 6.2 登录流 + +- `POST /api/auth/login { password }` → `tower-governor` 限 5 次 / 15min / IP → bcrypt verify → 成功签发 session +- Session: `tower-sessions` + `tower-sessions-sqlx-store`(SQLite 存) +- Cookie: `__Host-sid`、`HttpOnly`、`Secure`(生产)、`SameSite=Lax`、`Path=/`、TTL 30 天,每次写延期 +- `POST /api/auth/logout` 销毁 session;前端清 `session.authed = false` + +### 6.3 密码恢复(CLI 重置) + +- 二进制提供子命令:`navsrv reset-password [--password=<new>]`(不传交互式 prompt) +- 流程: + - 用户 `docker exec -it nav-container navsrv reset-password` + - 子命令打开同一 SQLite,写入新 bcrypt hash + 更新 `admin_password_updated_at` + - 立即吊销所有现有 session(`DELETE FROM sessions`) +- 文档:README 加"忘记密码"段落 +- 没有 UI 入口;登录页只有 `Forgot password? See README.` 提示 + +### 6.4 输入校验 / 防护 + +- Rust:`serde` + `validator`:name ≤ 80 char、url 必须 `http(s)://`、reject `javascript:` / `data:` +- 前端:表单同等校验 + 提交前 zod parse;后端是真值 +- XSS:Svelte 默认 escape;`<NavIcon>` `src` 限 `/icons/...` 同源 + `https://` 同源校验通过的图源 +- CSRF:SameSite=Lax + 同源 → 不需要显式 token +- 速率限制:login 5 / 15min;写接口 60 / min / session +- 安全 header(tower-http):`X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin`, `X-Frame-Options: DENY` + +--- + +## 7. Design System (new) + +> **Note**: 本节定义**原则 + tokens 框架 + 起点风格**。具体视觉决策(确切色值、字号阶梯、最终 mockup)在 implement 阶段由 frontend-design / ui-ux-pro-max skill 深化;本节锁定的是**词汇与边界**,不是像素。 + +### 7.1 风格方向(已锁定) + +**现代极简(modern minimal)** — 业界对"工具属性产品 + 高频使用"场景的默认选择(Linear / Raycast / Vercel / Stripe Dashboard): + +- 克制中性色(warm neutral 偏向)+ 单一 accent(待 implement 阶段从可选 indigo / violet / teal 中选定) +- 强信息层级:通过 weight + size + 间距分层,**不**靠装饰 +- 微圆角:6/10/14px 三档;不用 24px 大圆角 +- Subtle shadow:1px border + 极弱 shadow,hover 时 shadow 升级 + 轻微 translateY(-2px) +- 排版:2-axis scale(size + weight),≤ 5 个字号档位 +- 动效:仅 transition(150–250ms ease-out);不用 spring / 抖动 / 旋转 +- 暗 / 亮主题双调;`prefers-color-scheme` 自动 + 用户 toggle 覆盖 +- 尊重 `prefers-reduced-motion` + +### 7.2 Design tokens(结构) + +```scss +// src/lib/design/tokens.scss +:root { + /* Spacing — 4px base, exponential */ + --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; + --sp-5: 24px; --sp-6: 32px; --sp-7: 48px; --sp-8: 64px; + + /* Radius */ + --rd-sm: 6px; --rd-md: 10px; --rd-lg: 14px; --rd-pill: 999px; + + /* Type */ + --ft-sans: -apple-system, BlinkMacSystemFont, ...; + --ft-mono: ui-monospace, SFMono-Regular, ...; + --fs-xs: 12px; --fs-sm: 13px; --fs-md: 15px; --fs-lg: 18px; --fs-xl: 24px; + --fw-regular: 400; --fw-medium: 500; --fw-semibold: 600; + --lh-tight: 1.2; --lh-base: 1.5; --lh-loose: 1.7; + + /* Color (light) */ + --c-bg: #fafaf9; + --c-surface: #ffffff; + --c-surface-2: #f4f4f3; + --c-border: #e5e5e3; + --c-text: #1a1a18; + --c-text-2: #5a5a55; + --c-text-3: #8a8a85; + --c-accent: #4f46e5; /* 单 accent,待最终实现敲定 */ + --c-accent-bg: #eef2ff; + --c-success: #047857; + --c-warn: #b45309; + --c-danger: #b91c1c; + + /* Shadow */ + --sh-sm: 0 1px 2px rgba(0,0,0,0.04); + --sh-md: 0 4px 12px rgba(0,0,0,0.06); + --sh-lg: 0 12px 32px rgba(0,0,0,0.08); + + /* Motion */ + --tr-fast: 120ms ease-out; + --tr-base: 180ms ease-out; + --tr-slow: 260ms ease-out; +} + +[data-theme="dark"] { + --c-bg: #0e0e0d; + --c-surface: #18181a; + --c-surface-2: #232326; + --c-border: #2c2c30; + --c-text: #f5f5f3; + --c-text-2: #b5b5b0; + --c-text-3: #767672; + --c-accent: #818cf8; + --c-accent-bg: #1e1b4b; + --sh-sm: 0 1px 2px rgba(0,0,0,0.4); + --sh-md: 0 4px 12px rgba(0,0,0,0.45); + --sh-lg: 0 12px 32px rgba(0,0,0,0.5); +} + +@media (prefers-reduced-motion: reduce) { + :root { --tr-fast: 0ms; --tr-base: 0ms; --tr-slow: 0ms; } +} +``` + +### 7.3 Component primitives + +新建 `src/lib/components/ui/`: +- `Button.svelte` — 三种 intent (primary / secondary / ghost) × 三种 size (sm / md / lg) + icon-only variant +- `Input.svelte` / `Textarea.svelte` — 含 leading icon、错误态、help text +- `Dialog.svelte` — focus trap + escape + body scroll lock + portal +- `Toast.svelte` + `ToastViewport.svelte` — 4 intents、aria-live polite +- `Menu.svelte` (context menu) — 键盘 navigable +- `Chip.svelte` — tag filter / status +- `Switch.svelte` — toggle 切换 +- `Card.svelte` — surface + radius + shadow +- `IconButton.svelte` +- `Skeleton.svelte` +- 全部受 design tokens 驱动 + +### 7.4 Footer 重做 + +- 现状:`copyright | ICP 备案 | 公安备案`,三段 grid,断点切列 +- 新设计原则: + - 主行:`版权` + 一段 inline 的 ICP / 公安链接(不再要 `divider` 装饰条) + - 次行(可选):`Built with ❤︎ — Source on GitHub` 之类(用户自定义) + - 移动端:自动堆叠 + - 全部内容来自 `config` 表,不再硬编码 + - i18n:英文 locale 下隐藏中国大陆备案信息,或显示对应英文翻译(用户在 site settings 填) + +### 7.5 现有视觉资产处置 + +- 紫蓝渐变背景 → 保留作为 hero / 头像光晕的可选 accent,不做主背景 +- shark-bounce 动画 → 移除(不符合"现代极简",且违反 reduced-motion) +- 圆角 24px 大圆角 → 缩小到 14px (`--rd-lg`),`box-shadow` 用 `--sh-md` +- nav-item 重投影 → 替换为 1px border + subtle shadow + accent ring on focus / hover +- 抖动 hover → 替换为 `transform: translateY(-2px)` + shadow upgrade + +--- + +## 8. i18n + a11y + Type Safety + Errors + +### 8.1 i18n(前端,自实现 ~150 行) + +- 文件:`src/lib/i18n/{en.json, zh.json}`,flat key +- store:`localeStore: Writable<'zh' | 'en'>`,LS 持久化 +- API:`t(key: string, params?: Record<string, string | number>): string`(Svelte derived,自动响应 locale 变化) +- Key 命名:`<domain>.<module>.<purpose>` + - `header.search.placeholder` + - `editor.item.deleteConfirm` + - `auth.login.title` + - `error.network.offline` +- 数据层 i18n:`name`、`name_i18n: { en?: string }` 字段;显示时 `record.name_i18n?.[locale] ?? record.name` +- 切换 UI:Header `<LocaleToggle>`,icon: `语 / EN` +- **不引入** `svelte-i18n` / `i18next` + +### 8.2 a11y(WCAG 2.1 AA) + +- 所有交互元素 `<button type="button">`(清除现有 `<div on:click>`) +- Tab 顺序合理;可跳过 nav 区的 `Skip to content` 链接(visually hidden until focus) +- 键盘:Enter / Space 激活;Esc 关 dialog;Tab trap 在 dialog 内;`/` 聚焦搜索;`g` 进入 group quick switch(路线图) +- ARIA:search `role="searchbox"`、toggle `aria-pressed`、dialog `role="dialog" aria-modal="true" aria-labelledby`、toast `aria-live="polite" / assertive` +- 焦点环:`outline: 2px solid var(--c-accent); outline-offset: 2px;`,所有可聚焦元素可见 +- 颜色对比:light + dark 主题分别确保 ≥ 4.5:1(normal text)/ 3:1(large text) +- `prefers-reduced-motion`:tokens 已处理(动效降为 0ms) + +### 8.3 类型严格(双端) + +- TypeScript:`strict: true`、`noUncheckedIndexedAccess: true`、零 `any`(用 `unknown` + 类型 narrowing) +- zod schema 单一源 → `z.infer` 出 TS 类型 → 后端 serde struct 手维护一致 +- Rust:`#[deny(warnings)]`、clippy `-W clippy::pedantic`(合理放宽) +- API client:`apiClient<TRes>(method, path, body, resSchema): Promise<TRes>` —— **禁止裸 fetch** + +### 8.4 错误处理边界 + +- 仅在 fetch / 表单 / 文件上传三处加显式错误处理 +- 内部纯函数不写 try/catch;Rust handler 错误一律 `?` 返 `AppError` +- toast 系统:`info` / `success` / `warn` / `error`,aria-live polite/assertive +- 网络失败:retry button;连续 3 次失败 banner "无法连接服务" +- 401:自动弹 LoginDialog;登录后重试原请求 + +--- + +## 9. Migration + Docker + Dev Workflow + +### 9.1 一次性数据迁移 + +- 前端 build stage 跑 `scripts/dump-bootstrap.mjs`(dynamic-import 当前 `src/lib/constants/{nav,siteInfo}.ts` 后 `JSON.stringify` 输出 `build/bootstrap.json`) +- Rust `build.rs` 把 stage 间拷贝过来的 `bootstrap.json` 通过 `include_str!("../bootstrap.json")` 编译期嵌入 binary +- `MigrationService::seed_if_empty()`:启动后检查 `sites + items` 为空 → 反序列化嵌入字符串 → 写入 SQLite +- 备案 / 站名 / avatar 路径进 `config` 表 +- 默认 group(按用途预分;用户登录后可任意调整 / 合并 / 重命名): + - **网络 / Network** (`network`):RouterOS / OpenWRT / K2P / qBittorrent / Jackett + - **媒体 / Media** (`media`):Jellyfin / Surveillance / 相册 + - **NAS / Storage** (`nas`):Synology / 文件夹 / Nas Tools + - **工具 / Tools** (`tools`):Esxi / Home Assistant / 思源笔记 / 博客 / Portainer / 青龙 +- 幂等:再次启动检测非空跳过;本 seed 一次性,重置数据需用户清空 volume + +### 9.2 Docker(multi-stage) + +```dockerfile +# ──── Stage 1: build frontend ──── +FROM node:20-alpine AS web-build +WORKDIR /web +RUN corepack enable +COPY web/pnpm-lock.yaml web/package.json ./ +RUN pnpm install --frozen-lockfile +COPY web/ ./ +COPY scripts/dump-bootstrap.mjs /scripts/dump-bootstrap.mjs +RUN pnpm build && \ + node /scripts/dump-bootstrap.mjs > build/bootstrap.json +# /web/build 含 SPA 资产 + bootstrap.json + +# ──── Stage 2: build server ──── +FROM rust:1.79-slim AS server-build +WORKDIR /server +RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* +# 缓存依赖层 +COPY server/Cargo.toml server/Cargo.lock ./ +RUN mkdir src && echo 'fn main(){}' > src/main.rs && \ + cargo build --release && \ + rm -rf src target/release/deps/navsrv* target/release/navsrv* +COPY server/ ./ +COPY --from=web-build /web/build/bootstrap.json ./bootstrap.json +RUN cargo build --release + +# ──── Stage 3: runtime ──── +FROM gcr.io/distroless/cc-debian12 +COPY --from=server-build /server/target/release/navsrv /usr/local/bin/navsrv +COPY --from=web-build /web/build /app/static +ENV PORT=8080 DATA_DIR=/app/data STATIC_DIR=/app/static +VOLUME /app/data +EXPOSE 8080 +USER nonroot +CMD ["navsrv"] +``` + +提供 `docker-compose.yml` sample: +- service `nav`(本镜像,端口 8080,volume `./data:/app/data`,env `BOOTSTRAP_ADMIN_PASSWORD`) +- service `caddy`(前置 TLS,反代 nav:8080) + +### 9.3 目录结构(重构后) + +> 仓库采用 **web/ + server/ 双子项目**:前端、后端各自自包含,根目录只放跨子项目资源(Dockerfile / docker-compose / docs / scripts / e2e tests / README)。**不**引入 pnpm/cargo workspace(YAGNI)。 + +``` +navigation_website/ +├─ web/ # SvelteKit 子项目(自包含) +│ ├─ package.json +│ ├─ pnpm-lock.yaml +│ ├─ svelte.config.js # adapter-static +│ ├─ vite.config.ts # dev proxy /api → :8080 +│ ├─ tsconfig.json +│ ├─ static/ # avatar.png / navIcons/* +│ └─ src/ +│ ├─ app.html, app.scss, app.d.ts +│ ├─ routes/ +│ │ ├─ +layout.svelte +│ │ ├─ +page.svelte +│ │ └─ +error.svelte +│ └─ lib/ +│ ├─ api/client.ts # apiClient + zod parse +│ ├─ design/tokens.scss +│ ├─ i18n/{store,en,zh}.ts +│ ├─ stores/{nav,uiPrefs,session,editMode,toast}.ts +│ ├─ types/nav.ts # zod schemas + TS types +│ ├─ components/ +│ │ ├─ ui/ # design system primitives +│ │ ├─ Header/ +│ │ ├─ Nav/ +│ │ ├─ Edit/ +│ │ └─ Footer.svelte +│ └─ utils/{isURL,fuzzy,debounce} +│ +├─ server/ # Rust crate(自包含) +│ ├─ Cargo.toml +│ ├─ Cargo.lock +│ ├─ build.rs +│ ├─ migrations/ +│ │ └─ 0001_init.sql +│ └─ src/ +│ ├─ main.rs +│ ├─ config.rs, error.rs +│ ├─ auth/{mod,middleware,password,session}.rs +│ ├─ cli.rs # reset-password / bootstrap-admin 子命令 +│ ├─ routes/{mod,nav,items,groups,sites,tags,config,auth,favicon,health}.rs +│ ├─ repo/{mod,nav,config}.rs +│ └─ services/{mod,favicon,migration,bootstrap}.rs +│ +├─ scripts/ +│ └─ dump-bootstrap.mjs # 把 web/ 旧常量转 bootstrap.json +├─ docs/superpowers/{specs,plans}/ # 设计 + 实施计划 +├─ tests/ # Playwright e2e(跨前后端) +│ └─ playwright.config.ts +│ +├─ Dockerfile # multi-stage:web + server → distroless +├─ docker-compose.yml # sample(含 caddy 前置 TLS) +├─ .editorconfig +├─ .gitignore +├─ .dockerignore +└─ README.md +``` + +**职责边界**: +- `web/` 内一切命令以 `web/` 为 cwd(`pnpm i && pnpm dev/build/test`) +- `server/` 内一切命令以 `server/` 为 cwd(`cargo run / cargo test`) +- 根目录命令仅 `docker build` / `docker compose up` / `playwright test` +- 旧 repo 根的 `src/` `static/` `package.json` `pnpm-lock.yaml` `svelte.config.js` `vite.config.ts` `tsconfig.json` `playwright.config.js` `.eslintrc.cjs` `.eslintignore` `.prettierrc` `.prettierignore` 全部 `git mv` 进 `web/`(playwright 移到根 `tests/`) + +### 9.4 Dev workflow + +```bash +# Terminal A: 后端 +cd server +cargo run # listens :8080, auto-migrates SQLite at ./dev-data/data.db + +# Terminal B: 前端 +cd web +pnpm dev # listens :5173, vite proxies /api → localhost:8080 +``` + +### 9.5 部署运维 + +- `docker run -d --name nav -p 8080:8080 -v ./data:/app/data -e BOOTSTRAP_ADMIN_PASSWORD=changeme navsrv:latest` +- 改密码:UI 自助 / `docker exec -it nav navsrv reset-password` +- 备份:`rsync ./data /backup/nav/$(date +%F)/` +- 升级:`docker pull` + 重启容器;schema 自动迁移 + +--- + +## 10. Roadmap & Scope Discipline + +### 10.1 路线图(**本期不做**) + +| 项 | 推迟原因 | +|---|---| +| 多用户 / OAuth / SSO | 单管理员场景够用 | +| 操作日志 / 审计 | YAGNI | +| 实时多端同步(SSE / WS) | 个人站访问不大 | +| PWA / 离线 | 同上 | +| 健康检查(ping 链接) | 可后续 cron 跑 | +| 远端备份(git / S3) | 用户 cron rsync | +| 邮件密码重置 | CLI 替代 | +| 多产物 build | 用户已确认仅"区域切换" | +| `g` quick switch / cmd-K palette | 路线图候选 | + +### 10.2 范围纪律(**不顺手做**) + +- 不引入 Tailwind / DaisyUI / shadcn / 任何 UI 库 +- 不引入 svelte-i18n / svelte-i18next +- 不引入 ORM 之外(仅 sqlx) +- 不写"防御性"`if (!x) return` 满天飞 —— TypeScript 严格 + Rust 类型已保证 +- 不为假想未来需求加扩展点(如可插拔 DataProvider,已被实际架构吸收) +- 不重构未在本 spec 第 9 节登记的文件 + +### 10.3 已验收的核心决策(用户确认) + +- [x] 后端:Rust + Axum + SQLite + tower-sessions +- [x] 部署:单 Docker 镜像、单进程、Rust 直接 serve 静态前端 +- [x] 编辑形态:同页"编辑模式"(inline 拖拽 + 双击 + 右键菜单 + dialog) +- [x] 鉴权:单管理员密码 + bcrypt + signed cookie +- [x] 密码恢复:CLI 子命令 `navsrv reset-password` +- [x] UI:重建 design system 全新风格(不用 Tailwind) +- [x] Footer 重做 +- [x] 数据范式:3NF(item × site 多对多 link 表,不用 JSON 列) +- [x] 多站点语义:增强现有"区域切换"(不做多用户 / 多产物) +- [x] 数据加载:runtime fetch(不打 build-time 注入) +- [x] i18n:自实现,前端 + 数据层双轨 +- [x] 设计风格:**现代极简(Linear / Raycast 风格)** +- [x] Footer:主行 inline 备案 + 次行 Built-with + 英文 locale 隐藏中国大陆备案 +- [x] Session TTL:**30 天 sliding** +- [x] Bootstrap 密码:env 优先;未设时生成随机密码写日志 + `INITIAL_PASSWORD.txt`,改密码后自动删 +- [x] 默认分组:**网络 / 媒体 / NAS / 工具** 四组按用途预分 + +### 10.4 待 implement 阶段细化(不在本 spec 范围) + +- 最终配色(accent 色值 / 中性色阶完整曲线) +- 字号 / 行高的精确阶梯 +- 完整 keyboard shortcut 表 +- favicon 缓存策略 TTL +- session SQLite 表清理 cron +- error code 体系 +- Playwright 端到端测试覆盖矩阵 + +--- + +## 11. Open Questions(已全部按业界最佳实践拍板) + +| # | 问题 | 决策 | 依据 | +|---|---|---|---| +| 1 | Design system 起点风格 | 现代极简(Linear / Raycast 风) | 工具属性 + 高频使用场景默认;信息密度友好;暗 / 亮主题易做 | +| 2 | Footer 设计 | 主行 inline 备案 + 次行 Built-with + 英文 locale 隐藏大陆备案 | ICP / 公安备案是大陆法规要求,对非中文用户无意义;分行减视觉噪声 | +| 3 | Session TTL | 30 天 sliding | GitHub 14d / Linear 90d 之间的工业默认;个人单管理员场景平衡摩擦与风险 | +| 4 | Bootstrap 密码 | env 优先;未设生成随机密码写日志 + 文件,修改后自动删除 | 参考 GitLab Omnibus / Postgres 镜像;safe-by-default 但不挡新手 | +| 5 | 默认分组 | 按用途预分 4 组(网络 / 媒体 / NAS / 工具) | 良好初始体验;结构是可改的,预设结构 ≫ 一锅烩 | + +详见 §6.1 / §7.1 / §7.4 / §10.3 各节落定。 From 3eaa09c02086623c1c71f1c22821c026fa65910b Mon Sep 17 00:00:00 2001 From: messica <bitxwave@163.com> Date: Wed, 20 May 2026 19:44:49 +0800 Subject: [PATCH 02/77] =?UTF-8?q?feat:=20Rust=20backend=20(Plan=201)=20?= =?UTF-8?q?=E2=80=94=20Axum=20+=20SQLite=20+=20auth=20+=20CRUD=20+=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-process Rust binary serving JSON API + SPA. - 3NF SQLite schema (sites/groups/items/links/tags/config) with WAL + foreign keys + busy timeout - NavRepo + ConfigRepo trait + sqlx impl; .sqlx offline metadata committed - /api/nav public read returns full NavBundle (camelCase) - Full CRUD endpoints behind RequireAuth - Single-admin auth: bcrypt cost 12 + signed cookie session via tower-sessions (SQLite store), 30d sliding - Login rate limited (tower-governor 5 burst per 15min) - Bootstrap admin password: env-var or auto-generated 24-char password written to INITIAL_PASSWORD.txt (auto-deleted on first password change) - /api/icons/upload (multipart, ≤1 MiB, ext-allow-list) - /api/favicon proxy with 7d disk cache - ServeDir + SPA fallback (Rust serves SPA + /api/* in one process) - CLI: navsrv reset-password [--password=…] invalidates all sessions - Repo split: web/ + server/ subprojects - 40 cargo tests; clippy --all-targets -D warnings clean; fmt clean --- .dockerignore | 32 +- .gitignore | 51 +- README.md | 8 + server/.env.example | 14 + server/.gitignore | 6 + ...7db2ab007dca57ef0edaa604172a8206dd975.json | 12 + ...64ac52e99659718ad419812da54831f7a190a.json | 32 + ...62f9b924bfe56c03c27ccffb8a9e09cbf4833.json | 50 + ...2d353da71aaff9e95817a32ec9f27ccb93f82.json | 12 + ...93badbad9930e00ba20b0dc1de8c65d199ede.json | 12 + ...99b639e22039fb91c83f9d613b92cf46c1bd3.json | 12 + ...f104fe634b84f6f0195cb4c94f5d188c189da.json | 12 + ...351800b27ba75e058ce84af0ed027ffd70a5a.json | 12 + ...58789df6f08fbbb6a8448b9736036de309932.json | 12 + ...88df0ec7456df24ad633b34d69cf6f8dab040.json | 12 + ...a9256458667e74836a1f3f0ac309b13639d57.json | 12 + ...60ebb3a3200f5892644704aea628b45e81654.json | 12 + ...1cf41de8a71bb7fe0a75a3e8d504196f82628.json | 12 + ...7fab5ba6ad361a1bfddc88dd4c41190515778.json | 12 + ...879dd341f9eb8b27a92d6de7a05c028b7c6fe.json | 12 + ...baa02d6d653c00e94dd7c26cafd6d73eb2c46.json | 12 + ...c182cbe94a1f5d8b67e7c4179d08562ecf5bf.json | 80 + ...3b68d506b3706cc8d1ecace476be0375adb7c.json | 50 + ...49d8a55fcb34a56d34a625f9831c01297b30a.json | 12 + ...2a15109e57de882a4d5a76e35a5b04ab0991d.json | 12 + ...e522c15589cdde0a5c9711bdfb8efd7b79915.json | 12 + ...13f9ddb01a1a19266c22bea75ce536a65ec7b.json | 12 + ...e4e20568aad24916ff9a906ffcbfdf1758530.json | 12 + ...398c6fbe7d09262def5624d4c65e01317d0c8.json | 12 + ...8503678e4cf199514958e63d23c1ed3d484c5.json | 12 + ...6f5436ffc4031ca7480cd75904939b90b6d3b.json | 12 + ...fc366bb64a9615b49e379c807c79efcffe854.json | 12 + ...a73b6efc65c44156c0bb7eb22b382aacb890b.json | 20 + ...0d7376694b86ca986e32503710cb069f74e97.json | 12 + ...781e0e0678d1bee286dd90352646052958da3.json | 12 + ...329ba01b0cc99fe21660b76535b69080fcd96.json | 12 + ...ac607091d7cc6923c55d181a344963a475d7a.json | 12 + ...77fb93cd4820017e8398cf0bc5494ef8dc129.json | 12 + ...5214d5257599ed5d9d8fa48c80e22ceb24c07.json | 12 + ...e727f416d7c1c1e03d49026a12f385c350935.json | 12 + ...f2747f2d87a96e042abc979dcea23acefff2c.json | 12 + ...6a45f3835d7265618cca762389a5811a7f254.json | 12 + ...9727dd5e9aa33541890087c4000124fbd5255.json | 12 + ...2931ab1d15cef4930ecf341f4978c5f650043.json | 12 + ...2495958548a65a6fb4f036f15cff740031806.json | 12 + ...a880a6cf46dba14d98b0a54f0c672600f5db6.json | 26 + ...8c8441f11778e4ac5ef328d51fb76f63e464b.json | 38 + ...8351f81a5678389dc8fc1343e5c2fc207f74b.json | 12 + ...e7158cbf5737bf8f20c06eadaca32fd356344.json | 12 + ...1df901cd0200a902421054b0cd2ad7e448753.json | 12 + ...b6d1e5e6eb96ec425552298d77e1775371d45.json | 12 + server/Cargo.lock | 4103 +++++++++++++++++ server/Cargo.toml | 60 + server/README.md | 48 + server/bootstrap.json | 23 + server/build.rs | 3 + server/migrations/0001_init.sql | 72 + server/rust-toolchain.toml | 4 + server/src/app.rs | 65 + server/src/auth/middleware.rs | 33 + server/src/auth/mod.rs | 4 + server/src/auth/password.rs | 34 + server/src/auth/session.rs | 39 + server/src/cli.rs | 71 + server/src/config.rs | 89 + server/src/db.rs | 71 + server/src/dto.rs | 214 + server/src/error.rs | 122 + server/src/lib.rs | 11 + server/src/main.rs | 65 + server/src/repo/config.rs | 78 + server/src/repo/mod.rs | 6 + server/src/repo/nav.rs | 35 + server/src/repo/sqlx_impl.rs | 608 +++ server/src/routes/auth.rs | 82 + server/src/routes/config.rs | 78 + server/src/routes/favicon.rs | 34 + server/src/routes/groups.rs | 50 + server/src/routes/health.rs | 10 + server/src/routes/icons.rs | 72 + server/src/routes/items.rs | 54 + server/src/routes/mod.rs | 33 + server/src/routes/nav.rs | 15 + server/src/routes/sites.rs | 50 + server/src/routes/tags.rs | 41 + server/src/services/bootstrap.rs | 64 + server/src/services/bundle.rs | 63 + server/src/services/favicon.rs | 70 + server/src/services/migration.rs | 168 + server/src/services/mod.rs | 4 + server/src/state.rs | 26 + server/tests/api_auth.rs | 78 + server/tests/api_config_password.rs | 58 + server/tests/api_groups_sites_tags.rs | 93 + server/tests/api_icons.rs | 48 + server/tests/api_items.rs | 138 + server/tests/api_nav.rs | 15 + server/tests/bootstrap.rs | 54 + server/tests/cli.rs | 31 + server/tests/health.rs | 11 + server/tests/repo_config.rs | 34 + server/tests/repo_items.rs | 184 + server/tests/repo_sites.rs | 74 + server/tests/seed.rs | 31 + server/tests/spa_fallback.rs | 29 + .eslintignore => web/.eslintignore | 0 .eslintrc.cjs => web/.eslintrc.cjs | 0 .npmrc => web/.npmrc | 0 .prettierignore => web/.prettierignore | 0 .prettierrc => web/.prettierrc | 0 package.json => web/package.json | 1 + .../playwright.config.js | 0 pnpm-lock.yaml => web/pnpm-lock.yaml | 0 {src => web/src}/app.d.ts | 0 {src => web/src}/app.html | 0 {src => web/src}/app.scss | 0 {src => web/src}/lib/components/Avatar.svelte | 0 {src => web/src}/lib/components/Footer.svelte | 0 {src => web/src}/lib/components/Header.svelte | 0 {src => web/src}/lib/components/Nav.svelte | 0 .../src}/lib/components/SiteSelect.svelte | 0 {src => web/src}/lib/constants/avatar.ts | 0 {src => web/src}/lib/constants/nav.ts | 0 {src => web/src}/lib/constants/siteInfo.ts | 0 {src => web/src}/lib/store/siteStore.ts | 0 {src => web/src}/lib/utils/index.ts | 0 {src => web/src}/lib/utils/isURL.ts | 0 {src => web/src}/routes/+layout.svelte | 0 {src => web/src}/routes/+page.svelte | 0 {src => web/src}/service-worker.ts | 0 {static => web/static}/avatar.png | Bin {static => web/static}/favicon.png | Bin {static => web/static}/manifest.json | 0 {static => web/static}/navIcons/album.png | Bin {static => web/static}/navIcons/blog.png | Bin {static => web/static}/navIcons/esxi.png | Bin {static => web/static}/navIcons/file.png | Bin .../static}/navIcons/homeAssistant.svg | 0 {static => web/static}/navIcons/jackett.png | Bin {static => web/static}/navIcons/jellyfin.svg | 0 .../static}/navIcons/moviePilot.svg | 0 {static => web/static}/navIcons/nasTools.png | Bin {static => web/static}/navIcons/openWRT.png | Bin {static => web/static}/navIcons/phicomm.png | Bin {static => web/static}/navIcons/plex.png | Bin {static => web/static}/navIcons/portainer.svg | 0 .../static}/navIcons/qBittorrent.png | Bin {static => web/static}/navIcons/qinglong.png | Bin {static => web/static}/navIcons/routerOS.png | Bin {static => web/static}/navIcons/singBox.svg | 0 .../static}/navIcons/siyuanNote.png | Bin .../static}/navIcons/surveillanceStation.png | Bin {static => web/static}/navIcons/synology.png | Bin svelte.config.js => web/svelte.config.js | 0 {tests => web/tests}/test.ts | 0 tsconfig.json => web/tsconfig.json | 0 vite.config.ts => web/vite.config.ts | 13 +- 157 files changed, 8506 insertions(+), 33 deletions(-) create mode 100644 server/.env.example create mode 100644 server/.gitignore create mode 100644 server/.sqlx/query-018c014e9b876efe39ee03b4dac7db2ab007dca57ef0edaa604172a8206dd975.json create mode 100644 server/.sqlx/query-0db0cdc73f5f3b0a724c13e3fa064ac52e99659718ad419812da54831f7a190a.json create mode 100644 server/.sqlx/query-14d0e90d88368fd2b1b382532b962f9b924bfe56c03c27ccffb8a9e09cbf4833.json create mode 100644 server/.sqlx/query-1557cdb522eeb4b2393e1bb6e3f2d353da71aaff9e95817a32ec9f27ccb93f82.json create mode 100644 server/.sqlx/query-1a8e3c65c434b2159b7df6bc7b593badbad9930e00ba20b0dc1de8c65d199ede.json create mode 100644 server/.sqlx/query-1bf99816bc9be757b49c2b1075e99b639e22039fb91c83f9d613b92cf46c1bd3.json create mode 100644 server/.sqlx/query-1fe34b94c20773d35ab0d740b40f104fe634b84f6f0195cb4c94f5d188c189da.json create mode 100644 server/.sqlx/query-2aea185fc7a3724953adef82f75351800b27ba75e058ce84af0ed027ffd70a5a.json create mode 100644 server/.sqlx/query-2efab86a43e8e6c658393bcd90458789df6f08fbbb6a8448b9736036de309932.json create mode 100644 server/.sqlx/query-35c8b3e80537de0a2088a6ee4da88df0ec7456df24ad633b34d69cf6f8dab040.json create mode 100644 server/.sqlx/query-4003dd1d7f77ad559bab2d85679a9256458667e74836a1f3f0ac309b13639d57.json create mode 100644 server/.sqlx/query-4b84fda8f718e500a61e42b414b60ebb3a3200f5892644704aea628b45e81654.json create mode 100644 server/.sqlx/query-5309baf627ca79102c18538856a1cf41de8a71bb7fe0a75a3e8d504196f82628.json create mode 100644 server/.sqlx/query-5317be5ee01f4ad4a6681a368157fab5ba6ad361a1bfddc88dd4c41190515778.json create mode 100644 server/.sqlx/query-563b6cfe1cde0eb0b2019903d8e879dd341f9eb8b27a92d6de7a05c028b7c6fe.json create mode 100644 server/.sqlx/query-5a0667baad5f3321fb5a5f0cd24baa02d6d653c00e94dd7c26cafd6d73eb2c46.json create mode 100644 server/.sqlx/query-5ad2f300b77f5983877f6e576d0c182cbe94a1f5d8b67e7c4179d08562ecf5bf.json create mode 100644 server/.sqlx/query-60833943deb5e25462d920d36833b68d506b3706cc8d1ecace476be0375adb7c.json create mode 100644 server/.sqlx/query-642aadbfbbca043f19b80a1d1b749d8a55fcb34a56d34a625f9831c01297b30a.json create mode 100644 server/.sqlx/query-6b0782599be7e8dad4b38a303432a15109e57de882a4d5a76e35a5b04ab0991d.json create mode 100644 server/.sqlx/query-6da968dd1c08041c5bc91d4d807e522c15589cdde0a5c9711bdfb8efd7b79915.json create mode 100644 server/.sqlx/query-79a3d48f7c65db8e17e469ed54713f9ddb01a1a19266c22bea75ce536a65ec7b.json create mode 100644 server/.sqlx/query-7dc83626e527405130beee7cf3de4e20568aad24916ff9a906ffcbfdf1758530.json create mode 100644 server/.sqlx/query-86cade496a9deb55a0a57c08666398c6fbe7d09262def5624d4c65e01317d0c8.json create mode 100644 server/.sqlx/query-8d846a4e80eda9d30d2c18addbc8503678e4cf199514958e63d23c1ed3d484c5.json create mode 100644 server/.sqlx/query-9add219f4eb09bf47fd82bd426a6f5436ffc4031ca7480cd75904939b90b6d3b.json create mode 100644 server/.sqlx/query-9bd2d1d1e871dde77b4768390d2fc366bb64a9615b49e379c807c79efcffe854.json create mode 100644 server/.sqlx/query-9f446fea4be730b92077288cf55a73b6efc65c44156c0bb7eb22b382aacb890b.json create mode 100644 server/.sqlx/query-a00bce4b3500492530c5c8c017e0d7376694b86ca986e32503710cb069f74e97.json create mode 100644 server/.sqlx/query-acecac2afa949dc59110a3d84d7781e0e0678d1bee286dd90352646052958da3.json create mode 100644 server/.sqlx/query-ba826f8413816631d43cfec3c17329ba01b0cc99fe21660b76535b69080fcd96.json create mode 100644 server/.sqlx/query-bed47da1cfc8e819d2de79a8440ac607091d7cc6923c55d181a344963a475d7a.json create mode 100644 server/.sqlx/query-c09e30cdab3b984f5f5989ccbe577fb93cd4820017e8398cf0bc5494ef8dc129.json create mode 100644 server/.sqlx/query-cdca47905108f250bfcc425e06e5214d5257599ed5d9d8fa48c80e22ceb24c07.json create mode 100644 server/.sqlx/query-d343261c0578c677c168814cf88e727f416d7c1c1e03d49026a12f385c350935.json create mode 100644 server/.sqlx/query-d69cd4da9c0ed4e3528c1ee2494f2747f2d87a96e042abc979dcea23acefff2c.json create mode 100644 server/.sqlx/query-d92d9ce82fe2a58d01b99a324bc6a45f3835d7265618cca762389a5811a7f254.json create mode 100644 server/.sqlx/query-d95fee43c4950b9d7ac420d4a119727dd5e9aa33541890087c4000124fbd5255.json create mode 100644 server/.sqlx/query-da9e4028a1d46d942fcff8b5dfc2931ab1d15cef4930ecf341f4978c5f650043.json create mode 100644 server/.sqlx/query-dbf1e31abbead0d1ceb936d58f82495958548a65a6fb4f036f15cff740031806.json create mode 100644 server/.sqlx/query-ddf98d9fc5694bca2b491fbcc7da880a6cf46dba14d98b0a54f0c672600f5db6.json create mode 100644 server/.sqlx/query-df27ff82e851bb95aef3e576ab68c8441f11778e4ac5ef328d51fb76f63e464b.json create mode 100644 server/.sqlx/query-dfc31268416854e485b22a4706e8351f81a5678389dc8fc1343e5c2fc207f74b.json create mode 100644 server/.sqlx/query-e8a2d24de286cb8df5657c88f39e7158cbf5737bf8f20c06eadaca32fd356344.json create mode 100644 server/.sqlx/query-ece38ab6133af02692986502a961df901cd0200a902421054b0cd2ad7e448753.json create mode 100644 server/.sqlx/query-f645842429f85616ae093960947b6d1e5e6eb96ec425552298d77e1775371d45.json create mode 100644 server/Cargo.lock create mode 100644 server/Cargo.toml create mode 100644 server/README.md create mode 100644 server/bootstrap.json create mode 100644 server/build.rs create mode 100644 server/migrations/0001_init.sql create mode 100644 server/rust-toolchain.toml create mode 100644 server/src/app.rs create mode 100644 server/src/auth/middleware.rs create mode 100644 server/src/auth/mod.rs create mode 100644 server/src/auth/password.rs create mode 100644 server/src/auth/session.rs create mode 100644 server/src/cli.rs create mode 100644 server/src/config.rs create mode 100644 server/src/db.rs create mode 100644 server/src/dto.rs create mode 100644 server/src/error.rs create mode 100644 server/src/lib.rs create mode 100644 server/src/main.rs create mode 100644 server/src/repo/config.rs create mode 100644 server/src/repo/mod.rs create mode 100644 server/src/repo/nav.rs create mode 100644 server/src/repo/sqlx_impl.rs create mode 100644 server/src/routes/auth.rs create mode 100644 server/src/routes/config.rs create mode 100644 server/src/routes/favicon.rs create mode 100644 server/src/routes/groups.rs create mode 100644 server/src/routes/health.rs create mode 100644 server/src/routes/icons.rs create mode 100644 server/src/routes/items.rs create mode 100644 server/src/routes/mod.rs create mode 100644 server/src/routes/nav.rs create mode 100644 server/src/routes/sites.rs create mode 100644 server/src/routes/tags.rs create mode 100644 server/src/services/bootstrap.rs create mode 100644 server/src/services/bundle.rs create mode 100644 server/src/services/favicon.rs create mode 100644 server/src/services/migration.rs create mode 100644 server/src/services/mod.rs create mode 100644 server/src/state.rs create mode 100644 server/tests/api_auth.rs create mode 100644 server/tests/api_config_password.rs create mode 100644 server/tests/api_groups_sites_tags.rs create mode 100644 server/tests/api_icons.rs create mode 100644 server/tests/api_items.rs create mode 100644 server/tests/api_nav.rs create mode 100644 server/tests/bootstrap.rs create mode 100644 server/tests/cli.rs create mode 100644 server/tests/health.rs create mode 100644 server/tests/repo_config.rs create mode 100644 server/tests/repo_items.rs create mode 100644 server/tests/repo_sites.rs create mode 100644 server/tests/seed.rs create mode 100644 server/tests/spa_fallback.rs rename .eslintignore => web/.eslintignore (100%) rename .eslintrc.cjs => web/.eslintrc.cjs (100%) rename .npmrc => web/.npmrc (100%) rename .prettierignore => web/.prettierignore (100%) rename .prettierrc => web/.prettierrc (100%) rename package.json => web/package.json (96%) rename playwright.config.js => web/playwright.config.js (100%) rename pnpm-lock.yaml => web/pnpm-lock.yaml (100%) rename {src => web/src}/app.d.ts (100%) rename {src => web/src}/app.html (100%) rename {src => web/src}/app.scss (100%) rename {src => web/src}/lib/components/Avatar.svelte (100%) rename {src => web/src}/lib/components/Footer.svelte (100%) rename {src => web/src}/lib/components/Header.svelte (100%) rename {src => web/src}/lib/components/Nav.svelte (100%) rename {src => web/src}/lib/components/SiteSelect.svelte (100%) rename {src => web/src}/lib/constants/avatar.ts (100%) rename {src => web/src}/lib/constants/nav.ts (100%) rename {src => web/src}/lib/constants/siteInfo.ts (100%) rename {src => web/src}/lib/store/siteStore.ts (100%) rename {src => web/src}/lib/utils/index.ts (100%) rename {src => web/src}/lib/utils/isURL.ts (100%) rename {src => web/src}/routes/+layout.svelte (100%) rename {src => web/src}/routes/+page.svelte (100%) rename {src => web/src}/service-worker.ts (100%) rename {static => web/static}/avatar.png (100%) rename {static => web/static}/favicon.png (100%) rename {static => web/static}/manifest.json (100%) rename {static => web/static}/navIcons/album.png (100%) rename {static => web/static}/navIcons/blog.png (100%) rename {static => web/static}/navIcons/esxi.png (100%) rename {static => web/static}/navIcons/file.png (100%) rename {static => web/static}/navIcons/homeAssistant.svg (100%) rename {static => web/static}/navIcons/jackett.png (100%) rename {static => web/static}/navIcons/jellyfin.svg (100%) rename {static => web/static}/navIcons/moviePilot.svg (100%) rename {static => web/static}/navIcons/nasTools.png (100%) rename {static => web/static}/navIcons/openWRT.png (100%) rename {static => web/static}/navIcons/phicomm.png (100%) rename {static => web/static}/navIcons/plex.png (100%) rename {static => web/static}/navIcons/portainer.svg (100%) rename {static => web/static}/navIcons/qBittorrent.png (100%) rename {static => web/static}/navIcons/qinglong.png (100%) rename {static => web/static}/navIcons/routerOS.png (100%) rename {static => web/static}/navIcons/singBox.svg (100%) rename {static => web/static}/navIcons/siyuanNote.png (100%) rename {static => web/static}/navIcons/surveillanceStation.png (100%) rename {static => web/static}/navIcons/synology.png (100%) rename svelte.config.js => web/svelte.config.js (100%) rename {tests => web/tests}/test.ts (100%) rename tsconfig.json => web/tsconfig.json (100%) rename vite.config.ts => web/vite.config.ts (53%) diff --git a/.dockerignore b/.dockerignore index d918727..c05b4d2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,15 +1,25 @@ -# README.md -README.md +# Build artifacts +**/node_modules +**/.svelte-kit +**/build +**/target -# .git +# Local data / env +**/.env +**/.env.* +!**/.env.example +**/dev-data +data/ + +# Git / dev .git .gitignore - -# .vscode +.github +.worktrees .vscode - -# node_modules -node_modules - -#editorconfig -.editorconfig +.idea +docs +tests +*.md +!README.md +snapshot_*.png diff --git a/.gitignore b/.gitignore index 9cab67b..f6a93cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,34 @@ +# OS .DS_Store -node_modules -/build -/.svelte-kit -/package -.env -.env.* -!.env.example -.vercel -.output -vite.config.js.timestamp-* -vite.config.ts.timestamp-* -.yarn/* -!.yarn/patches -!.yarn/releases -!.yarn/plugins -!.yarn/sdks -!.yarn/versions -.pnp.* +Thumbs.db + +# Editors +.vscode/ +.idea/ + +# Frontend +web/node_modules/ +web/.svelte-kit/ +web/build/ +web/.env +web/.env.* +!web/.env.example + +# Backend +server/target/ +server/.env +server/.env.* +!server/.env.example +server/dev-data/ + +# Local volumes / runtime +data/ +*.log +*.pid + +# Worktrees (per ~/.claude/rules/worktree-location.md) +.worktrees/ + +# Tests +test-results/ +playwright-report/ diff --git a/README.md b/README.md index 149567e..8eacc30 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,14 @@ A static navigation website written in svelte3. ![H5 Site Switch](./snapshot_h5_site_switch.png) +## Project layout + +- `web/` — SvelteKit SPA (`pnpm dev` / `pnpm build`) +- `server/` — Rust backend (`cargo run`); see `server/README.md` +- `docs/superpowers/` — design specs and implementation plans + +The production deploy is a single Docker image bundling both (Plan 5). + ## Pre-install - [Install git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..13b9d38 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,14 @@ +# Server +PORT=8080 +DATA_DIR=./dev-data +STATIC_DIR=../web/build +RUST_LOG=info,sqlx=warn,tower_http=info + +# Auth +# Set on first boot only. Leave empty to have one generated and written +# to ${DATA_DIR}/INITIAL_PASSWORD.txt. +BOOTSTRAP_ADMIN_PASSWORD= + +# Security +# Set true behind HTTPS terminator. Cookies become Secure-only. +SECURE_COOKIES=false diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..738d184 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,6 @@ +target/ +dev-data/ +.env +.env.* +!.env.example +*.log diff --git a/server/.sqlx/query-018c014e9b876efe39ee03b4dac7db2ab007dca57ef0edaa604172a8206dd975.json b/server/.sqlx/query-018c014e9b876efe39ee03b4dac7db2ab007dca57ef0edaa604172a8206dd975.json new file mode 100644 index 0000000..7910743 --- /dev/null +++ b/server/.sqlx/query-018c014e9b876efe39ee03b4dac7db2ab007dca57ef0edaa604172a8206dd975.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO item_tags (item_id, tag_id)\n SELECT ?, tags.id FROM tags WHERE tags.slug = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "018c014e9b876efe39ee03b4dac7db2ab007dca57ef0edaa604172a8206dd975" +} diff --git a/server/.sqlx/query-0db0cdc73f5f3b0a724c13e3fa064ac52e99659718ad419812da54831f7a190a.json b/server/.sqlx/query-0db0cdc73f5f3b0a724c13e3fa064ac52e99659718ad419812da54831f7a190a.json new file mode 100644 index 0000000..42e8005 --- /dev/null +++ b/server/.sqlx/query-0db0cdc73f5f3b0a724c13e3fa064ac52e99659718ad419812da54831f7a190a.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT il.item_id as \"item_id!: i64\", s.value as \"site_value!\", il.url as \"url!\"\n FROM item_links il JOIN sites s ON s.id = il.site_id", + "describe": { + "columns": [ + { + "name": "item_id!: i64", + "ordinal": 0, + "type_info": "Int64" + }, + { + "name": "site_value!", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "url!", + "ordinal": 2, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "0db0cdc73f5f3b0a724c13e3fa064ac52e99659718ad419812da54831f7a190a" +} diff --git a/server/.sqlx/query-14d0e90d88368fd2b1b382532b962f9b924bfe56c03c27ccffb8a9e09cbf4833.json b/server/.sqlx/query-14d0e90d88368fd2b1b382532b962f9b924bfe56c03c27ccffb8a9e09cbf4833.json new file mode 100644 index 0000000..02499ec --- /dev/null +++ b/server/.sqlx/query-14d0e90d88368fd2b1b382532b962f9b924bfe56c03c27ccffb8a9e09cbf4833.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "SELECT id as \"id!: i64\", value, name,\n name_i18n as \"name_i18n: serde_json::Value\",\n sort_order as \"sort_order!: i64\",\n is_default\n FROM sites ORDER BY sort_order, id", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Int64" + }, + { + "name": "value", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "name_i18n: serde_json::Value", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "sort_order!: i64", + "ordinal": 4, + "type_info": "Int64" + }, + { + "name": "is_default", + "ordinal": 5, + "type_info": "Int64" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + true, + false, + false + ] + }, + "hash": "14d0e90d88368fd2b1b382532b962f9b924bfe56c03c27ccffb8a9e09cbf4833" +} diff --git a/server/.sqlx/query-1557cdb522eeb4b2393e1bb6e3f2d353da71aaff9e95817a32ec9f27ccb93f82.json b/server/.sqlx/query-1557cdb522eeb4b2393e1bb6e3f2d353da71aaff9e95817a32ec9f27ccb93f82.json new file mode 100644 index 0000000..f0ebce5 --- /dev/null +++ b/server/.sqlx/query-1557cdb522eeb4b2393e1bb6e3f2d353da71aaff9e95817a32ec9f27ccb93f82.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE sites SET sort_order=? WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "1557cdb522eeb4b2393e1bb6e3f2d353da71aaff9e95817a32ec9f27ccb93f82" +} diff --git a/server/.sqlx/query-1a8e3c65c434b2159b7df6bc7b593badbad9930e00ba20b0dc1de8c65d199ede.json b/server/.sqlx/query-1a8e3c65c434b2159b7df6bc7b593badbad9930e00ba20b0dc1de8c65d199ede.json new file mode 100644 index 0000000..877912e --- /dev/null +++ b/server/.sqlx/query-1a8e3c65c434b2159b7df6bc7b593badbad9930e00ba20b0dc1de8c65d199ede.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE sites SET name=? WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "1a8e3c65c434b2159b7df6bc7b593badbad9930e00ba20b0dc1de8c65d199ede" +} diff --git a/server/.sqlx/query-1bf99816bc9be757b49c2b1075e99b639e22039fb91c83f9d613b92cf46c1bd3.json b/server/.sqlx/query-1bf99816bc9be757b49c2b1075e99b639e22039fb91c83f9d613b92cf46c1bd3.json new file mode 100644 index 0000000..c418ce5 --- /dev/null +++ b/server/.sqlx/query-1bf99816bc9be757b49c2b1075e99b639e22039fb91c83f9d613b92cf46c1bd3.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE items SET description_i18n=?, updated_at=? WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "1bf99816bc9be757b49c2b1075e99b639e22039fb91c83f9d613b92cf46c1bd3" +} diff --git a/server/.sqlx/query-1fe34b94c20773d35ab0d740b40f104fe634b84f6f0195cb4c94f5d188c189da.json b/server/.sqlx/query-1fe34b94c20773d35ab0d740b40f104fe634b84f6f0195cb4c94f5d188c189da.json new file mode 100644 index 0000000..654d3bf --- /dev/null +++ b/server/.sqlx/query-1fe34b94c20773d35ab0d740b40f104fe634b84f6f0195cb4c94f5d188c189da.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO config (key, value) VALUES (?, ?)\n ON CONFLICT(key) DO UPDATE SET value=excluded.value", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "1fe34b94c20773d35ab0d740b40f104fe634b84f6f0195cb4c94f5d188c189da" +} diff --git a/server/.sqlx/query-2aea185fc7a3724953adef82f75351800b27ba75e058ce84af0ed027ffd70a5a.json b/server/.sqlx/query-2aea185fc7a3724953adef82f75351800b27ba75e058ce84af0ed027ffd70a5a.json new file mode 100644 index 0000000..2ef9298 --- /dev/null +++ b/server/.sqlx/query-2aea185fc7a3724953adef82f75351800b27ba75e058ce84af0ed027ffd70a5a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE groups SET sort_order=? WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "2aea185fc7a3724953adef82f75351800b27ba75e058ce84af0ed027ffd70a5a" +} diff --git a/server/.sqlx/query-2efab86a43e8e6c658393bcd90458789df6f08fbbb6a8448b9736036de309932.json b/server/.sqlx/query-2efab86a43e8e6c658393bcd90458789df6f08fbbb6a8448b9736036de309932.json new file mode 100644 index 0000000..c583685 --- /dev/null +++ b/server/.sqlx/query-2efab86a43e8e6c658393bcd90458789df6f08fbbb6a8448b9736036de309932.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO item_links (item_id, site_id, url)\n SELECT ?, sites.id, ? FROM sites WHERE sites.value = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "2efab86a43e8e6c658393bcd90458789df6f08fbbb6a8448b9736036de309932" +} diff --git a/server/.sqlx/query-35c8b3e80537de0a2088a6ee4da88df0ec7456df24ad633b34d69cf6f8dab040.json b/server/.sqlx/query-35c8b3e80537de0a2088a6ee4da88df0ec7456df24ad633b34d69cf6f8dab040.json new file mode 100644 index 0000000..f6ce780 --- /dev/null +++ b/server/.sqlx/query-35c8b3e80537de0a2088a6ee4da88df0ec7456df24ad633b34d69cf6f8dab040.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE groups SET slug=? WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "35c8b3e80537de0a2088a6ee4da88df0ec7456df24ad633b34d69cf6f8dab040" +} diff --git a/server/.sqlx/query-4003dd1d7f77ad559bab2d85679a9256458667e74836a1f3f0ac309b13639d57.json b/server/.sqlx/query-4003dd1d7f77ad559bab2d85679a9256458667e74836a1f3f0ac309b13639d57.json new file mode 100644 index 0000000..8aed6cd --- /dev/null +++ b/server/.sqlx/query-4003dd1d7f77ad559bab2d85679a9256458667e74836a1f3f0ac309b13639d57.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE sites SET name_i18n=? WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "4003dd1d7f77ad559bab2d85679a9256458667e74836a1f3f0ac309b13639d57" +} diff --git a/server/.sqlx/query-4b84fda8f718e500a61e42b414b60ebb3a3200f5892644704aea628b45e81654.json b/server/.sqlx/query-4b84fda8f718e500a61e42b414b60ebb3a3200f5892644704aea628b45e81654.json new file mode 100644 index 0000000..3a7406f --- /dev/null +++ b/server/.sqlx/query-4b84fda8f718e500a61e42b414b60ebb3a3200f5892644704aea628b45e81654.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO items\n (group_id, name, name_i18n, description, description_i18n,\n icon_kind, icon_value, sort_order, created_at, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT MAX(sort_order)+1 FROM items WHERE group_id IS ?), 0), ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 10 + }, + "nullable": [] + }, + "hash": "4b84fda8f718e500a61e42b414b60ebb3a3200f5892644704aea628b45e81654" +} diff --git a/server/.sqlx/query-5309baf627ca79102c18538856a1cf41de8a71bb7fe0a75a3e8d504196f82628.json b/server/.sqlx/query-5309baf627ca79102c18538856a1cf41de8a71bb7fe0a75a3e8d504196f82628.json new file mode 100644 index 0000000..bcf15a9 --- /dev/null +++ b/server/.sqlx/query-5309baf627ca79102c18538856a1cf41de8a71bb7fe0a75a3e8d504196f82628.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM tags WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "5309baf627ca79102c18538856a1cf41de8a71bb7fe0a75a3e8d504196f82628" +} diff --git a/server/.sqlx/query-5317be5ee01f4ad4a6681a368157fab5ba6ad361a1bfddc88dd4c41190515778.json b/server/.sqlx/query-5317be5ee01f4ad4a6681a368157fab5ba6ad361a1bfddc88dd4c41190515778.json new file mode 100644 index 0000000..55711d6 --- /dev/null +++ b/server/.sqlx/query-5317be5ee01f4ad4a6681a368157fab5ba6ad361a1bfddc88dd4c41190515778.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE groups SET collapsed_default=? WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "5317be5ee01f4ad4a6681a368157fab5ba6ad361a1bfddc88dd4c41190515778" +} diff --git a/server/.sqlx/query-563b6cfe1cde0eb0b2019903d8e879dd341f9eb8b27a92d6de7a05c028b7c6fe.json b/server/.sqlx/query-563b6cfe1cde0eb0b2019903d8e879dd341f9eb8b27a92d6de7a05c028b7c6fe.json new file mode 100644 index 0000000..0a2c447 --- /dev/null +++ b/server/.sqlx/query-563b6cfe1cde0eb0b2019903d8e879dd341f9eb8b27a92d6de7a05c028b7c6fe.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE items SET sort_order=?, updated_at=? WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "563b6cfe1cde0eb0b2019903d8e879dd341f9eb8b27a92d6de7a05c028b7c6fe" +} diff --git a/server/.sqlx/query-5a0667baad5f3321fb5a5f0cd24baa02d6d653c00e94dd7c26cafd6d73eb2c46.json b/server/.sqlx/query-5a0667baad5f3321fb5a5f0cd24baa02d6d653c00e94dd7c26cafd6d73eb2c46.json new file mode 100644 index 0000000..e2dae30 --- /dev/null +++ b/server/.sqlx/query-5a0667baad5f3321fb5a5f0cd24baa02d6d653c00e94dd7c26cafd6d73eb2c46.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO tags (slug, name, name_i18n) VALUES (?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "5a0667baad5f3321fb5a5f0cd24baa02d6d653c00e94dd7c26cafd6d73eb2c46" +} diff --git a/server/.sqlx/query-5ad2f300b77f5983877f6e576d0c182cbe94a1f5d8b67e7c4179d08562ecf5bf.json b/server/.sqlx/query-5ad2f300b77f5983877f6e576d0c182cbe94a1f5d8b67e7c4179d08562ecf5bf.json new file mode 100644 index 0000000..8eb797e --- /dev/null +++ b/server/.sqlx/query-5ad2f300b77f5983877f6e576d0c182cbe94a1f5d8b67e7c4179d08562ecf5bf.json @@ -0,0 +1,80 @@ +{ + "db_name": "SQLite", + "query": "SELECT id as \"id!: i64\", group_id,\n name, name_i18n as \"name_i18n: serde_json::Value\",\n description, description_i18n as \"description_i18n: serde_json::Value\",\n icon_kind, icon_value,\n sort_order as \"sort_order!: i64\",\n created_at as \"created_at!: i64\",\n updated_at as \"updated_at!: i64\"\n FROM items\n ORDER BY group_id, sort_order, id", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Int64" + }, + { + "name": "group_id", + "ordinal": 1, + "type_info": "Int64" + }, + { + "name": "name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "name_i18n: serde_json::Value", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "description_i18n: serde_json::Value", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "icon_kind", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "icon_value", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "sort_order!: i64", + "ordinal": 8, + "type_info": "Int64" + }, + { + "name": "created_at!: i64", + "ordinal": 9, + "type_info": "Int64" + }, + { + "name": "updated_at!: i64", + "ordinal": 10, + "type_info": "Int64" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + true, + true, + false, + true, + true, + true, + false, + false, + false, + false, + false + ] + }, + "hash": "5ad2f300b77f5983877f6e576d0c182cbe94a1f5d8b67e7c4179d08562ecf5bf" +} diff --git a/server/.sqlx/query-60833943deb5e25462d920d36833b68d506b3706cc8d1ecace476be0375adb7c.json b/server/.sqlx/query-60833943deb5e25462d920d36833b68d506b3706cc8d1ecace476be0375adb7c.json new file mode 100644 index 0000000..44bda0a --- /dev/null +++ b/server/.sqlx/query-60833943deb5e25462d920d36833b68d506b3706cc8d1ecace476be0375adb7c.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "SELECT id as \"id!: i64\", slug, name,\n name_i18n as \"name_i18n: serde_json::Value\",\n sort_order as \"sort_order!: i64\",\n collapsed_default\n FROM groups ORDER BY sort_order, id", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Int64" + }, + { + "name": "slug", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "name_i18n: serde_json::Value", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "sort_order!: i64", + "ordinal": 4, + "type_info": "Int64" + }, + { + "name": "collapsed_default", + "ordinal": 5, + "type_info": "Int64" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + true, + false, + false + ] + }, + "hash": "60833943deb5e25462d920d36833b68d506b3706cc8d1ecace476be0375adb7c" +} diff --git a/server/.sqlx/query-642aadbfbbca043f19b80a1d1b749d8a55fcb34a56d34a625f9831c01297b30a.json b/server/.sqlx/query-642aadbfbbca043f19b80a1d1b749d8a55fcb34a56d34a625f9831c01297b30a.json new file mode 100644 index 0000000..7444cdf --- /dev/null +++ b/server/.sqlx/query-642aadbfbbca043f19b80a1d1b749d8a55fcb34a56d34a625f9831c01297b30a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM item_links WHERE item_id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "642aadbfbbca043f19b80a1d1b749d8a55fcb34a56d34a625f9831c01297b30a" +} diff --git a/server/.sqlx/query-6b0782599be7e8dad4b38a303432a15109e57de882a4d5a76e35a5b04ab0991d.json b/server/.sqlx/query-6b0782599be7e8dad4b38a303432a15109e57de882a4d5a76e35a5b04ab0991d.json new file mode 100644 index 0000000..442af47 --- /dev/null +++ b/server/.sqlx/query-6b0782599be7e8dad4b38a303432a15109e57de882a4d5a76e35a5b04ab0991d.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE groups SET name_i18n=? WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "6b0782599be7e8dad4b38a303432a15109e57de882a4d5a76e35a5b04ab0991d" +} diff --git a/server/.sqlx/query-6da968dd1c08041c5bc91d4d807e522c15589cdde0a5c9711bdfb8efd7b79915.json b/server/.sqlx/query-6da968dd1c08041c5bc91d4d807e522c15589cdde0a5c9711bdfb8efd7b79915.json new file mode 100644 index 0000000..3be9abe --- /dev/null +++ b/server/.sqlx/query-6da968dd1c08041c5bc91d4d807e522c15589cdde0a5c9711bdfb8efd7b79915.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE tags SET slug=? WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "6da968dd1c08041c5bc91d4d807e522c15589cdde0a5c9711bdfb8efd7b79915" +} diff --git a/server/.sqlx/query-79a3d48f7c65db8e17e469ed54713f9ddb01a1a19266c22bea75ce536a65ec7b.json b/server/.sqlx/query-79a3d48f7c65db8e17e469ed54713f9ddb01a1a19266c22bea75ce536a65ec7b.json new file mode 100644 index 0000000..471f936 --- /dev/null +++ b/server/.sqlx/query-79a3d48f7c65db8e17e469ed54713f9ddb01a1a19266c22bea75ce536a65ec7b.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM config WHERE key = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "79a3d48f7c65db8e17e469ed54713f9ddb01a1a19266c22bea75ce536a65ec7b" +} diff --git a/server/.sqlx/query-7dc83626e527405130beee7cf3de4e20568aad24916ff9a906ffcbfdf1758530.json b/server/.sqlx/query-7dc83626e527405130beee7cf3de4e20568aad24916ff9a906ffcbfdf1758530.json new file mode 100644 index 0000000..d4b9d5b --- /dev/null +++ b/server/.sqlx/query-7dc83626e527405130beee7cf3de4e20568aad24916ff9a906ffcbfdf1758530.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE items SET icon_kind=?, updated_at=? WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "7dc83626e527405130beee7cf3de4e20568aad24916ff9a906ffcbfdf1758530" +} diff --git a/server/.sqlx/query-86cade496a9deb55a0a57c08666398c6fbe7d09262def5624d4c65e01317d0c8.json b/server/.sqlx/query-86cade496a9deb55a0a57c08666398c6fbe7d09262def5624d4c65e01317d0c8.json new file mode 100644 index 0000000..45e05e8 --- /dev/null +++ b/server/.sqlx/query-86cade496a9deb55a0a57c08666398c6fbe7d09262def5624d4c65e01317d0c8.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM items WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "86cade496a9deb55a0a57c08666398c6fbe7d09262def5624d4c65e01317d0c8" +} diff --git a/server/.sqlx/query-8d846a4e80eda9d30d2c18addbc8503678e4cf199514958e63d23c1ed3d484c5.json b/server/.sqlx/query-8d846a4e80eda9d30d2c18addbc8503678e4cf199514958e63d23c1ed3d484c5.json new file mode 100644 index 0000000..d8e9599 --- /dev/null +++ b/server/.sqlx/query-8d846a4e80eda9d30d2c18addbc8503678e4cf199514958e63d23c1ed3d484c5.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO item_links (item_id, site_id, url)\n SELECT ?, sites.id, ? FROM sites WHERE sites.value = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "8d846a4e80eda9d30d2c18addbc8503678e4cf199514958e63d23c1ed3d484c5" +} diff --git a/server/.sqlx/query-9add219f4eb09bf47fd82bd426a6f5436ffc4031ca7480cd75904939b90b6d3b.json b/server/.sqlx/query-9add219f4eb09bf47fd82bd426a6f5436ffc4031ca7480cd75904939b90b6d3b.json new file mode 100644 index 0000000..0fbef6b --- /dev/null +++ b/server/.sqlx/query-9add219f4eb09bf47fd82bd426a6f5436ffc4031ca7480cd75904939b90b6d3b.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO config (key, value) VALUES (?, ?)\n ON CONFLICT(key) DO UPDATE SET value=excluded.value", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "9add219f4eb09bf47fd82bd426a6f5436ffc4031ca7480cd75904939b90b6d3b" +} diff --git a/server/.sqlx/query-9bd2d1d1e871dde77b4768390d2fc366bb64a9615b49e379c807c79efcffe854.json b/server/.sqlx/query-9bd2d1d1e871dde77b4768390d2fc366bb64a9615b49e379c807c79efcffe854.json new file mode 100644 index 0000000..a64ffac --- /dev/null +++ b/server/.sqlx/query-9bd2d1d1e871dde77b4768390d2fc366bb64a9615b49e379c807c79efcffe854.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM groups WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "9bd2d1d1e871dde77b4768390d2fc366bb64a9615b49e379c807c79efcffe854" +} diff --git a/server/.sqlx/query-9f446fea4be730b92077288cf55a73b6efc65c44156c0bb7eb22b382aacb890b.json b/server/.sqlx/query-9f446fea4be730b92077288cf55a73b6efc65c44156c0bb7eb22b382aacb890b.json new file mode 100644 index 0000000..f8d9b86 --- /dev/null +++ b/server/.sqlx/query-9f446fea4be730b92077288cf55a73b6efc65c44156c0bb7eb22b382aacb890b.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT value FROM config WHERE key = ?", + "describe": { + "columns": [ + { + "name": "value", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "9f446fea4be730b92077288cf55a73b6efc65c44156c0bb7eb22b382aacb890b" +} diff --git a/server/.sqlx/query-a00bce4b3500492530c5c8c017e0d7376694b86ca986e32503710cb069f74e97.json b/server/.sqlx/query-a00bce4b3500492530c5c8c017e0d7376694b86ca986e32503710cb069f74e97.json new file mode 100644 index 0000000..10fd99c --- /dev/null +++ b/server/.sqlx/query-a00bce4b3500492530c5c8c017e0d7376694b86ca986e32503710cb069f74e97.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE groups SET name=? WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "a00bce4b3500492530c5c8c017e0d7376694b86ca986e32503710cb069f74e97" +} diff --git a/server/.sqlx/query-acecac2afa949dc59110a3d84d7781e0e0678d1bee286dd90352646052958da3.json b/server/.sqlx/query-acecac2afa949dc59110a3d84d7781e0e0678d1bee286dd90352646052958da3.json new file mode 100644 index 0000000..355a5dd --- /dev/null +++ b/server/.sqlx/query-acecac2afa949dc59110a3d84d7781e0e0678d1bee286dd90352646052958da3.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE sites SET is_default=? WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "acecac2afa949dc59110a3d84d7781e0e0678d1bee286dd90352646052958da3" +} diff --git a/server/.sqlx/query-ba826f8413816631d43cfec3c17329ba01b0cc99fe21660b76535b69080fcd96.json b/server/.sqlx/query-ba826f8413816631d43cfec3c17329ba01b0cc99fe21660b76535b69080fcd96.json new file mode 100644 index 0000000..c9c60d8 --- /dev/null +++ b/server/.sqlx/query-ba826f8413816631d43cfec3c17329ba01b0cc99fe21660b76535b69080fcd96.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE items SET sort_order=?, group_id=?, updated_at=? WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "ba826f8413816631d43cfec3c17329ba01b0cc99fe21660b76535b69080fcd96" +} diff --git a/server/.sqlx/query-bed47da1cfc8e819d2de79a8440ac607091d7cc6923c55d181a344963a475d7a.json b/server/.sqlx/query-bed47da1cfc8e819d2de79a8440ac607091d7cc6923c55d181a344963a475d7a.json new file mode 100644 index 0000000..636fe4e --- /dev/null +++ b/server/.sqlx/query-bed47da1cfc8e819d2de79a8440ac607091d7cc6923c55d181a344963a475d7a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE sites SET value=? WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "bed47da1cfc8e819d2de79a8440ac607091d7cc6923c55d181a344963a475d7a" +} diff --git a/server/.sqlx/query-c09e30cdab3b984f5f5989ccbe577fb93cd4820017e8398cf0bc5494ef8dc129.json b/server/.sqlx/query-c09e30cdab3b984f5f5989ccbe577fb93cd4820017e8398cf0bc5494ef8dc129.json new file mode 100644 index 0000000..c3c68f8 --- /dev/null +++ b/server/.sqlx/query-c09e30cdab3b984f5f5989ccbe577fb93cd4820017e8398cf0bc5494ef8dc129.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO groups (slug, name, name_i18n, collapsed_default) VALUES (?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "c09e30cdab3b984f5f5989ccbe577fb93cd4820017e8398cf0bc5494ef8dc129" +} diff --git a/server/.sqlx/query-cdca47905108f250bfcc425e06e5214d5257599ed5d9d8fa48c80e22ceb24c07.json b/server/.sqlx/query-cdca47905108f250bfcc425e06e5214d5257599ed5d9d8fa48c80e22ceb24c07.json new file mode 100644 index 0000000..a8c8b97 --- /dev/null +++ b/server/.sqlx/query-cdca47905108f250bfcc425e06e5214d5257599ed5d9d8fa48c80e22ceb24c07.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE items SET name_i18n=?, updated_at=? WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "cdca47905108f250bfcc425e06e5214d5257599ed5d9d8fa48c80e22ceb24c07" +} diff --git a/server/.sqlx/query-d343261c0578c677c168814cf88e727f416d7c1c1e03d49026a12f385c350935.json b/server/.sqlx/query-d343261c0578c677c168814cf88e727f416d7c1c1e03d49026a12f385c350935.json new file mode 100644 index 0000000..8ca0947 --- /dev/null +++ b/server/.sqlx/query-d343261c0578c677c168814cf88e727f416d7c1c1e03d49026a12f385c350935.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM sites WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "d343261c0578c677c168814cf88e727f416d7c1c1e03d49026a12f385c350935" +} diff --git a/server/.sqlx/query-d69cd4da9c0ed4e3528c1ee2494f2747f2d87a96e042abc979dcea23acefff2c.json b/server/.sqlx/query-d69cd4da9c0ed4e3528c1ee2494f2747f2d87a96e042abc979dcea23acefff2c.json new file mode 100644 index 0000000..3a1ef85 --- /dev/null +++ b/server/.sqlx/query-d69cd4da9c0ed4e3528c1ee2494f2747f2d87a96e042abc979dcea23acefff2c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE tags SET name=? WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "d69cd4da9c0ed4e3528c1ee2494f2747f2d87a96e042abc979dcea23acefff2c" +} diff --git a/server/.sqlx/query-d92d9ce82fe2a58d01b99a324bc6a45f3835d7265618cca762389a5811a7f254.json b/server/.sqlx/query-d92d9ce82fe2a58d01b99a324bc6a45f3835d7265618cca762389a5811a7f254.json new file mode 100644 index 0000000..7a4702e --- /dev/null +++ b/server/.sqlx/query-d92d9ce82fe2a58d01b99a324bc6a45f3835d7265618cca762389a5811a7f254.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO item_tags (item_id, tag_id)\n SELECT ?, tags.id FROM tags WHERE tags.slug = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "d92d9ce82fe2a58d01b99a324bc6a45f3835d7265618cca762389a5811a7f254" +} diff --git a/server/.sqlx/query-d95fee43c4950b9d7ac420d4a119727dd5e9aa33541890087c4000124fbd5255.json b/server/.sqlx/query-d95fee43c4950b9d7ac420d4a119727dd5e9aa33541890087c4000124fbd5255.json new file mode 100644 index 0000000..3e348e8 --- /dev/null +++ b/server/.sqlx/query-d95fee43c4950b9d7ac420d4a119727dd5e9aa33541890087c4000124fbd5255.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO sites (value, name, name_i18n, is_default) VALUES (?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "d95fee43c4950b9d7ac420d4a119727dd5e9aa33541890087c4000124fbd5255" +} diff --git a/server/.sqlx/query-da9e4028a1d46d942fcff8b5dfc2931ab1d15cef4930ecf341f4978c5f650043.json b/server/.sqlx/query-da9e4028a1d46d942fcff8b5dfc2931ab1d15cef4930ecf341f4978c5f650043.json new file mode 100644 index 0000000..5cd5e06 --- /dev/null +++ b/server/.sqlx/query-da9e4028a1d46d942fcff8b5dfc2931ab1d15cef4930ecf341f4978c5f650043.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE items SET icon_value=?, updated_at=? WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "da9e4028a1d46d942fcff8b5dfc2931ab1d15cef4930ecf341f4978c5f650043" +} diff --git a/server/.sqlx/query-dbf1e31abbead0d1ceb936d58f82495958548a65a6fb4f036f15cff740031806.json b/server/.sqlx/query-dbf1e31abbead0d1ceb936d58f82495958548a65a6fb4f036f15cff740031806.json new file mode 100644 index 0000000..f919aec --- /dev/null +++ b/server/.sqlx/query-dbf1e31abbead0d1ceb936d58f82495958548a65a6fb4f036f15cff740031806.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE items SET name=?, updated_at=? WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "dbf1e31abbead0d1ceb936d58f82495958548a65a6fb4f036f15cff740031806" +} diff --git a/server/.sqlx/query-ddf98d9fc5694bca2b491fbcc7da880a6cf46dba14d98b0a54f0c672600f5db6.json b/server/.sqlx/query-ddf98d9fc5694bca2b491fbcc7da880a6cf46dba14d98b0a54f0c672600f5db6.json new file mode 100644 index 0000000..b150598 --- /dev/null +++ b/server/.sqlx/query-ddf98d9fc5694bca2b491fbcc7da880a6cf46dba14d98b0a54f0c672600f5db6.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT it.item_id as \"item_id!: i64\", t.slug as \"slug!\"\n FROM item_tags it JOIN tags t ON t.id = it.tag_id", + "describe": { + "columns": [ + { + "name": "item_id!: i64", + "ordinal": 0, + "type_info": "Int64" + }, + { + "name": "slug!", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false + ] + }, + "hash": "ddf98d9fc5694bca2b491fbcc7da880a6cf46dba14d98b0a54f0c672600f5db6" +} diff --git a/server/.sqlx/query-df27ff82e851bb95aef3e576ab68c8441f11778e4ac5ef328d51fb76f63e464b.json b/server/.sqlx/query-df27ff82e851bb95aef3e576ab68c8441f11778e4ac5ef328d51fb76f63e464b.json new file mode 100644 index 0000000..2957267 --- /dev/null +++ b/server/.sqlx/query-df27ff82e851bb95aef3e576ab68c8441f11778e4ac5ef328d51fb76f63e464b.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT id as \"id!: i64\", slug, name,\n name_i18n as \"name_i18n: serde_json::Value\"\n FROM tags ORDER BY id", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Int64" + }, + { + "name": "slug", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "name_i18n: serde_json::Value", + "ordinal": 3, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + true + ] + }, + "hash": "df27ff82e851bb95aef3e576ab68c8441f11778e4ac5ef328d51fb76f63e464b" +} diff --git a/server/.sqlx/query-dfc31268416854e485b22a4706e8351f81a5678389dc8fc1343e5c2fc207f74b.json b/server/.sqlx/query-dfc31268416854e485b22a4706e8351f81a5678389dc8fc1343e5c2fc207f74b.json new file mode 100644 index 0000000..ea1dc66 --- /dev/null +++ b/server/.sqlx/query-dfc31268416854e485b22a4706e8351f81a5678389dc8fc1343e5c2fc207f74b.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM item_tags WHERE item_id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "dfc31268416854e485b22a4706e8351f81a5678389dc8fc1343e5c2fc207f74b" +} diff --git a/server/.sqlx/query-e8a2d24de286cb8df5657c88f39e7158cbf5737bf8f20c06eadaca32fd356344.json b/server/.sqlx/query-e8a2d24de286cb8df5657c88f39e7158cbf5737bf8f20c06eadaca32fd356344.json new file mode 100644 index 0000000..8ea2b39 --- /dev/null +++ b/server/.sqlx/query-e8a2d24de286cb8df5657c88f39e7158cbf5737bf8f20c06eadaca32fd356344.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE tags SET name_i18n=? WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "e8a2d24de286cb8df5657c88f39e7158cbf5737bf8f20c06eadaca32fd356344" +} diff --git a/server/.sqlx/query-ece38ab6133af02692986502a961df901cd0200a902421054b0cd2ad7e448753.json b/server/.sqlx/query-ece38ab6133af02692986502a961df901cd0200a902421054b0cd2ad7e448753.json new file mode 100644 index 0000000..d8bcf00 --- /dev/null +++ b/server/.sqlx/query-ece38ab6133af02692986502a961df901cd0200a902421054b0cd2ad7e448753.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE items SET description=?, updated_at=? WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "ece38ab6133af02692986502a961df901cd0200a902421054b0cd2ad7e448753" +} diff --git a/server/.sqlx/query-f645842429f85616ae093960947b6d1e5e6eb96ec425552298d77e1775371d45.json b/server/.sqlx/query-f645842429f85616ae093960947b6d1e5e6eb96ec425552298d77e1775371d45.json new file mode 100644 index 0000000..535f0bb --- /dev/null +++ b/server/.sqlx/query-f645842429f85616ae093960947b6d1e5e6eb96ec425552298d77e1775371d45.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE items SET group_id=?, updated_at=? WHERE id=?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "f645842429f85616ae093960947b6d1e5e6eb96ec425552298d77e1775371d45" +} diff --git a/server/Cargo.lock b/server/Cargo.lock new file mode 100644 index 0000000..25efe52 --- /dev/null +++ b/server/Cargo.lock @@ -0,0 +1,4103 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "auto-future" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "bytes", + "futures-util", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.4.0", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "axum-test" +version = "15.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac63648e380fd001402a02ec804e7686f9c4751f8cad85b7de0b53dae483a128" +dependencies = [ + "anyhow", + "auto-future", + "axum", + "bytes", + "cookie", + "http 1.4.0", + "http-body-util", + "hyper", + "hyper-util", + "mime", + "pretty_assertions", + "reserve-port", + "rust-multipart-rfc7578_2", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "tokio", + "tower 0.5.3", + "url", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7" +dependencies = [ + "base64 0.22.1", + "blowfish", + "getrandom 0.2.17", + "subtle", + "zeroize", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "pear", + "serde", + "toml", + "uncased", + "version_check", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "forwarded-header-value" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" +dependencies = [ + "nonempty", + "thiserror 1.0.69", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "governor" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68a7f542ee6b35af73b06abc0dad1c1bae89964e4e253bc4b587b91c9637867b" +dependencies = [ + "cfg-if", + "dashmap", + "futures", + "futures-timer", + "no-std-compat", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.8.6", + "smallvec", + "spinning_top", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http 1.4.0", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.0", + "hyper", + "hyper-util", + "rustls 0.23.40", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.7", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "if_chain" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.5", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", + "serde", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http 1.4.0", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "navsrv" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "axum-test", + "bcrypt", + "bytes", + "chrono", + "clap", + "dotenvy", + "figment", + "mime", + "multer", + "rand 0.8.6", + "reqwest", + "serde", + "serde_json", + "sqlx", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tower 0.4.13", + "tower-http 0.5.2", + "tower-sessions", + "tower-sessions-sqlx-store", + "tower_governor", + "tracing", + "tracing-subscriber", + "validator", +] + +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nonempty" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "version_check", + "yansi", +] + +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.40", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls 0.23.40", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.40", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower 0.5.3", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.7", +] + +[[package]] +name = "reserve-port" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94070964579245eb2f76e62a7668fe87bd9969ed6c41256f3bf614e3323dd3cc" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b748410c0afdef2ebbe3685a6a862e2ee937127cdaae623336a459451c8d57" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http 0.2.12", + "mime", + "mime_guess", + "rand 0.8.6", + "thiserror 1.0.69", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.13", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlformat" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" +dependencies = [ + "nom", + "unicode_categories", +] + +[[package]] +name = "sqlx" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9a2ccff1a000a5a59cd33da541d9f2fdcd9e6e8229cc200565942bff36d0aaa" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ba59a9342a3d9bab6c56c118be528b27c9b60e490080e9711a04dccac83ef6" +dependencies = [ + "ahash", + "atoi", + "byteorder", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-channel", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashlink", + "hex", + "indexmap", + "log", + "memchr", + "once_cell", + "paste", + "percent-encoding", + "rustls 0.21.12", + "rustls-pemfile", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlformat", + "thiserror 1.0.69", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.25.4", +] + +[[package]] +name = "sqlx-macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea40e2345eb2faa9e1e5e326db8c34711317d2b5e08d0d5741619048a803127" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 1.0.109", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5833ef53aaa16d860e92123292f1f6a3d53c34ba8b1969f152ef1a7bb803f3c8" +dependencies = [ + "dotenvy", + "either", + "heck 0.4.1", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 1.0.109", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418" +dependencies = [ + "atoi", + "base64 0.21.7", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 1.0.69", + "time", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e" +dependencies = [ + "atoi", + "base64 0.21.7", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 1.0.69", + "time", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "time", + "tracing", + "url", + "urlencoding", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.40", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-cookies" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fd0118512cf0b3768f7fcccf0bef1ae41d68f2b45edc1e77432b36c97c56c6d" +dependencies = [ + "async-trait", + "axum-core", + "cookie", + "futures-util", + "http 1.4.0", + "parking_lot", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.4.0", + "http-body", + "pin-project-lite", + "tower 0.5.3", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tower-sessions" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3db9ccda3dabcbaad83dda82d9486eaa0a6983fe0cecfe113087a990cd240e1" +dependencies = [ + "async-trait", + "http 1.4.0", + "time", + "tokio", + "tower-cookies", + "tower-layer", + "tower-service", + "tower-sessions-core", + "tower-sessions-memory-store", + "tracing", +] + +[[package]] +name = "tower-sessions-core" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc792f6a4179356f9093447edebe14bb73dbc99be636886281313cb98b47201b" +dependencies = [ + "async-trait", + "axum-core", + "base64 0.21.7", + "futures", + "http 1.4.0", + "parking_lot", + "rand 0.8.6", + "serde", + "serde_json", + "thiserror 1.0.69", + "time", + "tokio", + "tracing", +] + +[[package]] +name = "tower-sessions-memory-store" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d780288cc38a7f001b39a22392c29b1398bda163c846f07b1253713042bc549" +dependencies = [ + "async-trait", + "time", + "tokio", + "tower-sessions-core", +] + +[[package]] +name = "tower-sessions-sqlx-store" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93bb337be75252391f9cff681fac300fa9df91bdccdc00c88c88b174637b00d" +dependencies = [ + "async-trait", + "rmp-serde", + "sqlx", + "thiserror 1.0.69", + "time", + "tower-sessions-core", +] + +[[package]] +name = "tower_governor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3790eac6ad3fb8d9d96c2b040ae06e2517aa24b067545d1078b96ae72f7bb9a7" +dependencies = [ + "axum", + "forwarded-header-value", + "governor", + "http 1.4.0", + "pin-project", + "thiserror 1.0.69", + "tower 0.4.13", + "tracing", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna 1.1.0", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "validator" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b92f40481c04ff1f4f61f304d61793c7b56ff76ac1469f1beb199b1445b253bd" +dependencies = [ + "idna 0.4.0", + "lazy_static", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc44ca3088bb3ba384d9aecf40c6a23a676ce23e09bdaca2073d99c207f864af" +dependencies = [ + "if_chain", + "lazy_static", + "proc-macro-error", + "proc-macro2", + "quote", + "regex", + "syn 1.0.109", + "validator_types", +] + +[[package]] +name = "validator_types" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111abfe30072511849c5910134e8baf8dc05de4c0e5903d681cbd5c9c4d611e3" +dependencies = [ + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..3e009f6 --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,60 @@ +[package] +name = "navsrv" +version = "0.1.0" +edition = "2021" +rust-version = "1.79" +default-run = "navsrv" + +[[bin]] +name = "navsrv" +path = "src/main.rs" + +[lib] +name = "navsrv" +path = "src/lib.rs" + +[dependencies] +axum = { version = "0.7", features = ["macros", "multipart"] } +tokio = { version = "1", features = ["full"] } +tower = "0.4" +tower-http = { version = "0.5", features = ["fs", "trace", "compression-gzip", "set-header"] } +tower-sessions = "0.10" +tower-sessions-sqlx-store = { version = "0.10", features = ["sqlite"] } +tower_governor = "0.3" + +sqlx = { version = "0.7", default-features = false, features = ["runtime-tokio-rustls", "sqlite", "chrono", "json", "macros", "migrate"] } + +serde = { version = "1", features = ["derive"] } +serde_json = "1" +validator = { version = "0.16", features = ["derive"] } + +bcrypt = "0.15" +rand = "0.8" + +clap = { version = "4", features = ["derive"] } +figment = { version = "0.10", features = ["toml", "env"] } +dotenvy = "0.15" + +thiserror = "1" +anyhow = "1" + +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } + +chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } + +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +mime = "0.3" +multer = "3" +bytes = "1" +async-trait = "0.1" + +[dev-dependencies] +axum-test = "15" +tempfile = "3" +serde_json = "1" + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = true diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..9262ff1 --- /dev/null +++ b/server/README.md @@ -0,0 +1,48 @@ +# navsrv + +Rust backend for the navigation site (Plan 1 of 5). Serves the SPA + JSON API at `:8080`. + +## Quickstart (dev) + +```bash +cd server +cp .env.example .env # edit if needed +cargo run # listens on :8080 +``` + +On first boot, an admin password is generated and printed. It's also written to +`./dev-data/INITIAL_PASSWORD.txt`. Log in via the SPA to change it; the file is +auto-deleted after a successful change. + +To start the SPA dev server (port 5173) talking to this backend: + +```bash +cd ../web +pnpm dev +# /api/* is proxied to localhost:8080 by web/vite.config.ts +``` + +## Configuration (env) + +| Var | Default | Notes | +|---------------------------|-----------------------|----------------------------------------| +| `PORT` | `8080` | | +| `DATA_DIR` | `./dev-data` | SQLite DB + uploads + INITIAL_PASSWORD | +| `STATIC_DIR` | `../web/build` | SvelteKit `pnpm build` output | +| `BOOTSTRAP_ADMIN_PASSWORD`| (unset) | First boot only; else random+file | +| `SECURE_COOKIES` | `false` | Set `true` behind HTTPS | +| `RUST_LOG` | `info,sqlx=warn,...` | tracing-subscriber filter | + +## CLI + +``` +navsrv reset-password [--password <new>] # invalidates all sessions; deletes INITIAL_PASSWORD.txt +``` + +## Tests + +```bash +cargo test +cargo clippy --all-targets -- -D warnings +cargo fmt --check +``` diff --git a/server/bootstrap.json b/server/bootstrap.json new file mode 100644 index 0000000..2c4543d --- /dev/null +++ b/server/bootstrap.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": 1, + "meta": { + "siteName": "Navigation", + "siteAvatarPath": "/avatar.png", + "siteCopyright": "Copyright © 2026 — All rights reserved.", + "siteIcp": null, + "sitePolice": null, + "defaultTheme": "system" + }, + "sites": [ + { "value": "shangHai", "name": "上海", "name_i18n": { "en": "Shanghai" }, "is_default": true, "sort_order": 0 }, + { "value": "beiJing", "name": "北京", "name_i18n": { "en": "Beijing" }, "is_default": false, "sort_order": 1 } + ], + "groups": [ + { "slug": "network", "name": "网络", "name_i18n": { "en": "Network" }, "sort_order": 0 }, + { "slug": "media", "name": "媒体", "name_i18n": { "en": "Media" }, "sort_order": 1 }, + { "slug": "nas", "name": "NAS", "name_i18n": { "en": "NAS" }, "sort_order": 2 }, + { "slug": "tools", "name": "工具", "name_i18n": { "en": "Tools" }, "sort_order": 3 } + ], + "tags": [], + "items": [] +} diff --git a/server/build.rs b/server/build.rs new file mode 100644 index 0000000..23e373a --- /dev/null +++ b/server/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo:rerun-if-changed=bootstrap.json"); +} diff --git a/server/migrations/0001_init.sql b/server/migrations/0001_init.sql new file mode 100644 index 0000000..2c51208 --- /dev/null +++ b/server/migrations/0001_init.sql @@ -0,0 +1,72 @@ +-- server/migrations/0001_init.sql + +PRAGMA foreign_keys = ON; + +CREATE TABLE sites ( + id INTEGER PRIMARY KEY, + value TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + name_i18n TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + is_default INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE groups ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + name_i18n TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + collapsed_default INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE items ( + id INTEGER PRIMARY KEY, + group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL, + name TEXT NOT NULL, + name_i18n TEXT, + description TEXT, + description_i18n TEXT, + icon_kind TEXT NOT NULL CHECK (icon_kind IN ('asset','url','auto-favicon')), + icon_value TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE item_links ( + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE, + url TEXT NOT NULL, + PRIMARY KEY (item_id, site_id) +); + +CREATE TABLE tags ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + name_i18n TEXT +); + +CREATE TABLE item_tags ( + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (item_id, tag_id) +); + +CREATE TABLE config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE INDEX idx_items_group_sort ON items(group_id, sort_order); +CREATE INDEX idx_item_links_site ON item_links(site_id); +CREATE INDEX idx_item_tags_tag ON item_tags(tag_id); + +INSERT INTO config (key, value) VALUES ('schema_version', '1'); + +CREATE TABLE tower_sessions ( + id TEXT PRIMARY KEY NOT NULL, + data BLOB NOT NULL, + expiry_date INTEGER NOT NULL +); diff --git a/server/rust-toolchain.toml b/server/rust-toolchain.toml new file mode 100644 index 0000000..85f3606 --- /dev/null +++ b/server/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] +profile = "minimal" diff --git a/server/src/app.rs b/server/src/app.rs new file mode 100644 index 0000000..5c59373 --- /dev/null +++ b/server/src/app.rs @@ -0,0 +1,65 @@ +use axum::http::{header, HeaderValue, Request}; +use axum::Router; +use std::path::PathBuf; +use tower_http::compression::CompressionLayer; +use tower_http::services::{ServeDir, ServeFile}; +use tower_http::set_header::SetResponseHeaderLayer; +use tower_http::trace::{DefaultOnResponse, TraceLayer}; +use tower_sessions::SessionManagerLayer; +use tower_sessions_sqlx_store::SqliteStore; +use tracing::Level; + +use crate::routes; +use crate::state::AppState; + +pub fn build_app( + state: AppState, + session_layer: SessionManagerLayer<SqliteStore>, + static_dir: PathBuf, +) -> Router { + let trace = TraceLayer::new_for_http() + .make_span_with(|req: &Request<_>| { + let method = req.method(); + let uri = req.uri().path(); + tracing::info_span!("http", %method, %uri) + }) + .on_response(DefaultOnResponse::new().level(Level::INFO)); + + let index_html = static_dir.join("index.html"); + let serve_dir = ServeDir::new(static_dir.clone()).fallback(ServeFile::new(index_html)); + + let user_icons_dir = state.data_dir.join("icons"); + + Router::new() + .merge(routes::api(state)) + .nest_service("/icons", ServeDir::new(user_icons_dir)) + .fallback_service(serve_dir) + .layer(session_layer) + .layer(trace) + .layer(CompressionLayer::new()) + .layer(SetResponseHeaderLayer::if_not_present( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + header::REFERRER_POLICY, + HeaderValue::from_static("same-origin"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + header::X_FRAME_OPTIONS, + HeaderValue::from_static("DENY"), + )) +} + +pub async fn build_app_for_tests() -> anyhow::Result<Router> { + use crate::auth::session::layer as session_layer; + use crate::db::connect_in_memory; + use crate::repo::{SqlxConfigRepo, SqlxNavRepo}; + use std::sync::Arc; + let pool = connect_in_memory().await?; + let nav = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg = Arc::new(SqlxConfigRepo::new(pool.clone())); + let dir = std::env::temp_dir(); + let state = AppState::new(nav, cfg, dir.clone()); + Ok(build_app(state, session_layer(pool, false), dir)) +} diff --git a/server/src/auth/middleware.rs b/server/src/auth/middleware.rs new file mode 100644 index 0000000..ca0e55a --- /dev/null +++ b/server/src/auth/middleware.rs @@ -0,0 +1,33 @@ +use axum::async_trait; +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use tower_sessions::Session; + +use crate::auth::session::SESSION_KEY_AUTHED; +use crate::error::AppError; + +pub struct RequireAuth; + +#[async_trait] +impl<S> FromRequestParts<S> for RequireAuth +where + S: Send + Sync, +{ + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { + let session = Session::from_request_parts(parts, state) + .await + .map_err(|_| AppError::Unauthenticated)?; + let authed = session + .get::<bool>(SESSION_KEY_AUTHED) + .await + .map_err(|_| AppError::Unauthenticated)? + .unwrap_or(false); + if authed { + Ok(Self) + } else { + Err(AppError::Unauthenticated) + } + } +} diff --git a/server/src/auth/mod.rs b/server/src/auth/mod.rs new file mode 100644 index 0000000..8fc2dfe --- /dev/null +++ b/server/src/auth/mod.rs @@ -0,0 +1,4 @@ +pub mod middleware; +pub mod password; +pub mod session; +pub use middleware::RequireAuth; diff --git a/server/src/auth/password.rs b/server/src/auth/password.rs new file mode 100644 index 0000000..19488aa --- /dev/null +++ b/server/src/auth/password.rs @@ -0,0 +1,34 @@ +//! bcrypt-based password hashing. + +use crate::error::{AppError, Result}; + +pub const BCRYPT_COST: u32 = 12; + +pub fn hash(plain: &str) -> Result<String> { + if plain.is_empty() { + return Err(AppError::Validation("password must not be empty".into())); + } + Ok(bcrypt::hash(plain, BCRYPT_COST)?) +} + +pub fn verify(plain: &str, hashed: &str) -> Result<bool> { + Ok(bcrypt::verify(plain, hashed)?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hash_then_verify_roundtrip() { + let h = hash("hunter2").unwrap(); + assert!(verify("hunter2", &h).unwrap()); + assert!(!verify("wrong", &h).unwrap()); + } + + #[test] + fn empty_password_rejected() { + let err = hash("").unwrap_err(); + assert!(matches!(err, AppError::Validation(_))); + } +} diff --git a/server/src/auth/session.rs b/server/src/auth/session.rs new file mode 100644 index 0000000..2331599 --- /dev/null +++ b/server/src/auth/session.rs @@ -0,0 +1,39 @@ +use sqlx::SqlitePool; +use std::time::Duration; +use tower_sessions::cookie::time::Duration as CookieDur; +use tower_sessions::cookie::SameSite; +use tower_sessions::{Expiry, SessionManagerLayer}; +use tower_sessions_sqlx_store::SqliteStore; + +pub const SESSION_KEY_AUTHED: &str = "authed"; + +pub fn layer(pool: SqlitePool, secure: bool) -> SessionManagerLayer<SqliteStore> { + let store = SqliteStore::new(pool) + .with_table_name("tower_sessions") + .expect("valid table"); + // RFC 6265bis: `__Host-` prefix REQUIRES Secure. Use it only when secure=true + // (HTTPS deploy) so dev-mode HTTP cookies aren't silently rejected by clients. + let name = if secure { "__Host-sid" } else { "sid" }; + SessionManagerLayer::new(store) + .with_name(name) + .with_secure(secure) + .with_http_only(true) + .with_same_site(SameSite::Lax) + .with_expiry(Expiry::OnInactivity(CookieDur::days(30))) +} + +/// Background task: prune expired sessions every hour. +pub async fn run_pruner(pool: SqlitePool) { + let mut tick = tokio::time::interval(Duration::from_secs(3600)); + loop { + tick.tick().await; + let now = chrono::Utc::now().timestamp_millis(); + if let Err(e) = sqlx::query("DELETE FROM tower_sessions WHERE expiry_date < ?") + .bind(now) + .execute(&pool) + .await + { + tracing::warn!(error = %e, "session pruner failed"); + } + } +} diff --git a/server/src/cli.rs b/server/src/cli.rs new file mode 100644 index 0000000..b3a0fc5 --- /dev/null +++ b/server/src/cli.rs @@ -0,0 +1,71 @@ +use anyhow::Context; +use clap::{Parser, Subcommand}; +use std::io::{self, Write}; + +use crate::auth::password; +use crate::config::Settings; +use crate::db::{connect, migrate}; +use crate::repo::{ConfigRepo, SqlxConfigRepo}; + +#[derive(Parser, Debug)] +#[command(name = "navsrv", version)] +pub struct Cli { + #[command(subcommand)] + pub command: Option<Command>, +} + +#[derive(Subcommand, Debug)] +pub enum Command { + /// Reset the admin password. Reads new password from --password or stdin. + ResetPassword { + /// New password. If omitted, prompted on stdin. + #[arg(long)] + password: Option<String>, + }, +} + +pub async fn run_command(cmd: Command) -> anyhow::Result<()> { + match cmd { + Command::ResetPassword { password: maybe } => { + let settings = Settings::load()?; + let pool = connect(&settings.db_url()).await.context("db connect")?; + migrate(&pool).await.context("migrate")?; + let cfg: std::sync::Arc<dyn ConfigRepo> = + std::sync::Arc::new(SqlxConfigRepo::new(pool.clone())); + let pw = match maybe { + Some(p) => p, + None => { + let mut buf = String::new(); + print!("New admin password: "); + io::stdout().flush().ok(); + io::stdin().read_line(&mut buf).context("read stdin")?; + buf.trim().to_string() + } + }; + if pw.len() < 8 { + anyhow::bail!("password must be at least 8 chars"); + } + let hash = password::hash(&pw)?; + let now = chrono::Utc::now().timestamp_millis().to_string(); + cfg.upsert_many(&[ + ("admin_password_hash", &hash), + ("admin_password_updated_at", &now), + ]) + .await?; + + // Invalidate all sessions. + sqlx::query("DELETE FROM tower_sessions") + .execute(&pool) + .await?; + + // Remove leftover INITIAL_PASSWORD.txt if any. + let initial = settings.data_dir.join("INITIAL_PASSWORD.txt"); + if initial.exists() { + let _ = std::fs::remove_file(&initial); + } + + println!("Admin password reset; all sessions invalidated."); + Ok(()) + } + } +} diff --git a/server/src/config.rs b/server/src/config.rs new file mode 100644 index 0000000..522143a --- /dev/null +++ b/server/src/config.rs @@ -0,0 +1,89 @@ +//! Runtime configuration loaded from env (with optional .env). + +use figment::providers::Env; +use figment::Figment; +use serde::Deserialize; +use std::path::PathBuf; + +#[derive(Debug, Clone, Deserialize)] +pub struct Settings { + /// TCP port to listen on. + #[serde(default = "default_port")] + pub port: u16, + /// Directory holding `data.db`, `INITIAL_PASSWORD.txt`, and the icon cache. + #[serde(default = "default_data_dir")] + pub data_dir: PathBuf, + /// Directory holding the SvelteKit build output (HTML/JS/CSS). + #[serde(default = "default_static_dir")] + pub static_dir: PathBuf, + /// Initial admin password, if provided. When None, a random one is generated on first boot. + pub bootstrap_admin_password: Option<String>, + /// Set true on prod deployments to require Secure cookies. + #[serde(default)] + pub secure_cookies: bool, + /// `tracing-subscriber` env filter, e.g. "info,sqlx=warn". + #[serde(default = "default_log")] + pub rust_log: String, +} + +fn default_port() -> u16 { + 8080 +} +fn default_data_dir() -> PathBuf { + PathBuf::from("./dev-data") +} +fn default_static_dir() -> PathBuf { + PathBuf::from("../web/build") +} +fn default_log() -> String { + "info,sqlx=warn,tower_http=info".into() +} + +impl Settings { + pub fn load() -> anyhow::Result<Self> { + // Best-effort .env loading; ignore if missing. + let _ = dotenvy::dotenv(); + let s: Settings = Figment::new().merge(Env::raw().split("__")).extract()?; + std::fs::create_dir_all(&s.data_dir)?; + Ok(s) + } + + pub fn db_url(&self) -> String { + let path = self.data_dir.join("data.db"); + format!("sqlite://{}?mode=rwc", path.display()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_are_sane() { + // SAFETY: this is single-threaded test code; std::env safe in tests. + std::env::remove_var("PORT"); + std::env::remove_var("DATA_DIR"); + std::env::remove_var("STATIC_DIR"); + std::env::remove_var("BOOTSTRAP_ADMIN_PASSWORD"); + std::env::remove_var("SECURE_COOKIES"); + std::env::remove_var("RUST_LOG"); + let s = Settings::load().unwrap(); + assert_eq!(s.port, 8080); + assert!(s.data_dir.ends_with("dev-data")); + assert!(!s.secure_cookies); + assert!(s.bootstrap_admin_password.is_none()); + } + + #[test] + fn db_url_uses_data_dir() { + let s = Settings { + port: 8080, + data_dir: PathBuf::from("/tmp/x"), + static_dir: PathBuf::from("/tmp/static"), + bootstrap_admin_password: None, + secure_cookies: false, + rust_log: "info".into(), + }; + assert_eq!(s.db_url(), "sqlite:///tmp/x/data.db?mode=rwc"); + } +} diff --git a/server/src/db.rs b/server/src/db.rs new file mode 100644 index 0000000..0382425 --- /dev/null +++ b/server/src/db.rs @@ -0,0 +1,71 @@ +//! SQLite connection pool with safe defaults (WAL, foreign keys, busy timeout). + +use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}; +use sqlx::{ConnectOptions, SqlitePool}; +use std::str::FromStr; +use std::time::Duration; + +/// Build a pool against the given URL, applying recommended pragmas. +pub async fn connect(url: &str) -> sqlx::Result<SqlitePool> { + let opts = SqliteConnectOptions::from_str(url)? + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Wal) + .synchronous(SqliteSynchronous::Normal) + .foreign_keys(true) + .busy_timeout(Duration::from_secs(5)) + .log_statements(tracing::log::LevelFilter::Debug); + + SqlitePoolOptions::new() + .max_connections(5) + .acquire_timeout(Duration::from_secs(5)) + .connect_with(opts) + .await +} + +/// Run all bundled migrations against the pool. +pub async fn migrate(pool: &SqlitePool) -> sqlx::Result<()> { + sqlx::migrate!("./migrations").run(pool).await?; + Ok(()) +} + +/// Build an in-memory pool with migrations applied. Used by unit and +/// integration tests; not intended for production callers. +#[doc(hidden)] +pub async fn connect_in_memory() -> sqlx::Result<SqlitePool> { + let pool = connect("sqlite::memory:").await?; + migrate(&pool).await?; + Ok(pool) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn pool_runs_pragmas() { + let pool = connect("sqlite::memory:").await.unwrap(); + let row: (String,) = sqlx::query_as("PRAGMA journal_mode") + .fetch_one(&pool) + .await + .unwrap(); + // SQLite returns "memory" for in-memory DBs even after WAL request, so just sanity-check connectivity. + assert!(!row.0.is_empty()); + let row: (i64,) = sqlx::query_as("PRAGMA foreign_keys") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(row.0, 1); + } + + #[tokio::test] + async fn migrate_creates_tables() { + let pool = connect_in_memory().await.unwrap(); + let row: (i64,) = sqlx::query_as( + "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='items'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(row.0, 1); + } +} diff --git a/server/src/dto.rs b/server/src/dto.rs new file mode 100644 index 0000000..93bc25e --- /dev/null +++ b/server/src/dto.rs @@ -0,0 +1,214 @@ +use serde::{Deserialize, Serialize}; + +fn zero() -> i64 { + 0 +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Site { + pub id: i64, + pub value: String, + pub name: String, + pub name_i18n: Option<serde_json::Value>, + pub sort_order: i64, + pub is_default: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Group { + pub id: i64, + pub slug: String, + pub name: String, + pub name_i18n: Option<serde_json::Value>, + pub sort_order: i64, + pub collapsed_default: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Tag { + pub id: i64, + pub slug: String, + pub name: String, + pub name_i18n: Option<serde_json::Value>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum IconKind { + Asset, + Url, + AutoFavicon, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Item { + pub id: i64, + pub group_id: Option<i64>, + pub name: String, + pub name_i18n: Option<serde_json::Value>, + pub description: Option<String>, + pub description_i18n: Option<serde_json::Value>, + pub icon_kind: IconKind, + pub icon_value: String, + pub sort_order: i64, + pub links: std::collections::BTreeMap<String, String>, // site.value -> URL + pub tag_slugs: Vec<String>, + #[serde(default = "zero")] + pub created_at: i64, + #[serde(default = "zero")] + pub updated_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Meta { + pub site_name: String, + pub site_avatar_path: Option<String>, + pub site_copyright: String, + pub site_icp: Option<Link>, + pub site_police: Option<Link>, + pub default_theme: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Link { + pub text: String, + pub url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct NavBundle { + pub schema_version: i64, + pub meta: Meta, + pub sites: Vec<Site>, + pub groups: Vec<Group>, + pub items: Vec<Item>, + pub tags: Vec<Tag>, +} + +// ----- Write payloads ----- + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ItemPayload { + pub group_id: Option<i64>, + pub name: String, + #[serde(default)] + pub name_i18n: Option<serde_json::Value>, + #[serde(default)] + pub description: Option<String>, + #[serde(default)] + pub description_i18n: Option<serde_json::Value>, + pub icon_kind: IconKind, + pub icon_value: String, + #[serde(default)] + pub links: std::collections::BTreeMap<String, String>, + #[serde(default)] + pub tag_slugs: Vec<String>, +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ItemPatch { + #[serde(default)] + pub group_id: Option<Option<i64>>, + #[serde(default)] + pub name: Option<String>, + #[serde(default)] + pub name_i18n: Option<Option<serde_json::Value>>, + #[serde(default)] + pub description: Option<Option<String>>, + #[serde(default)] + pub description_i18n: Option<Option<serde_json::Value>>, + #[serde(default)] + pub icon_kind: Option<IconKind>, + #[serde(default)] + pub icon_value: Option<String>, + #[serde(default)] + pub links: Option<std::collections::BTreeMap<String, String>>, + #[serde(default)] + pub tag_slugs: Option<Vec<String>>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReorderEntry { + pub id: i64, + pub sort_order: i64, + #[serde(default)] + pub group_id: Option<Option<i64>>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GroupPayload { + pub slug: String, + pub name: String, + #[serde(default)] + pub name_i18n: Option<serde_json::Value>, + #[serde(default)] + pub collapsed_default: bool, +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct GroupPatch { + #[serde(default)] + pub slug: Option<String>, + #[serde(default)] + pub name: Option<String>, + #[serde(default)] + pub name_i18n: Option<Option<serde_json::Value>>, + #[serde(default)] + pub collapsed_default: Option<bool>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SitePayload { + pub value: String, + pub name: String, + #[serde(default)] + pub name_i18n: Option<serde_json::Value>, + #[serde(default)] + pub is_default: bool, +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct SitePatch { + #[serde(default)] + pub value: Option<String>, + #[serde(default)] + pub name: Option<String>, + #[serde(default)] + pub name_i18n: Option<Option<serde_json::Value>>, + #[serde(default)] + pub is_default: Option<bool>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TagPayload { + pub slug: String, + pub name: String, + #[serde(default)] + pub name_i18n: Option<serde_json::Value>, +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct TagPatch { + #[serde(default)] + pub slug: Option<String>, + #[serde(default)] + pub name: Option<String>, + #[serde(default)] + pub name_i18n: Option<Option<serde_json::Value>>, +} diff --git a/server/src/error.rs b/server/src/error.rs new file mode 100644 index 0000000..734919d --- /dev/null +++ b/server/src/error.rs @@ -0,0 +1,122 @@ +//! Unified application error type. + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::json; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AppError { + #[error("not found")] + NotFound, + + #[error("unauthenticated")] + Unauthenticated, + + #[error("forbidden")] + Forbidden, + + #[error("validation failed: {0}")] + Validation(String), + + #[error("conflict: {0}")] + Conflict(String), + + #[error("rate limited")] + RateLimited, + + #[error(transparent)] + Sqlx(#[from] sqlx::Error), + + #[error(transparent)] + Bcrypt(#[from] bcrypt::BcryptError), + + #[error(transparent)] + Io(#[from] std::io::Error), + + #[error(transparent)] + Json(#[from] serde_json::Error), + + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +impl AppError { + pub fn status(&self) -> StatusCode { + match self { + AppError::NotFound => StatusCode::NOT_FOUND, + AppError::Unauthenticated => StatusCode::UNAUTHORIZED, + AppError::Forbidden => StatusCode::FORBIDDEN, + AppError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY, + AppError::Conflict(_) => StatusCode::CONFLICT, + AppError::RateLimited => StatusCode::TOO_MANY_REQUESTS, + AppError::Sqlx(sqlx::Error::RowNotFound) => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn code(&self) -> &'static str { + match self { + AppError::NotFound => "not_found", + AppError::Unauthenticated => "unauthenticated", + AppError::Forbidden => "forbidden", + AppError::Validation(_) => "validation_failed", + AppError::Conflict(_) => "conflict", + AppError::RateLimited => "rate_limited", + _ => "internal", + } + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let status = self.status(); + let code = self.code(); + // Internal errors must not leak details to clients. + let message = match &self { + AppError::Validation(m) | AppError::Conflict(m) => Some(m.clone()), + AppError::NotFound + | AppError::Unauthenticated + | AppError::Forbidden + | AppError::RateLimited => None, + other => { + tracing::error!(error = %other, "internal error"); + None + } + }; + let body = match message { + Some(m) => json!({ "error": code, "message": m }), + None => json!({ "error": code }), + }; + (status, Json(body)).into_response() + } +} + +pub type Result<T> = std::result::Result<T, AppError>; + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::StatusCode; + + #[test] + fn validation_error_is_422_with_message() { + let err = AppError::Validation("name too long".into()); + assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY); + let resp = err.into_response(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); + } + + #[test] + fn not_found_is_404_no_message() { + let err = AppError::NotFound; + assert_eq!(err.status(), StatusCode::NOT_FOUND); + } + + #[test] + fn sqlx_row_not_found_maps_to_404() { + let err: AppError = sqlx::Error::RowNotFound.into(); + assert_eq!(err.status(), StatusCode::NOT_FOUND); + } +} diff --git a/server/src/lib.rs b/server/src/lib.rs new file mode 100644 index 0000000..592e277 --- /dev/null +++ b/server/src/lib.rs @@ -0,0 +1,11 @@ +pub mod app; +pub mod auth; +pub mod cli; +pub mod config; +pub mod db; +pub mod dto; +pub mod error; +pub mod repo; +pub mod routes; +pub mod services; +pub mod state; diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..b91dd0c --- /dev/null +++ b/server/src/main.rs @@ -0,0 +1,65 @@ +use anyhow::Context; +use clap::Parser; +use navsrv::{ + app::build_app, + auth::session::{layer as session_layer, run_pruner}, + cli::{run_command, Cli}, + config::Settings, + db::{connect, migrate}, + repo::{SqlxConfigRepo, SqlxNavRepo}, + services::bootstrap::ensure_admin_password, + state::AppState, +}; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::net::TcpListener; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + let settings = Settings::load().context("load settings")?; + init_tracing(&settings.rust_log); + + if let Some(cmd) = cli.command { + return run_command(cmd).await; + } + + let pool = connect(&settings.db_url()).await.context("db connect")?; + migrate(&pool).await.context("migrate")?; + + let nav = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg = Arc::new(SqlxConfigRepo::new(pool.clone())); + + ensure_admin_password( + cfg.clone(), + &settings.data_dir, + settings.bootstrap_admin_password.clone(), + ) + .await?; + + navsrv::services::migration::seed_if_empty(nav.clone() as _, cfg.clone() as _).await?; + + let state = AppState::new(nav, cfg, settings.data_dir.clone()); + let app = build_app( + state, + session_layer(pool.clone(), settings.secure_cookies), + settings.static_dir.clone(), + ); + tokio::spawn(run_pruner(pool)); + + let addr = SocketAddr::from(([0, 0, 0, 0], settings.port)); + let listener = TcpListener::bind(addr).await.context("bind")?; + tracing::info!(%addr, "navsrv listening"); + axum::serve(listener, app).await.context("serve")?; + Ok(()) +} + +fn init_tracing(filter: &str) { + use tracing_subscriber::{fmt, EnvFilter}; + let env = EnvFilter::try_new(filter).unwrap_or_else(|_| EnvFilter::new("info")); + fmt() + .with_env_filter(env) + .with_target(false) + .compact() + .init(); +} diff --git a/server/src/repo/config.rs b/server/src/repo/config.rs new file mode 100644 index 0000000..62c795e --- /dev/null +++ b/server/src/repo/config.rs @@ -0,0 +1,78 @@ +use crate::error::Result; +use async_trait::async_trait; +use sqlx::SqlitePool; +use std::collections::BTreeMap; + +#[async_trait] +pub trait ConfigRepo: Send + Sync { + async fn get(&self, key: &str) -> Result<Option<String>>; + async fn get_many(&self, keys: &[&str]) -> Result<BTreeMap<String, String>>; + async fn upsert(&self, key: &str, value: &str) -> Result<()>; + async fn upsert_many(&self, pairs: &[(&str, &str)]) -> Result<()>; + async fn delete(&self, key: &str) -> Result<()>; +} + +pub struct SqlxConfigRepo { + pool: SqlitePool, +} + +impl SqlxConfigRepo { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl ConfigRepo for SqlxConfigRepo { + async fn get(&self, key: &str) -> Result<Option<String>> { + let row = sqlx::query_scalar!("SELECT value FROM config WHERE key = ?", key) + .fetch_optional(&self.pool) + .await?; + Ok(row) + } + + async fn get_many(&self, keys: &[&str]) -> Result<BTreeMap<String, String>> { + let mut out = BTreeMap::new(); + for k in keys { + if let Some(v) = self.get(k).await? { + out.insert((*k).to_string(), v); + } + } + Ok(out) + } + + async fn upsert(&self, key: &str, value: &str) -> Result<()> { + sqlx::query!( + "INSERT INTO config (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value", + key, + value + ) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn upsert_many(&self, pairs: &[(&str, &str)]) -> Result<()> { + let mut tx = self.pool.begin().await?; + for (k, v) in pairs { + sqlx::query!( + "INSERT INTO config (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value", + k, + v + ) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(()) + } + + async fn delete(&self, key: &str) -> Result<()> { + sqlx::query!("DELETE FROM config WHERE key = ?", key) + .execute(&self.pool) + .await?; + Ok(()) + } +} diff --git a/server/src/repo/mod.rs b/server/src/repo/mod.rs new file mode 100644 index 0000000..7a7c115 --- /dev/null +++ b/server/src/repo/mod.rs @@ -0,0 +1,6 @@ +pub mod config; +pub mod nav; +pub mod sqlx_impl; +pub use config::{ConfigRepo, SqlxConfigRepo}; +pub use nav::NavRepo; +pub use sqlx_impl::SqlxNavRepo; diff --git a/server/src/repo/nav.rs b/server/src/repo/nav.rs new file mode 100644 index 0000000..5e199ba --- /dev/null +++ b/server/src/repo/nav.rs @@ -0,0 +1,35 @@ +use crate::dto::*; +use crate::error::Result; +use async_trait::async_trait; + +#[async_trait] +pub trait NavRepo: Send + Sync { + // ----- Bundle ----- + async fn get_bundle(&self) -> Result<(Vec<Site>, Vec<Group>, Vec<Item>, Vec<Tag>)>; + + // ----- Sites ----- + async fn list_sites(&self) -> Result<Vec<Site>>; + async fn create_site(&self, p: SitePayload) -> Result<Site>; + async fn patch_site(&self, id: i64, p: SitePatch) -> Result<Site>; + async fn delete_site(&self, id: i64) -> Result<()>; + async fn reorder_sites(&self, entries: Vec<ReorderEntry>) -> Result<()>; + + // ----- Groups ----- + async fn list_groups(&self) -> Result<Vec<Group>>; + async fn create_group(&self, p: GroupPayload) -> Result<Group>; + async fn patch_group(&self, id: i64, p: GroupPatch) -> Result<Group>; + async fn delete_group(&self, id: i64) -> Result<()>; + async fn reorder_groups(&self, entries: Vec<ReorderEntry>) -> Result<()>; + + // ----- Items ----- + async fn create_item(&self, p: ItemPayload) -> Result<Item>; + async fn patch_item(&self, id: i64, p: ItemPatch) -> Result<Item>; + async fn delete_item(&self, id: i64) -> Result<()>; + async fn reorder_items(&self, entries: Vec<ReorderEntry>) -> Result<()>; + + // ----- Tags ----- + async fn list_tags(&self) -> Result<Vec<Tag>>; + async fn create_tag(&self, p: TagPayload) -> Result<Tag>; + async fn patch_tag(&self, id: i64, p: TagPatch) -> Result<Tag>; + async fn delete_tag(&self, id: i64) -> Result<()>; +} diff --git a/server/src/repo/sqlx_impl.rs b/server/src/repo/sqlx_impl.rs new file mode 100644 index 0000000..c8fedc0 --- /dev/null +++ b/server/src/repo/sqlx_impl.rs @@ -0,0 +1,608 @@ +//! `NavRepo` implementation backed by a SQLite pool. +//! +//! Sites/Groups/Tags are fully implemented here. Item methods are stubbed +//! and filled in by Task 14. + +use crate::dto::*; +use crate::error::{AppError, Result}; +use crate::repo::nav::NavRepo; +use async_trait::async_trait; +use sqlx::SqlitePool; + +pub struct SqlxNavRepo { + pool: SqlitePool, +} + +impl SqlxNavRepo { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +fn now_ms() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +fn map_unique_violation(err: sqlx::Error) -> AppError { + if let Some(db_err) = err.as_database_error() { + let msg = db_err.message(); + if msg.contains("UNIQUE constraint failed") { + return AppError::Conflict(msg.to_string()); + } + } + AppError::Sqlx(err) +} + +#[async_trait] +impl NavRepo for SqlxNavRepo { + async fn get_bundle(&self) -> Result<(Vec<Site>, Vec<Group>, Vec<Item>, Vec<Tag>)> { + let sites = self.list_sites().await?; + let groups = self.list_groups().await?; + let tags = self.list_tags().await?; + let items = self.list_items_full().await?; + Ok((sites, groups, items, tags)) + } + + // ---- Sites ---- + + async fn list_sites(&self) -> Result<Vec<Site>> { + let rows = sqlx::query!( + r#"SELECT id as "id!: i64", value, name, + name_i18n as "name_i18n: serde_json::Value", + sort_order as "sort_order!: i64", + is_default + FROM sites ORDER BY sort_order, id"# + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .into_iter() + .map(|r| Site { + id: r.id, + value: r.value, + name: r.name, + name_i18n: r.name_i18n, + sort_order: r.sort_order, + is_default: r.is_default != 0, + }) + .collect()) + } + + async fn create_site(&self, p: SitePayload) -> Result<Site> { + let res = sqlx::query!( + "INSERT INTO sites (value, name, name_i18n, is_default) VALUES (?, ?, ?, ?)", + p.value, + p.name, + p.name_i18n, + p.is_default + ) + .execute(&self.pool) + .await + .map_err(map_unique_violation)?; + let id = res.last_insert_rowid(); + self.list_sites() + .await? + .into_iter() + .find(|s| s.id == id) + .ok_or(AppError::NotFound) + } + + async fn patch_site(&self, id: i64, p: SitePatch) -> Result<Site> { + let mut tx = self.pool.begin().await?; + if let Some(v) = p.value { + sqlx::query!("UPDATE sites SET value=? WHERE id=?", v, id) + .execute(&mut *tx) + .await + .map_err(map_unique_violation)?; + } + if let Some(v) = p.name { + sqlx::query!("UPDATE sites SET name=? WHERE id=?", v, id) + .execute(&mut *tx) + .await?; + } + if let Some(v) = p.name_i18n { + sqlx::query!("UPDATE sites SET name_i18n=? WHERE id=?", v, id) + .execute(&mut *tx) + .await?; + } + if let Some(v) = p.is_default { + sqlx::query!("UPDATE sites SET is_default=? WHERE id=?", v, id) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + self.list_sites() + .await? + .into_iter() + .find(|s| s.id == id) + .ok_or(AppError::NotFound) + } + + async fn delete_site(&self, id: i64) -> Result<()> { + let res = sqlx::query!("DELETE FROM sites WHERE id=?", id) + .execute(&self.pool) + .await?; + if res.rows_affected() == 0 { + return Err(AppError::NotFound); + } + Ok(()) + } + + async fn reorder_sites(&self, entries: Vec<ReorderEntry>) -> Result<()> { + let mut tx = self.pool.begin().await?; + for e in entries { + sqlx::query!( + "UPDATE sites SET sort_order=? WHERE id=?", + e.sort_order, + e.id + ) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(()) + } + + // ---- Groups ---- + + async fn list_groups(&self) -> Result<Vec<Group>> { + let rows = sqlx::query!( + r#"SELECT id as "id!: i64", slug, name, + name_i18n as "name_i18n: serde_json::Value", + sort_order as "sort_order!: i64", + collapsed_default + FROM groups ORDER BY sort_order, id"# + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .into_iter() + .map(|r| Group { + id: r.id, + slug: r.slug, + name: r.name, + name_i18n: r.name_i18n, + sort_order: r.sort_order, + collapsed_default: r.collapsed_default != 0, + }) + .collect()) + } + + async fn create_group(&self, p: GroupPayload) -> Result<Group> { + let res = sqlx::query!( + "INSERT INTO groups (slug, name, name_i18n, collapsed_default) VALUES (?, ?, ?, ?)", + p.slug, + p.name, + p.name_i18n, + p.collapsed_default + ) + .execute(&self.pool) + .await + .map_err(map_unique_violation)?; + let id = res.last_insert_rowid(); + self.list_groups() + .await? + .into_iter() + .find(|g| g.id == id) + .ok_or(AppError::NotFound) + } + + async fn patch_group(&self, id: i64, p: GroupPatch) -> Result<Group> { + let mut tx = self.pool.begin().await?; + if let Some(v) = p.slug { + sqlx::query!("UPDATE groups SET slug=? WHERE id=?", v, id) + .execute(&mut *tx) + .await + .map_err(map_unique_violation)?; + } + if let Some(v) = p.name { + sqlx::query!("UPDATE groups SET name=? WHERE id=?", v, id) + .execute(&mut *tx) + .await?; + } + if let Some(v) = p.name_i18n { + sqlx::query!("UPDATE groups SET name_i18n=? WHERE id=?", v, id) + .execute(&mut *tx) + .await?; + } + if let Some(v) = p.collapsed_default { + sqlx::query!("UPDATE groups SET collapsed_default=? WHERE id=?", v, id) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + self.list_groups() + .await? + .into_iter() + .find(|g| g.id == id) + .ok_or(AppError::NotFound) + } + + async fn delete_group(&self, id: i64) -> Result<()> { + let res = sqlx::query!("DELETE FROM groups WHERE id=?", id) + .execute(&self.pool) + .await?; + if res.rows_affected() == 0 { + return Err(AppError::NotFound); + } + Ok(()) + } + + async fn reorder_groups(&self, entries: Vec<ReorderEntry>) -> Result<()> { + let mut tx = self.pool.begin().await?; + for e in entries { + sqlx::query!( + "UPDATE groups SET sort_order=? WHERE id=?", + e.sort_order, + e.id + ) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(()) + } + + // ---- Tags ---- + + async fn list_tags(&self) -> Result<Vec<Tag>> { + let rows = sqlx::query!( + r#"SELECT id as "id!: i64", slug, name, + name_i18n as "name_i18n: serde_json::Value" + FROM tags ORDER BY id"# + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .into_iter() + .map(|r| Tag { + id: r.id, + slug: r.slug, + name: r.name, + name_i18n: r.name_i18n, + }) + .collect()) + } + + async fn create_tag(&self, p: TagPayload) -> Result<Tag> { + let res = sqlx::query!( + "INSERT INTO tags (slug, name, name_i18n) VALUES (?, ?, ?)", + p.slug, + p.name, + p.name_i18n + ) + .execute(&self.pool) + .await + .map_err(map_unique_violation)?; + let id = res.last_insert_rowid(); + self.list_tags() + .await? + .into_iter() + .find(|t| t.id == id) + .ok_or(AppError::NotFound) + } + + async fn patch_tag(&self, id: i64, p: TagPatch) -> Result<Tag> { + let mut tx = self.pool.begin().await?; + if let Some(v) = p.slug { + sqlx::query!("UPDATE tags SET slug=? WHERE id=?", v, id) + .execute(&mut *tx) + .await + .map_err(map_unique_violation)?; + } + if let Some(v) = p.name { + sqlx::query!("UPDATE tags SET name=? WHERE id=?", v, id) + .execute(&mut *tx) + .await?; + } + if let Some(v) = p.name_i18n { + sqlx::query!("UPDATE tags SET name_i18n=? WHERE id=?", v, id) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + self.list_tags() + .await? + .into_iter() + .find(|t| t.id == id) + .ok_or(AppError::NotFound) + } + + async fn delete_tag(&self, id: i64) -> Result<()> { + let res = sqlx::query!("DELETE FROM tags WHERE id=?", id) + .execute(&self.pool) + .await?; + if res.rows_affected() == 0 { + return Err(AppError::NotFound); + } + Ok(()) + } + + // ---- Items ---- + + async fn create_item(&self, p: ItemPayload) -> Result<Item> { + let now = now_ms(); + let kind_str = match p.icon_kind { + IconKind::Asset => "asset", + IconKind::Url => "url", + IconKind::AutoFavicon => "auto-favicon", + }; + + let mut tx = self.pool.begin().await?; + let res = sqlx::query!( + r#"INSERT INTO items + (group_id, name, name_i18n, description, description_i18n, + icon_kind, icon_value, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT MAX(sort_order)+1 FROM items WHERE group_id IS ?), 0), ?, ?)"#, + p.group_id, p.name, p.name_i18n, p.description, p.description_i18n, + kind_str, p.icon_value, p.group_id, now, now + ).execute(&mut *tx).await?; + let id = res.last_insert_rowid(); + + for (site_value, url) in &p.links { + sqlx::query!( + r#"INSERT INTO item_links (item_id, site_id, url) + SELECT ?, sites.id, ? FROM sites WHERE sites.value = ?"#, + id, + url, + site_value + ) + .execute(&mut *tx) + .await?; + } + + for slug in &p.tag_slugs { + sqlx::query!( + r#"INSERT INTO item_tags (item_id, tag_id) + SELECT ?, tags.id FROM tags WHERE tags.slug = ?"#, + id, + slug + ) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + self.fetch_item(id).await + } + + async fn patch_item(&self, id: i64, p: ItemPatch) -> Result<Item> { + let now = now_ms(); + let mut tx = self.pool.begin().await?; + if let Some(v) = p.group_id { + sqlx::query!( + "UPDATE items SET group_id=?, updated_at=? WHERE id=?", + v, + now, + id + ) + .execute(&mut *tx) + .await?; + } + if let Some(v) = p.name { + sqlx::query!( + "UPDATE items SET name=?, updated_at=? WHERE id=?", + v, + now, + id + ) + .execute(&mut *tx) + .await?; + } + if let Some(v) = p.name_i18n { + sqlx::query!( + "UPDATE items SET name_i18n=?, updated_at=? WHERE id=?", + v, + now, + id + ) + .execute(&mut *tx) + .await?; + } + if let Some(v) = p.description { + sqlx::query!( + "UPDATE items SET description=?, updated_at=? WHERE id=?", + v, + now, + id + ) + .execute(&mut *tx) + .await?; + } + if let Some(v) = p.description_i18n { + sqlx::query!( + "UPDATE items SET description_i18n=?, updated_at=? WHERE id=?", + v, + now, + id + ) + .execute(&mut *tx) + .await?; + } + if let Some(v) = p.icon_kind { + let s = match v { + IconKind::Asset => "asset", + IconKind::Url => "url", + IconKind::AutoFavicon => "auto-favicon", + }; + sqlx::query!( + "UPDATE items SET icon_kind=?, updated_at=? WHERE id=?", + s, + now, + id + ) + .execute(&mut *tx) + .await?; + } + if let Some(v) = p.icon_value { + sqlx::query!( + "UPDATE items SET icon_value=?, updated_at=? WHERE id=?", + v, + now, + id + ) + .execute(&mut *tx) + .await?; + } + + if let Some(links) = p.links { + sqlx::query!("DELETE FROM item_links WHERE item_id=?", id) + .execute(&mut *tx) + .await?; + for (site_value, url) in links { + sqlx::query!( + r#"INSERT INTO item_links (item_id, site_id, url) + SELECT ?, sites.id, ? FROM sites WHERE sites.value = ?"#, + id, + url, + site_value + ) + .execute(&mut *tx) + .await?; + } + } + if let Some(tag_slugs) = p.tag_slugs { + sqlx::query!("DELETE FROM item_tags WHERE item_id=?", id) + .execute(&mut *tx) + .await?; + for slug in tag_slugs { + sqlx::query!( + r#"INSERT INTO item_tags (item_id, tag_id) + SELECT ?, tags.id FROM tags WHERE tags.slug = ?"#, + id, + slug + ) + .execute(&mut *tx) + .await?; + } + } + tx.commit().await?; + self.fetch_item(id).await + } + + async fn delete_item(&self, id: i64) -> Result<()> { + let res = sqlx::query!("DELETE FROM items WHERE id=?", id) + .execute(&self.pool) + .await?; + if res.rows_affected() == 0 { + return Err(AppError::NotFound); + } + Ok(()) + } + + async fn reorder_items(&self, entries: Vec<ReorderEntry>) -> Result<()> { + let now = now_ms(); + let mut tx = self.pool.begin().await?; + for e in entries { + match e.group_id { + Some(gid) => { + sqlx::query!( + "UPDATE items SET sort_order=?, group_id=?, updated_at=? WHERE id=?", + e.sort_order, + gid, + now, + e.id + ) + .execute(&mut *tx) + .await?; + } + None => { + sqlx::query!( + "UPDATE items SET sort_order=?, updated_at=? WHERE id=?", + e.sort_order, + now, + e.id + ) + .execute(&mut *tx) + .await?; + } + } + } + tx.commit().await?; + Ok(()) + } +} + +impl SqlxNavRepo { + pub(crate) async fn fetch_item(&self, id: i64) -> Result<Item> { + self.list_items_full() + .await? + .into_iter() + .find(|i| i.id == id) + .ok_or(AppError::NotFound) + } + + pub(crate) async fn list_items_full(&self) -> Result<Vec<Item>> { + let item_rows = sqlx::query!( + r#"SELECT id as "id!: i64", group_id, + name, name_i18n as "name_i18n: serde_json::Value", + description, description_i18n as "description_i18n: serde_json::Value", + icon_kind, icon_value, + sort_order as "sort_order!: i64", + created_at as "created_at!: i64", + updated_at as "updated_at!: i64" + FROM items + ORDER BY group_id, sort_order, id"# + ) + .fetch_all(&self.pool) + .await?; + + let link_rows = sqlx::query!( + r#"SELECT il.item_id as "item_id!: i64", s.value as "site_value!", il.url as "url!" + FROM item_links il JOIN sites s ON s.id = il.site_id"# + ) + .fetch_all(&self.pool) + .await?; + + let tag_rows = sqlx::query!( + r#"SELECT it.item_id as "item_id!: i64", t.slug as "slug!" + FROM item_tags it JOIN tags t ON t.id = it.tag_id"# + ) + .fetch_all(&self.pool) + .await?; + + let mut items: Vec<Item> = item_rows + .into_iter() + .map(|r| { + let kind = match r.icon_kind.as_str() { + "url" => IconKind::Url, + "auto-favicon" => IconKind::AutoFavicon, + _ => IconKind::Asset, + }; + Item { + id: r.id, + group_id: r.group_id, + name: r.name, + name_i18n: r.name_i18n, + description: r.description, + description_i18n: r.description_i18n, + icon_kind: kind, + icon_value: r.icon_value, + sort_order: r.sort_order, + links: Default::default(), + tag_slugs: Vec::new(), + created_at: r.created_at, + updated_at: r.updated_at, + } + }) + .collect(); + + let by_id: std::collections::HashMap<i64, usize> = + items.iter().enumerate().map(|(i, it)| (it.id, i)).collect(); + + for r in link_rows { + if let Some(&idx) = by_id.get(&r.item_id) { + items[idx].links.insert(r.site_value, r.url); + } + } + for r in tag_rows { + if let Some(&idx) = by_id.get(&r.item_id) { + items[idx].tag_slugs.push(r.slug); + } + } + Ok(items) + } +} diff --git a/server/src/routes/auth.rs b/server/src/routes/auth.rs new file mode 100644 index 0000000..24fe651 --- /dev/null +++ b/server/src/routes/auth.rs @@ -0,0 +1,82 @@ +use axum::{ + extract::State, + http::StatusCode, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; +use serde_json::json; +use tower_governor::governor::GovernorConfigBuilder; +use tower_governor::key_extractor::GlobalKeyExtractor; +use tower_governor::GovernorLayer; +use tower_sessions::Session; + +use crate::auth::password; +use crate::auth::session::SESSION_KEY_AUTHED; +use crate::error::{AppError, Result}; +use crate::state::AppState; + +pub fn router() -> Router<AppState> { + // 5 attempts per 15 minutes ≈ 1 token replenished every 180s with a burst of 5. + // GlobalKeyExtractor: single shared bucket for the login route. This avoids + // per-IP keying which would require `into_make_service_with_connect_info` + // wiring at the serve site — out of scope for this task. + let conf = Box::new( + GovernorConfigBuilder::default() + .per_second(180) + .burst_size(5) + .key_extractor(GlobalKeyExtractor) + .finish() + .expect("governor config"), + ); + let governor = GovernorLayer { + config: Box::leak(conf), + }; + + Router::new() + .route("/auth/login", post(login).layer(governor)) + .route("/auth/logout", post(logout)) + .route("/auth/me", get(me)) +} + +#[derive(Deserialize)] +struct LoginBody { + password: String, +} + +async fn login( + State(s): State<AppState>, + session: Session, + Json(body): Json<LoginBody>, +) -> Result<StatusCode> { + let hashed = s + .config + .get("admin_password_hash") + .await? + .ok_or(AppError::Unauthenticated)?; + if !password::verify(&body.password, &hashed)? { + return Err(AppError::Unauthenticated); + } + session + .insert(SESSION_KEY_AUTHED, true) + .await + .map_err(|e| AppError::Other(anyhow::anyhow!(e)))?; + Ok(StatusCode::NO_CONTENT) +} + +async fn logout(session: Session) -> Result<StatusCode> { + session + .flush() + .await + .map_err(|e| AppError::Other(anyhow::anyhow!(e)))?; + Ok(StatusCode::NO_CONTENT) +} + +async fn me(session: Session) -> Result<Json<serde_json::Value>> { + let authed = session + .get::<bool>(SESSION_KEY_AUTHED) + .await + .map_err(|e| AppError::Other(anyhow::anyhow!(e)))? + .unwrap_or(false); + Ok(Json(json!({ "authenticated": authed }))) +} diff --git a/server/src/routes/config.rs b/server/src/routes/config.rs new file mode 100644 index 0000000..4a67293 --- /dev/null +++ b/server/src/routes/config.rs @@ -0,0 +1,78 @@ +use axum::{ + extract::State, + http::StatusCode, + routing::{patch, post}, + Json, Router, +}; +use serde::Deserialize; + +use crate::auth::{password, RequireAuth}; +use crate::error::{AppError, Result}; +use crate::state::AppState; + +pub fn router() -> Router<AppState> { + Router::new() + .route("/config", patch(patch_config)) + .route("/config/password", post(change_password)) +} + +#[derive(Deserialize)] +struct ConfigEntry { + key: String, + value: String, +} + +async fn patch_config( + _auth: RequireAuth, + State(s): State<AppState>, + Json(items): Json<Vec<ConfigEntry>>, +) -> Result<StatusCode> { + let pairs: Vec<(&str, &str)> = items + .iter() + .map(|i| (i.key.as_str(), i.value.as_str())) + .collect(); + s.config.upsert_many(&pairs).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Deserialize)] +struct ChangePassword { + current: String, + next: String, +} + +async fn change_password( + _auth: RequireAuth, + State(s): State<AppState>, + Json(body): Json<ChangePassword>, +) -> Result<StatusCode> { + if body.next.len() < 8 { + return Err(AppError::Validation( + "password must be at least 8 chars".into(), + )); + } + let current_hash = s + .config + .get("admin_password_hash") + .await? + .ok_or(AppError::Unauthenticated)?; + if !password::verify(&body.current, ¤t_hash)? { + return Err(AppError::Unauthenticated); + } + let new_hash = password::hash(&body.next)?; + let now = chrono::Utc::now().timestamp_millis().to_string(); + s.config + .upsert_many(&[ + ("admin_password_hash", &new_hash), + ("admin_password_updated_at", &now), + ]) + .await?; + + let initial = s.data_dir.join("INITIAL_PASSWORD.txt"); + if initial.exists() { + if let Err(e) = std::fs::remove_file(&initial) { + tracing::warn!(error=%e, ?initial, "failed to remove INITIAL_PASSWORD.txt"); + } + } + Ok(StatusCode::NO_CONTENT) +} diff --git a/server/src/routes/favicon.rs b/server/src/routes/favicon.rs new file mode 100644 index 0000000..c4937fb --- /dev/null +++ b/server/src/routes/favicon.rs @@ -0,0 +1,34 @@ +use axum::{ + extract::{Query, State}, + http::{header, HeaderMap, HeaderValue}, + response::IntoResponse, + routing::get, + Router, +}; +use serde::Deserialize; + +use crate::error::Result; +use crate::services::favicon::FaviconService; +use crate::state::AppState; +use std::sync::Arc; + +pub fn router() -> Router<AppState> { + Router::new().route("/favicon", get(favicon)) +} + +#[derive(Deserialize)] +struct Q { + host: String, +} + +async fn favicon(State(s): State<AppState>, Query(q): Query<Q>) -> Result<impl IntoResponse> { + let svc: &Arc<FaviconService> = &s.favicon; + let (mime, bytes) = svc.fetch(&q.host).await?; + let mut h = HeaderMap::new(); + h.insert(header::CONTENT_TYPE, HeaderValue::from_str(&mime).unwrap()); + h.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=86400"), + ); + Ok((h, bytes)) +} diff --git a/server/src/routes/groups.rs b/server/src/routes/groups.rs new file mode 100644 index 0000000..cfee6d9 --- /dev/null +++ b/server/src/routes/groups.rs @@ -0,0 +1,50 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + routing::{patch, post}, + Json, Router, +}; + +use crate::auth::RequireAuth; +use crate::dto::{Group, GroupPatch, GroupPayload, ReorderEntry}; +use crate::error::Result; +use crate::state::AppState; + +pub fn router() -> Router<AppState> { + Router::new() + .route("/groups", post(create)) + .route("/groups/reorder", post(reorder)) + .route("/groups/:id", patch(update).delete(remove)) +} + +async fn create( + _a: RequireAuth, + State(s): State<AppState>, + Json(body): Json<GroupPayload>, +) -> Result<(StatusCode, Json<Group>)> { + Ok((StatusCode::CREATED, Json(s.nav.create_group(body).await?))) +} +async fn update( + _a: RequireAuth, + State(s): State<AppState>, + Path(id): Path<i64>, + Json(body): Json<GroupPatch>, +) -> Result<Json<Group>> { + Ok(Json(s.nav.patch_group(id, body).await?)) +} +async fn remove( + _a: RequireAuth, + State(s): State<AppState>, + Path(id): Path<i64>, +) -> Result<StatusCode> { + s.nav.delete_group(id).await?; + Ok(StatusCode::NO_CONTENT) +} +async fn reorder( + _a: RequireAuth, + State(s): State<AppState>, + Json(entries): Json<Vec<ReorderEntry>>, +) -> Result<StatusCode> { + s.nav.reorder_groups(entries).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/server/src/routes/health.rs b/server/src/routes/health.rs new file mode 100644 index 0000000..f26b16d --- /dev/null +++ b/server/src/routes/health.rs @@ -0,0 +1,10 @@ +use axum::{routing::get, Json, Router}; +use serde_json::{json, Value}; + +pub fn router<S: Clone + Send + Sync + 'static>() -> Router<S> { + Router::new().route("/health", get(health)) +} + +async fn health() -> Json<Value> { + Json(json!({ "status": "ok" })) +} diff --git a/server/src/routes/icons.rs b/server/src/routes/icons.rs new file mode 100644 index 0000000..1ea59ee --- /dev/null +++ b/server/src/routes/icons.rs @@ -0,0 +1,72 @@ +use axum::{ + extract::{Multipart, State}, + http::StatusCode, + routing::post, + Json, Router, +}; +use rand::distributions::{Alphanumeric, DistString}; +use serde_json::{json, Value}; + +use crate::auth::RequireAuth; +use crate::error::{AppError, Result}; +use crate::state::AppState; + +pub fn router() -> Router<AppState> { + Router::new().route("/icons/upload", post(upload)) +} + +async fn upload( + _auth: RequireAuth, + State(s): State<AppState>, + mut form: Multipart, +) -> Result<(StatusCode, Json<Value>)> { + let icons_dir = s.data_dir.join("icons"); + std::fs::create_dir_all(&icons_dir)?; + + while let Some(field) = form + .next_field() + .await + .map_err(|e| AppError::Validation(format!("multipart: {e}")))? + { + if field.name() != Some("file") { + continue; + } + let filename = field + .file_name() + .map(str::to_owned) + .unwrap_or_else(|| "icon".into()); + let ext = std::path::Path::new(&filename) + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("png") + .to_lowercase(); + if !matches!( + ext.as_str(), + "png" | "jpg" | "jpeg" | "webp" | "svg" | "gif" + ) { + return Err(AppError::Validation("unsupported icon type".into())); + } + let mime = field.content_type().map(str::to_owned); + let data = field + .bytes() + .await + .map_err(|e| AppError::Validation(format!("multipart read: {e}")))?; + if data.len() > 1024 * 1024 { + return Err(AppError::Validation("icon larger than 1MiB".into())); + } + let nonce = Alphanumeric.sample_string(&mut rand::thread_rng(), 8); + let stored = format!("{nonce}.{ext}"); + let path = icons_dir.join(&stored); + std::fs::write(&path, &data)?; + let public_path = format!("/icons/{stored}"); + return Ok(( + StatusCode::CREATED, + Json(json!({ + "path": public_path, + "size": data.len(), + "mime": mime + })), + )); + } + Err(AppError::Validation("no `file` field".into())) +} diff --git a/server/src/routes/items.rs b/server/src/routes/items.rs new file mode 100644 index 0000000..949b36c --- /dev/null +++ b/server/src/routes/items.rs @@ -0,0 +1,54 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + routing::{patch, post}, + Json, Router, +}; + +use crate::auth::RequireAuth; +use crate::dto::{Item, ItemPatch, ItemPayload, ReorderEntry}; +use crate::error::Result; +use crate::state::AppState; + +pub fn router() -> Router<AppState> { + Router::new() + .route("/items", post(create)) + .route("/items/reorder", post(reorder)) + .route("/items/:id", patch(update).delete(remove)) +} + +async fn create( + _auth: RequireAuth, + State(s): State<AppState>, + Json(body): Json<ItemPayload>, +) -> Result<(StatusCode, Json<Item>)> { + let item = s.nav.create_item(body).await?; + Ok((StatusCode::CREATED, Json(item))) +} + +async fn update( + _auth: RequireAuth, + State(s): State<AppState>, + Path(id): Path<i64>, + Json(body): Json<ItemPatch>, +) -> Result<Json<Item>> { + Ok(Json(s.nav.patch_item(id, body).await?)) +} + +async fn remove( + _auth: RequireAuth, + State(s): State<AppState>, + Path(id): Path<i64>, +) -> Result<StatusCode> { + s.nav.delete_item(id).await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn reorder( + _auth: RequireAuth, + State(s): State<AppState>, + Json(entries): Json<Vec<ReorderEntry>>, +) -> Result<StatusCode> { + s.nav.reorder_items(entries).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs new file mode 100644 index 0000000..5947e55 --- /dev/null +++ b/server/src/routes/mod.rs @@ -0,0 +1,33 @@ +use axum::Router; + +pub mod auth; +pub mod config; +pub mod favicon; +pub mod groups; +pub mod health; +pub mod icons; +pub mod items; +pub mod nav; +pub mod sites; +pub mod tags; + +use crate::state::AppState; + +pub fn api(state: AppState) -> Router { + Router::new() + .nest( + "/api", + Router::new() + .merge(health::router()) + .merge(nav::router()) + .merge(auth::router()) + .merge(config::router()) + .merge(items::router()) + .merge(groups::router()) + .merge(sites::router()) + .merge(tags::router()) + .merge(icons::router()) + .merge(favicon::router()), + ) + .with_state(state) +} diff --git a/server/src/routes/nav.rs b/server/src/routes/nav.rs new file mode 100644 index 0000000..decc079 --- /dev/null +++ b/server/src/routes/nav.rs @@ -0,0 +1,15 @@ +use axum::{extract::State, routing::get, Json, Router}; + +use crate::dto::NavBundle; +use crate::error::Result; +use crate::services::bundle::assemble_bundle; +use crate::state::AppState; + +pub fn router() -> Router<AppState> { + Router::new().route("/nav", get(get_nav)) +} + +async fn get_nav(State(s): State<AppState>) -> Result<Json<NavBundle>> { + let bundle = assemble_bundle(s.nav.clone(), s.config.clone()).await?; + Ok(Json(bundle)) +} diff --git a/server/src/routes/sites.rs b/server/src/routes/sites.rs new file mode 100644 index 0000000..9d1638c --- /dev/null +++ b/server/src/routes/sites.rs @@ -0,0 +1,50 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + routing::{patch, post}, + Json, Router, +}; + +use crate::auth::RequireAuth; +use crate::dto::{ReorderEntry, Site, SitePatch, SitePayload}; +use crate::error::Result; +use crate::state::AppState; + +pub fn router() -> Router<AppState> { + Router::new() + .route("/sites", post(create)) + .route("/sites/reorder", post(reorder)) + .route("/sites/:id", patch(update).delete(remove)) +} + +async fn create( + _a: RequireAuth, + State(s): State<AppState>, + Json(body): Json<SitePayload>, +) -> Result<(StatusCode, Json<Site>)> { + Ok((StatusCode::CREATED, Json(s.nav.create_site(body).await?))) +} +async fn update( + _a: RequireAuth, + State(s): State<AppState>, + Path(id): Path<i64>, + Json(body): Json<SitePatch>, +) -> Result<Json<Site>> { + Ok(Json(s.nav.patch_site(id, body).await?)) +} +async fn remove( + _a: RequireAuth, + State(s): State<AppState>, + Path(id): Path<i64>, +) -> Result<StatusCode> { + s.nav.delete_site(id).await?; + Ok(StatusCode::NO_CONTENT) +} +async fn reorder( + _a: RequireAuth, + State(s): State<AppState>, + Json(entries): Json<Vec<ReorderEntry>>, +) -> Result<StatusCode> { + s.nav.reorder_sites(entries).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/server/src/routes/tags.rs b/server/src/routes/tags.rs new file mode 100644 index 0000000..c51884d --- /dev/null +++ b/server/src/routes/tags.rs @@ -0,0 +1,41 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + routing::{patch, post}, + Json, Router, +}; + +use crate::auth::RequireAuth; +use crate::dto::{Tag, TagPatch, TagPayload}; +use crate::error::Result; +use crate::state::AppState; + +pub fn router() -> Router<AppState> { + Router::new() + .route("/tags", post(create)) + .route("/tags/:id", patch(update).delete(remove)) +} + +async fn create( + _a: RequireAuth, + State(s): State<AppState>, + Json(body): Json<TagPayload>, +) -> Result<(StatusCode, Json<Tag>)> { + Ok((StatusCode::CREATED, Json(s.nav.create_tag(body).await?))) +} +async fn update( + _a: RequireAuth, + State(s): State<AppState>, + Path(id): Path<i64>, + Json(body): Json<TagPatch>, +) -> Result<Json<Tag>> { + Ok(Json(s.nav.patch_tag(id, body).await?)) +} +async fn remove( + _a: RequireAuth, + State(s): State<AppState>, + Path(id): Path<i64>, +) -> Result<StatusCode> { + s.nav.delete_tag(id).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/server/src/services/bootstrap.rs b/server/src/services/bootstrap.rs new file mode 100644 index 0000000..a364703 --- /dev/null +++ b/server/src/services/bootstrap.rs @@ -0,0 +1,64 @@ +use crate::auth::password; +use crate::error::Result; +use crate::repo::ConfigRepo; +use rand::distributions::{Alphanumeric, DistString}; +use std::path::Path; +use std::sync::Arc; + +const PASSWORD_LEN: usize = 24; + +pub enum BootstrapOutcome { + AlreadySet, + SetFromEnv, + Generated(String), +} + +pub async fn ensure_admin_password( + config: Arc<dyn ConfigRepo>, + data_dir: &Path, + env_password: Option<String>, +) -> Result<BootstrapOutcome> { + if config.get("admin_password_hash").await?.is_some() { + return Ok(BootstrapOutcome::AlreadySet); + } + + let now = chrono::Utc::now().timestamp_millis().to_string(); + + if let Some(pw) = env_password.filter(|s| !s.is_empty()) { + let hash = password::hash(&pw)?; + config + .upsert_many(&[ + ("admin_password_hash", &hash), + ("admin_password_updated_at", &now), + ]) + .await?; + tracing::warn!("Admin password set from env. Please change it via UI immediately."); + return Ok(BootstrapOutcome::SetFromEnv); + } + + let pw = Alphanumeric.sample_string(&mut rand::thread_rng(), PASSWORD_LEN); + let hash = password::hash(&pw)?; + config + .upsert_many(&[ + ("admin_password_hash", &hash), + ("admin_password_updated_at", &now), + ]) + .await?; + + let path = data_dir.join("INITIAL_PASSWORD.txt"); + let body = format!( + "Initial admin password (single use):\n{pw}\n\n\ + After logging in and changing the password via UI, this file is deleted automatically.\n" + ); + std::fs::write(&path, body)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + std::fs::set_permissions(&path, perms)?; + } + tracing::warn!(?path, "Generated initial admin password — see file above"); + tracing::warn!("INITIAL ADMIN PASSWORD: {pw}"); + + Ok(BootstrapOutcome::Generated(pw)) +} diff --git a/server/src/services/bundle.rs b/server/src/services/bundle.rs new file mode 100644 index 0000000..42244c2 --- /dev/null +++ b/server/src/services/bundle.rs @@ -0,0 +1,63 @@ +use crate::dto::*; +use crate::error::Result; +use crate::repo::{ConfigRepo, NavRepo}; +use std::sync::Arc; + +pub async fn assemble_bundle( + nav: Arc<dyn NavRepo>, + config: Arc<dyn ConfigRepo>, +) -> Result<NavBundle> { + let (sites, groups, items, tags) = nav.get_bundle().await?; + + let cfg = config + .get_many(&[ + "site_name", + "site_avatar_path", + "site_copyright", + "site_icp_text", + "site_icp_url", + "site_police_text", + "site_police_url", + "default_theme", + ]) + .await?; + + let icp = match ( + cfg.get("site_icp_text").cloned(), + cfg.get("site_icp_url").cloned(), + ) { + (Some(text), Some(url)) => Some(Link { text, url }), + _ => None, + }; + let police = match ( + cfg.get("site_police_text").cloned(), + cfg.get("site_police_url").cloned(), + ) { + (Some(text), Some(url)) => Some(Link { text, url }), + _ => None, + }; + + let meta = Meta { + site_name: cfg + .get("site_name") + .cloned() + .unwrap_or_else(|| "Navigation".into()), + site_avatar_path: cfg.get("site_avatar_path").cloned(), + site_copyright: cfg.get("site_copyright").cloned().unwrap_or_default(), + site_icp: icp, + site_police: police, + default_theme: cfg + .get("default_theme") + .cloned() + .unwrap_or_else(|| "system".into()), + }; + + Ok(NavBundle { + schema_version: 1, + meta, + sites, + groups, + items, + tags, + }) +} diff --git a/server/src/services/favicon.rs b/server/src/services/favicon.rs new file mode 100644 index 0000000..048e63f --- /dev/null +++ b/server/src/services/favicon.rs @@ -0,0 +1,70 @@ +use crate::error::{AppError, Result}; +use bytes::Bytes; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +const TTL: Duration = Duration::from_secs(7 * 24 * 3600); // 7 days +const MAX_BYTES: u64 = 256 * 1024; // 256 KiB + +pub struct FaviconService { + cache_dir: PathBuf, + http: reqwest::Client, +} + +impl FaviconService { + pub fn new(cache_dir: PathBuf) -> Self { + std::fs::create_dir_all(&cache_dir).ok(); + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .user_agent("navsrv-favicon/0.1") + .build() + .expect("http client"); + Self { cache_dir, http } + } + + pub async fn fetch(&self, host: &str) -> Result<(String, Bytes)> { + if !is_safe_host(host) { + return Err(AppError::Validation("bad host".into())); + } + let cached = self.cache_dir.join(host); + if let Ok(meta) = std::fs::metadata(&cached) { + if let Ok(age) = SystemTime::now().duration_since(meta.modified()?) { + if age < TTL { + let bytes = Bytes::from(std::fs::read(&cached)?); + return Ok(("image/png".into(), bytes)); + } + } + } + let url = format!("https://www.google.com/s2/favicons?domain={host}&sz=64"); + let resp = self + .http + .get(&url) + .send() + .await + .map_err(|e| AppError::Other(anyhow::anyhow!(e)))?; + if !resp.status().is_success() { + return Err(AppError::NotFound); + } + let bytes = resp + .bytes() + .await + .map_err(|e| AppError::Other(anyhow::anyhow!(e)))?; + if bytes.len() as u64 > MAX_BYTES { + return Err(AppError::Validation("favicon too large".into())); + } + std::fs::write(&cached, &bytes)?; + Ok(("image/png".into(), bytes)) + } +} + +fn is_safe_host(host: &str) -> bool { + !host.is_empty() + && host.len() <= 253 + && host + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') +} + +pub fn cache_dir(data_dir: &Path) -> PathBuf { + data_dir.join("icons").join("_cache") +} diff --git a/server/src/services/migration.rs b/server/src/services/migration.rs new file mode 100644 index 0000000..b42a8db --- /dev/null +++ b/server/src/services/migration.rs @@ -0,0 +1,168 @@ +use crate::dto::*; +use crate::error::{AppError, Result}; +use crate::repo::{ConfigRepo, NavRepo}; +use serde::Deserialize; +use std::sync::Arc; + +const BOOTSTRAP_JSON: &str = include_str!("../../bootstrap.json"); + +#[derive(Deserialize)] +struct BootstrapDoc { + #[serde(rename = "schemaVersion")] + _schema_version: i64, + meta: BootstrapMeta, + sites: Vec<BootstrapSite>, + groups: Vec<BootstrapGroup>, + tags: Vec<BootstrapTag>, + items: Vec<BootstrapItem>, +} + +#[derive(Deserialize)] +struct BootstrapMeta { + #[serde(rename = "siteName")] + site_name: String, + #[serde(rename = "siteAvatarPath")] + site_avatar_path: Option<String>, + #[serde(rename = "siteCopyright")] + site_copyright: String, + #[serde(rename = "siteIcp")] + site_icp: Option<Link>, + #[serde(rename = "sitePolice")] + site_police: Option<Link>, + #[serde(rename = "defaultTheme")] + default_theme: String, +} + +#[derive(Deserialize)] +struct BootstrapSite { + value: String, + name: String, + #[serde(default)] + name_i18n: Option<serde_json::Value>, + #[serde(default)] + is_default: bool, + #[serde(default)] + #[allow(dead_code)] + sort_order: i64, +} + +#[derive(Deserialize)] +struct BootstrapGroup { + slug: String, + name: String, + #[serde(default)] + name_i18n: Option<serde_json::Value>, + #[serde(default)] + #[allow(dead_code)] + sort_order: i64, + #[serde(default)] + collapsed_default: bool, +} + +#[derive(Deserialize)] +struct BootstrapTag { + slug: String, + name: String, + #[serde(default)] + name_i18n: Option<serde_json::Value>, +} + +#[derive(Deserialize)] +struct BootstrapItem { + name: String, + #[serde(default)] + name_i18n: Option<serde_json::Value>, + #[serde(rename = "groupSlug")] + group_slug: Option<String>, + #[serde(rename = "iconKind")] + icon_kind: IconKind, + #[serde(rename = "iconValue")] + icon_value: String, + #[serde(default)] + links: std::collections::BTreeMap<String, String>, + #[serde(default, rename = "tagSlugs")] + tag_slugs: Vec<String>, +} + +pub async fn seed_if_empty(nav: Arc<dyn NavRepo>, config: Arc<dyn ConfigRepo>) -> Result<()> { + let (sites, _, items, _) = nav.get_bundle().await?; + if !sites.is_empty() || !items.is_empty() { + return Ok(()); + } + let doc: BootstrapDoc = + serde_json::from_str(BOOTSTRAP_JSON).map_err(|e| AppError::Other(anyhow::anyhow!(e)))?; + + // Meta → config kv + let mut pairs: Vec<(String, String)> = Vec::new(); + pairs.push(("site_name".into(), doc.meta.site_name)); + if let Some(p) = doc.meta.site_avatar_path { + pairs.push(("site_avatar_path".into(), p)); + } + pairs.push(("site_copyright".into(), doc.meta.site_copyright)); + pairs.push(("default_theme".into(), doc.meta.default_theme)); + if let Some(l) = doc.meta.site_icp { + pairs.push(("site_icp_text".into(), l.text)); + pairs.push(("site_icp_url".into(), l.url)); + } + if let Some(l) = doc.meta.site_police { + pairs.push(("site_police_text".into(), l.text)); + pairs.push(("site_police_url".into(), l.url)); + } + let pair_refs: Vec<(&str, &str)> = pairs + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + config.upsert_many(&pair_refs).await?; + + // Sites + for s in doc.sites { + nav.create_site(SitePayload { + value: s.value, + name: s.name, + name_i18n: s.name_i18n, + is_default: s.is_default, + }) + .await?; + } + // Groups (track id by slug) + let mut group_id_by_slug = std::collections::HashMap::new(); + for g in doc.groups { + let created = nav + .create_group(GroupPayload { + slug: g.slug.clone(), + name: g.name, + name_i18n: g.name_i18n, + collapsed_default: g.collapsed_default, + }) + .await?; + group_id_by_slug.insert(g.slug, created.id); + } + // Tags + for t in doc.tags { + nav.create_tag(TagPayload { + slug: t.slug, + name: t.name, + name_i18n: t.name_i18n, + }) + .await?; + } + // Items + for it in doc.items { + let group_id = it + .group_slug + .and_then(|s| group_id_by_slug.get(&s).copied()); + nav.create_item(ItemPayload { + group_id, + name: it.name, + name_i18n: it.name_i18n, + description: None, + description_i18n: None, + icon_kind: it.icon_kind, + icon_value: it.icon_value, + links: it.links, + tag_slugs: it.tag_slugs, + }) + .await?; + } + Ok(()) +} diff --git a/server/src/services/mod.rs b/server/src/services/mod.rs new file mode 100644 index 0000000..d1a5730 --- /dev/null +++ b/server/src/services/mod.rs @@ -0,0 +1,4 @@ +pub mod bootstrap; +pub mod bundle; +pub mod favicon; +pub mod migration; diff --git a/server/src/state.rs b/server/src/state.rs new file mode 100644 index 0000000..8f88102 --- /dev/null +++ b/server/src/state.rs @@ -0,0 +1,26 @@ +use crate::repo::{ConfigRepo, NavRepo}; +use crate::services::favicon::FaviconService; +use std::path::PathBuf; +use std::sync::Arc; + +#[derive(Clone)] +pub struct AppState { + pub nav: Arc<dyn NavRepo>, + pub config: Arc<dyn ConfigRepo>, + pub data_dir: PathBuf, + pub favicon: Arc<FaviconService>, +} + +impl AppState { + pub fn new(nav: Arc<dyn NavRepo>, config: Arc<dyn ConfigRepo>, data_dir: PathBuf) -> Self { + let favicon = Arc::new(FaviconService::new(crate::services::favicon::cache_dir( + &data_dir, + ))); + Self { + nav, + config, + data_dir, + favicon, + } + } +} diff --git a/server/tests/api_auth.rs b/server/tests/api_auth.rs new file mode 100644 index 0000000..4a7f93a --- /dev/null +++ b/server/tests/api_auth.rs @@ -0,0 +1,78 @@ +use axum_test::TestServer; +use navsrv::app::build_app_for_tests; +use navsrv::auth::password; +use navsrv::db::connect_in_memory; +use navsrv::repo::{ConfigRepo, SqlxConfigRepo}; + +async fn server_with_password(pw: &str) -> (TestServer, std::sync::Arc<dyn ConfigRepo>) { + use navsrv::app::build_app; + use navsrv::auth::session::layer as session_layer; + use navsrv::repo::SqlxNavRepo; + use navsrv::state::AppState; + use std::sync::Arc; + let pool = connect_in_memory().await.unwrap(); + let nav = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg: Arc<dyn ConfigRepo> = Arc::new(SqlxConfigRepo::new(pool.clone())); + cfg.upsert("admin_password_hash", &password::hash(pw).unwrap()) + .await + .unwrap(); + let dir = std::env::temp_dir(); + let state = AppState::new(nav, cfg.clone(), dir.clone()); + let app = build_app(state, session_layer(pool, false), dir); + (TestServer::new(app).unwrap(), cfg) +} + +#[tokio::test] +async fn login_with_correct_password_returns_204_and_cookie() { + let (server, _cfg) = server_with_password("hunter2").await; + let res = server + .post("/api/auth/login") + .json(&serde_json::json!({ "password": "hunter2" })) + .await; + res.assert_status(axum::http::StatusCode::NO_CONTENT); + // Test runs in insecure mode (secure=false) so cookie name is plain "sid". + // Production HTTPS deploys get `__Host-sid` automatically (see auth/session.rs). + assert!(!res.cookie("sid").value().is_empty()); +} + +#[tokio::test] +async fn login_with_wrong_password_returns_401() { + let (server, _) = server_with_password("hunter2").await; + let res = server + .post("/api/auth/login") + .json(&serde_json::json!({ "password": "nope" })) + .await; + res.assert_status(axum::http::StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn me_unauth_returns_false() { + let app = build_app_for_tests().await.unwrap(); + let server = TestServer::new(app).unwrap(); + let res = server.get("/api/auth/me").await; + res.assert_status_ok(); + res.assert_json(&serde_json::json!({ "authenticated": false })); +} + +#[tokio::test] +async fn logout_clears_session() { + let (mut server, _) = server_with_password("hunter2").await; + server.do_save_cookies(); + server + .post("/api/auth/login") + .json(&serde_json::json!({ "password": "hunter2" })) + .await + .assert_status(axum::http::StatusCode::NO_CONTENT); + server + .get("/api/auth/me") + .await + .assert_json(&serde_json::json!({ "authenticated": true })); + server + .post("/api/auth/logout") + .await + .assert_status(axum::http::StatusCode::NO_CONTENT); + server + .get("/api/auth/me") + .await + .assert_json(&serde_json::json!({ "authenticated": false })); +} diff --git a/server/tests/api_config_password.rs b/server/tests/api_config_password.rs new file mode 100644 index 0000000..4ba0894 --- /dev/null +++ b/server/tests/api_config_password.rs @@ -0,0 +1,58 @@ +use axum_test::TestServer; +use navsrv::app::build_app; +use navsrv::auth::{password, session::layer as session_layer}; +use navsrv::db::connect_in_memory; +use navsrv::repo::{ConfigRepo, SqlxConfigRepo, SqlxNavRepo}; +use navsrv::state::AppState; +use std::sync::Arc; +use tempfile::TempDir; + +async fn boot() -> (TestServer, std::sync::Arc<dyn ConfigRepo>, TempDir) { + let pool = connect_in_memory().await.unwrap(); + let nav = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg: Arc<dyn ConfigRepo> = Arc::new(SqlxConfigRepo::new(pool.clone())); + cfg.upsert("admin_password_hash", &password::hash("old").unwrap()) + .await + .unwrap(); + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("INITIAL_PASSWORD.txt"), "old").unwrap(); + let state = AppState::new(nav, cfg.clone(), dir.path().to_path_buf()); + let app = build_app(state, session_layer(pool, false), dir.path().to_path_buf()); + let mut server = TestServer::new(app).unwrap(); + server.do_save_cookies(); + server + .post("/api/auth/login") + .json(&serde_json::json!({"password":"old"})) + .await + .assert_status_success(); + (server, cfg, dir) +} + +#[tokio::test] +async fn change_password_replaces_hash_and_deletes_initial_file() { + let (server, cfg, dir) = boot().await; + let res = server + .post("/api/config/password") + .json(&serde_json::json!({"current":"old","next":"newPwLongEnough"})) + .await; + res.assert_status(axum::http::StatusCode::NO_CONTENT); + + let h = cfg.get("admin_password_hash").await.unwrap().unwrap(); + assert!(password::verify("newPwLongEnough", &h).unwrap()); + assert!(!password::verify("old", &h).unwrap()); + + assert!( + !dir.path().join("INITIAL_PASSWORD.txt").exists(), + "INITIAL_PASSWORD.txt should be deleted after first successful change" + ); +} + +#[tokio::test] +async fn change_password_with_wrong_current_returns_401() { + let (server, _cfg, _dir) = boot().await; + server + .post("/api/config/password") + .json(&serde_json::json!({"current":"WRONG","next":"newPwLongEnough"})) + .await + .assert_status(axum::http::StatusCode::UNAUTHORIZED); +} diff --git a/server/tests/api_groups_sites_tags.rs b/server/tests/api_groups_sites_tags.rs new file mode 100644 index 0000000..0cb6a29 --- /dev/null +++ b/server/tests/api_groups_sites_tags.rs @@ -0,0 +1,93 @@ +use axum_test::TestServer; +use navsrv::app::build_app; +use navsrv::auth::{password, session::layer as session_layer}; +use navsrv::db::connect_in_memory; +use navsrv::repo::{ConfigRepo, NavRepo, SqlxConfigRepo, SqlxNavRepo}; +use navsrv::state::AppState; +use std::sync::Arc; + +async fn boot() -> TestServer { + let pool = connect_in_memory().await.unwrap(); + let nav: Arc<dyn NavRepo> = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg: Arc<dyn ConfigRepo> = Arc::new(SqlxConfigRepo::new(pool.clone())); + cfg.upsert("admin_password_hash", &password::hash("pw").unwrap()) + .await + .unwrap(); + let dir = std::env::temp_dir(); + let state = AppState::new(nav, cfg, dir.clone()); + let app = build_app(state, session_layer(pool, false), dir); + let mut server = TestServer::new(app).unwrap(); + server.do_save_cookies(); + server + .post("/api/auth/login") + .json(&serde_json::json!({"password":"pw"})) + .await + .assert_status_success(); + server +} + +#[tokio::test] +async fn group_lifecycle() { + let server = boot().await; + let g: serde_json::Value = server + .post("/api/groups") + .json(&serde_json::json!({ + "slug":"net","name":"Network" + })) + .await + .json(); + let id = g["id"].as_i64().unwrap(); + server + .patch(&format!("/api/groups/{id}")) + .json(&serde_json::json!({"name":"NET"})) + .await + .assert_status_ok(); + server + .delete(&format!("/api/groups/{id}")) + .await + .assert_status(axum::http::StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn site_lifecycle() { + let server = boot().await; + let s: serde_json::Value = server + .post("/api/sites") + .json(&serde_json::json!({ + "value":"sh","name":"Shanghai" + })) + .await + .json(); + let id = s["id"].as_i64().unwrap(); + server + .patch(&format!("/api/sites/{id}")) + .json(&serde_json::json!({"name":"SH"})) + .await + .assert_status_ok(); + server + .delete(&format!("/api/sites/{id}")) + .await + .assert_status(axum::http::StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn tag_lifecycle() { + let server = boot().await; + let t: serde_json::Value = server + .post("/api/tags") + .json(&serde_json::json!({ + "slug":"fav","name":"Favorite" + })) + .await + .json(); + let id = t["id"].as_i64().unwrap(); + server + .patch(&format!("/api/tags/{id}")) + .json(&serde_json::json!({"name":"⭐"})) + .await + .assert_status_ok(); + server + .delete(&format!("/api/tags/{id}")) + .await + .assert_status(axum::http::StatusCode::NO_CONTENT); +} diff --git a/server/tests/api_icons.rs b/server/tests/api_icons.rs new file mode 100644 index 0000000..d8ed078 --- /dev/null +++ b/server/tests/api_icons.rs @@ -0,0 +1,48 @@ +use axum_test::TestServer; +use navsrv::app::build_app; +use navsrv::auth::{password, session::layer as session_layer}; +use navsrv::db::connect_in_memory; +use navsrv::repo::{ConfigRepo, SqlxConfigRepo, SqlxNavRepo}; +use navsrv::state::AppState; +use std::sync::Arc; + +#[tokio::test] +async fn upload_writes_file_under_data_dir_icons() { + let pool = connect_in_memory().await.unwrap(); + let nav = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg: Arc<dyn ConfigRepo> = Arc::new(SqlxConfigRepo::new(pool.clone())); + cfg.upsert("admin_password_hash", &password::hash("pw").unwrap()) + .await + .unwrap(); + let dir = tempfile::TempDir::new().unwrap(); + let state = AppState::new(nav, cfg, dir.path().to_path_buf()); + let app = build_app(state, session_layer(pool, false), dir.path().to_path_buf()); + let mut server = TestServer::new(app).unwrap(); + server.do_save_cookies(); + server + .post("/api/auth/login") + .json(&serde_json::json!({"password":"pw"})) + .await + .assert_status_success(); + + let res = server + .post("/api/icons/upload") + .multipart( + axum_test::multipart::MultipartForm::new().add_part( + "file", + axum_test::multipart::Part::bytes(b"\x89PNG\r\n\x1a\n".to_vec()) + .file_name("hello.png") + .mime_type("image/png"), + ), + ) + .await; + res.assert_status(axum::http::StatusCode::CREATED); + let body: serde_json::Value = res.json(); + let path = body["path"].as_str().unwrap(); + assert!(path.starts_with("/icons/")); + assert!(dir + .path() + .join("icons") + .join(path.trim_start_matches("/icons/")) + .exists()); +} diff --git a/server/tests/api_items.rs b/server/tests/api_items.rs new file mode 100644 index 0000000..2dad010 --- /dev/null +++ b/server/tests/api_items.rs @@ -0,0 +1,138 @@ +use axum_test::TestServer; +use navsrv::app::build_app; +use navsrv::auth::{password, session::layer as session_layer}; +use navsrv::db::connect_in_memory; +use navsrv::dto::{GroupPayload, SitePayload}; +use navsrv::repo::{ConfigRepo, NavRepo, SqlxConfigRepo, SqlxNavRepo}; +use navsrv::state::AppState; +use std::sync::Arc; + +async fn auth_server() -> (TestServer, i64) { + let pool = connect_in_memory().await.unwrap(); + let nav: Arc<dyn NavRepo> = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg: Arc<dyn ConfigRepo> = Arc::new(SqlxConfigRepo::new(pool.clone())); + cfg.upsert("admin_password_hash", &password::hash("pw").unwrap()) + .await + .unwrap(); + let g = nav + .create_group(GroupPayload { + slug: "tools".into(), + name: "Tools".into(), + name_i18n: None, + collapsed_default: false, + }) + .await + .unwrap(); + nav.create_site(SitePayload { + value: "shangHai".into(), + name: "上海".into(), + name_i18n: None, + is_default: true, + }) + .await + .unwrap(); + + let dir = std::env::temp_dir(); + let state = AppState::new(nav, cfg, dir.clone()); + let app = build_app(state, session_layer(pool, false), dir); + let mut server = TestServer::new(app).unwrap(); + server.do_save_cookies(); + server + .post("/api/auth/login") + .json(&serde_json::json!({"password":"pw"})) + .await + .assert_status_success(); + (server, g.id) +} + +#[tokio::test] +async fn create_then_list_via_bundle() { + let (server, gid) = auth_server().await; + let body = serde_json::json!({ + "groupId": gid, + "name": "Router", + "iconKind": "asset", + "iconValue": "router.png", + "links": { "shangHai": "http://10.0.0.1" }, + "tagSlugs": [] + }); + let res = server.post("/api/items").json(&body).await; + res.assert_status(axum::http::StatusCode::CREATED); + let v: serde_json::Value = res.json(); + let id = v["id"].as_i64().unwrap(); + assert_eq!(v["name"], "Router"); + + let bundle: serde_json::Value = server.get("/api/nav").await.json(); + let item = bundle["items"] + .as_array() + .unwrap() + .iter() + .find(|i| i["id"].as_i64() == Some(id)) + .unwrap(); + assert_eq!(item["links"]["shangHai"], "http://10.0.0.1"); +} + +#[tokio::test] +async fn patch_changes_name() { + let (server, gid) = auth_server().await; + let id = server + .post("/api/items") + .json(&serde_json::json!({ + "groupId": gid, "name": "old", "iconKind":"asset", "iconValue":"x.png" + })) + .await + .json::<serde_json::Value>()["id"] + .as_i64() + .unwrap(); + + let res = server + .patch(&format!("/api/items/{id}")) + .json(&serde_json::json!({ "name": "new" })) + .await; + res.assert_status_ok(); + let v: serde_json::Value = res.json(); + assert_eq!(v["name"], "new"); +} + +#[tokio::test] +async fn delete_removes_item() { + let (server, gid) = auth_server().await; + let id = server + .post("/api/items") + .json(&serde_json::json!({ + "groupId": gid, "name": "x", "iconKind":"asset", "iconValue":"x.png" + })) + .await + .json::<serde_json::Value>()["id"] + .as_i64() + .unwrap(); + server + .delete(&format!("/api/items/{id}")) + .await + .assert_status(axum::http::StatusCode::NO_CONTENT); + + let bundle: serde_json::Value = server.get("/api/nav").await.json(); + assert!(bundle["items"] + .as_array() + .unwrap() + .iter() + .all(|i| i["id"].as_i64() != Some(id))); +} + +#[tokio::test] +async fn unauthed_returns_401() { + let pool = connect_in_memory().await.unwrap(); + let nav: Arc<dyn NavRepo> = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg: Arc<dyn ConfigRepo> = Arc::new(SqlxConfigRepo::new(pool.clone())); + let dir = std::env::temp_dir(); + let state = AppState::new(nav, cfg, dir.clone()); + let app = build_app(state, session_layer(pool, false), dir); + let server = TestServer::new(app).unwrap(); + server + .post("/api/items") + .json(&serde_json::json!({ + "name":"x","iconKind":"asset","iconValue":"x.png" + })) + .await + .assert_status(axum::http::StatusCode::UNAUTHORIZED); +} diff --git a/server/tests/api_nav.rs b/server/tests/api_nav.rs new file mode 100644 index 0000000..7672faf --- /dev/null +++ b/server/tests/api_nav.rs @@ -0,0 +1,15 @@ +use axum_test::TestServer; +use navsrv::app::build_app_for_tests; + +#[tokio::test] +async fn nav_endpoint_returns_empty_bundle_initially() { + let app = build_app_for_tests().await.unwrap(); + let server = TestServer::new(app).unwrap(); + let res = server.get("/api/nav").await; + res.assert_status_ok(); + let body: serde_json::Value = res.json(); + assert_eq!(body["schemaVersion"], 1); + assert!(body["sites"].as_array().unwrap().is_empty()); + assert!(body["items"].as_array().unwrap().is_empty()); + assert_eq!(body["meta"]["defaultTheme"], "system"); +} diff --git a/server/tests/bootstrap.rs b/server/tests/bootstrap.rs new file mode 100644 index 0000000..39b38a4 --- /dev/null +++ b/server/tests/bootstrap.rs @@ -0,0 +1,54 @@ +use navsrv::db::connect_in_memory; +use navsrv::repo::{ConfigRepo, SqlxConfigRepo}; +use navsrv::services::bootstrap::{ensure_admin_password, BootstrapOutcome}; +use std::sync::Arc; +use tempfile::TempDir; + +#[tokio::test] +async fn first_boot_with_env_writes_hash_only() { + let pool = connect_in_memory().await.unwrap(); + let cfg: Arc<dyn ConfigRepo> = Arc::new(SqlxConfigRepo::new(pool)); + let dir = TempDir::new().unwrap(); + + let out = ensure_admin_password(cfg.clone(), dir.path(), Some("env-pw".into())) + .await + .unwrap(); + assert!(matches!(out, BootstrapOutcome::SetFromEnv)); + assert!(cfg.get("admin_password_hash").await.unwrap().is_some()); + assert!(!dir.path().join("INITIAL_PASSWORD.txt").exists()); +} + +#[tokio::test] +async fn first_boot_without_env_generates_and_writes_file() { + let pool = connect_in_memory().await.unwrap(); + let cfg: Arc<dyn ConfigRepo> = Arc::new(SqlxConfigRepo::new(pool)); + let dir = TempDir::new().unwrap(); + + let out = ensure_admin_password(cfg.clone(), dir.path(), None) + .await + .unwrap(); + let pw = match out { + BootstrapOutcome::Generated(pw) => pw, + _ => panic!("expected Generated"), + }; + assert_eq!(pw.len(), 24); + let path = dir.path().join("INITIAL_PASSWORD.txt"); + assert!(path.exists()); + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains(&pw)); +} + +#[tokio::test] +async fn second_boot_is_idempotent() { + let pool = connect_in_memory().await.unwrap(); + let cfg: Arc<dyn ConfigRepo> = Arc::new(SqlxConfigRepo::new(pool)); + let dir = TempDir::new().unwrap(); + + ensure_admin_password(cfg.clone(), dir.path(), Some("first".into())) + .await + .unwrap(); + let out = ensure_admin_password(cfg.clone(), dir.path(), Some("ignored".into())) + .await + .unwrap(); + assert!(matches!(out, BootstrapOutcome::AlreadySet)); +} diff --git a/server/tests/cli.rs b/server/tests/cli.rs new file mode 100644 index 0000000..5a7c974 --- /dev/null +++ b/server/tests/cli.rs @@ -0,0 +1,31 @@ +use navsrv::auth::password; +use navsrv::cli::{run_command, Command}; +use navsrv::config::Settings; + +#[tokio::test] +async fn reset_password_flow() { + let dir = tempfile::TempDir::new().unwrap(); + std::env::set_var("DATA_DIR", dir.path()); + std::env::remove_var("BOOTSTRAP_ADMIN_PASSWORD"); + std::env::set_var("RUST_LOG", "warn"); + // Touch a fake initial password file to ensure it gets removed. + std::fs::write(dir.path().join("INITIAL_PASSWORD.txt"), "old").unwrap(); + + run_command(Command::ResetPassword { + password: Some("brand-new-pw".into()), + }) + .await + .unwrap(); + + let s = Settings::load().unwrap(); + let pool = navsrv::db::connect(&s.db_url()).await.unwrap(); + let h: (String,) = sqlx::query_as("SELECT value FROM config WHERE key='admin_password_hash'") + .fetch_one(&pool) + .await + .unwrap(); + assert!(password::verify("brand-new-pw", &h.0).unwrap()); + assert!(!dir.path().join("INITIAL_PASSWORD.txt").exists()); + + // cleanup env + std::env::remove_var("DATA_DIR"); +} diff --git a/server/tests/health.rs b/server/tests/health.rs new file mode 100644 index 0000000..1b96d84 --- /dev/null +++ b/server/tests/health.rs @@ -0,0 +1,11 @@ +use axum_test::TestServer; +use navsrv::app::build_app_for_tests; + +#[tokio::test] +async fn health_returns_ok_json() { + let app = build_app_for_tests().await.expect("app builds"); + let server = TestServer::new(app).expect("server"); + let res = server.get("/api/health").await; + res.assert_status_ok(); + res.assert_json(&serde_json::json!({ "status": "ok" })); +} diff --git a/server/tests/repo_config.rs b/server/tests/repo_config.rs new file mode 100644 index 0000000..68a3705 --- /dev/null +++ b/server/tests/repo_config.rs @@ -0,0 +1,34 @@ +use navsrv::db::connect_in_memory; +use navsrv::repo::{ConfigRepo, SqlxConfigRepo}; + +#[tokio::test] +async fn upsert_get_delete_config() { + let pool = connect_in_memory().await.unwrap(); + let repo = SqlxConfigRepo::new(pool); + repo.upsert("site_name", "My Site").await.unwrap(); + assert_eq!( + repo.get("site_name").await.unwrap().as_deref(), + Some("My Site") + ); + assert_eq!(repo.get("missing").await.unwrap(), None); + + repo.upsert("site_name", "Renamed").await.unwrap(); + assert_eq!( + repo.get("site_name").await.unwrap().as_deref(), + Some("Renamed") + ); + + repo.delete("site_name").await.unwrap(); + assert_eq!(repo.get("site_name").await.unwrap(), None); +} + +#[tokio::test] +async fn many_pairs() { + let pool = connect_in_memory().await.unwrap(); + let repo = SqlxConfigRepo::new(pool); + repo.upsert_many(&[("a", "1"), ("b", "2")]).await.unwrap(); + let all = repo.get_many(&["a", "b", "c"]).await.unwrap(); + assert_eq!(all.get("a").map(String::as_str), Some("1")); + assert_eq!(all.get("b").map(String::as_str), Some("2")); + assert!(!all.contains_key("c")); +} diff --git a/server/tests/repo_items.rs b/server/tests/repo_items.rs new file mode 100644 index 0000000..e919203 --- /dev/null +++ b/server/tests/repo_items.rs @@ -0,0 +1,184 @@ +use navsrv::db::connect_in_memory; +use navsrv::dto::*; +use navsrv::repo::{NavRepo, SqlxNavRepo}; + +async fn make_repo_with_seed() -> (SqlxNavRepo, i64, i64) { + let pool = connect_in_memory().await.unwrap(); + let repo = SqlxNavRepo::new(pool); + let g = repo + .create_group(GroupPayload { + slug: "tools".into(), + name: "Tools".into(), + name_i18n: None, + collapsed_default: false, + }) + .await + .unwrap(); + let s = repo + .create_site(SitePayload { + value: "shangHai".into(), + name: "上海".into(), + name_i18n: None, + is_default: true, + }) + .await + .unwrap(); + (repo, g.id, s.id) +} + +#[tokio::test] +async fn create_item_with_links_and_tags() { + let (repo, gid, _sid) = make_repo_with_seed().await; + let _t = repo + .create_tag(TagPayload { + slug: "fav".into(), + name: "Favorite".into(), + name_i18n: None, + }) + .await + .unwrap(); + let mut links = std::collections::BTreeMap::new(); + links.insert("shangHai".into(), "http://10.0.0.1".into()); + let item = repo + .create_item(ItemPayload { + group_id: Some(gid), + name: "RouterOS".into(), + name_i18n: None, + description: None, + description_i18n: None, + icon_kind: IconKind::Asset, + icon_value: "routerOS.png".into(), + links, + tag_slugs: vec!["fav".into()], + }) + .await + .unwrap(); + assert_eq!(item.name, "RouterOS"); + assert_eq!( + item.links.get("shangHai").map(String::as_str), + Some("http://10.0.0.1") + ); + assert_eq!(item.tag_slugs, vec!["fav"]); +} + +#[tokio::test] +async fn patch_item_replaces_links_and_tags() { + let (repo, gid, _sid) = make_repo_with_seed().await; + repo.create_tag(TagPayload { + slug: "a".into(), + name: "A".into(), + name_i18n: None, + }) + .await + .unwrap(); + repo.create_tag(TagPayload { + slug: "b".into(), + name: "B".into(), + name_i18n: None, + }) + .await + .unwrap(); + let mut links = std::collections::BTreeMap::new(); + links.insert("shangHai".into(), "http://1".into()); + let item = repo + .create_item(ItemPayload { + group_id: Some(gid), + name: "x".into(), + name_i18n: None, + description: None, + description_i18n: None, + icon_kind: IconKind::Asset, + icon_value: "x.png".into(), + links: links.clone(), + tag_slugs: vec!["a".into()], + }) + .await + .unwrap(); + let mut new_links = std::collections::BTreeMap::new(); + new_links.insert("shangHai".into(), "http://2".into()); + let p = ItemPatch { + links: Some(new_links), + tag_slugs: Some(vec!["b".into()]), + ..Default::default() + }; + let updated = repo.patch_item(item.id, p).await.unwrap(); + assert_eq!( + updated.links.get("shangHai").map(String::as_str), + Some("http://2") + ); + assert_eq!(updated.tag_slugs, vec!["b"]); +} + +#[tokio::test] +async fn delete_item_cascades_links_and_tags() { + let (repo, gid, _sid) = make_repo_with_seed().await; + let mut links = std::collections::BTreeMap::new(); + links.insert("shangHai".into(), "http://1".into()); + let item = repo + .create_item(ItemPayload { + group_id: Some(gid), + name: "x".into(), + name_i18n: None, + description: None, + description_i18n: None, + icon_kind: IconKind::Asset, + icon_value: "x.png".into(), + links, + tag_slugs: vec![], + }) + .await + .unwrap(); + repo.delete_item(item.id).await.unwrap(); + let (_, _, items, _) = repo.get_bundle().await.unwrap(); + assert!(items.is_empty()); +} + +#[tokio::test] +async fn reorder_items_writes_sort_order() { + let (repo, gid, _sid) = make_repo_with_seed().await; + let a = repo + .create_item(ItemPayload { + group_id: Some(gid), + name: "a".into(), + name_i18n: None, + description: None, + description_i18n: None, + icon_kind: IconKind::Asset, + icon_value: "a.png".into(), + links: Default::default(), + tag_slugs: vec![], + }) + .await + .unwrap(); + let b = repo + .create_item(ItemPayload { + group_id: Some(gid), + name: "b".into(), + name_i18n: None, + description: None, + description_i18n: None, + icon_kind: IconKind::Asset, + icon_value: "b.png".into(), + links: Default::default(), + tag_slugs: vec![], + }) + .await + .unwrap(); + repo.reorder_items(vec![ + ReorderEntry { + id: b.id, + sort_order: 0, + group_id: Some(Some(gid)), + }, + ReorderEntry { + id: a.id, + sort_order: 1, + group_id: Some(Some(gid)), + }, + ]) + .await + .unwrap(); + let (_, _, items, _) = repo.get_bundle().await.unwrap(); + assert_eq!(items[0].id, b.id); + assert_eq!(items[1].id, a.id); +} diff --git a/server/tests/repo_sites.rs b/server/tests/repo_sites.rs new file mode 100644 index 0000000..43d28e7 --- /dev/null +++ b/server/tests/repo_sites.rs @@ -0,0 +1,74 @@ +use navsrv::db::connect_in_memory; +use navsrv::dto::{SitePatch, SitePayload}; +use navsrv::error::AppError; +use navsrv::repo::{NavRepo, SqlxNavRepo}; + +#[tokio::test] +async fn create_list_patch_delete_site() { + let pool = connect_in_memory().await.unwrap(); + let repo = SqlxNavRepo::new(pool); + + let s = repo + .create_site(SitePayload { + value: "shangHai".into(), + name: "上海".into(), + name_i18n: Some(serde_json::json!({"en": "Shanghai"})), + is_default: true, + }) + .await + .unwrap(); + assert_eq!(s.value, "shangHai"); + assert!(s.is_default); + + let s2 = repo + .create_site(SitePayload { + value: "beiJing".into(), + name: "北京".into(), + name_i18n: None, + is_default: false, + }) + .await + .unwrap(); + + let all = repo.list_sites().await.unwrap(); + assert_eq!(all.len(), 2); + + let patched = repo + .patch_site( + s2.id, + SitePatch { + name: Some("Beijing".into()), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(patched.name, "Beijing"); + + repo.delete_site(s.id).await.unwrap(); + assert_eq!(repo.list_sites().await.unwrap().len(), 1); +} + +#[tokio::test] +async fn unique_value_constraint_returns_conflict() { + let pool = connect_in_memory().await.unwrap(); + let repo = SqlxNavRepo::new(pool); + repo.create_site(SitePayload { + value: "x".into(), + name: "X".into(), + name_i18n: None, + is_default: false, + }) + .await + .unwrap(); + let err = repo + .create_site(SitePayload { + value: "x".into(), + name: "Y".into(), + name_i18n: None, + is_default: false, + }) + .await + .unwrap_err(); + assert!(matches!(err, AppError::Conflict(_)), "got {err:?}"); +} diff --git a/server/tests/seed.rs b/server/tests/seed.rs new file mode 100644 index 0000000..1ebec3b --- /dev/null +++ b/server/tests/seed.rs @@ -0,0 +1,31 @@ +use navsrv::db::connect_in_memory; +use navsrv::repo::{ConfigRepo, NavRepo, SqlxConfigRepo, SqlxNavRepo}; +use navsrv::services::migration::seed_if_empty; +use std::sync::Arc; + +#[tokio::test] +async fn seed_populates_sites_and_groups() { + let pool = connect_in_memory().await.unwrap(); + let nav: Arc<dyn NavRepo> = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg: Arc<dyn ConfigRepo> = Arc::new(SqlxConfigRepo::new(pool.clone())); + seed_if_empty(nav.clone(), cfg.clone()).await.unwrap(); + let (sites, groups, _items, _tags) = nav.get_bundle().await.unwrap(); + assert!(sites.iter().any(|s| s.value == "shangHai")); + assert!(groups.iter().any(|g| g.slug == "network")); + assert_eq!( + cfg.get("site_name").await.unwrap().as_deref(), + Some("Navigation") + ); +} + +#[tokio::test] +async fn seed_idempotent() { + let pool = connect_in_memory().await.unwrap(); + let nav: Arc<dyn NavRepo> = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg: Arc<dyn ConfigRepo> = Arc::new(SqlxConfigRepo::new(pool.clone())); + seed_if_empty(nav.clone(), cfg.clone()).await.unwrap(); + let count_before = nav.get_bundle().await.unwrap().0.len(); + seed_if_empty(nav.clone(), cfg.clone()).await.unwrap(); + let count_after = nav.get_bundle().await.unwrap().0.len(); + assert_eq!(count_before, count_after); +} diff --git a/server/tests/spa_fallback.rs b/server/tests/spa_fallback.rs new file mode 100644 index 0000000..1a27651 --- /dev/null +++ b/server/tests/spa_fallback.rs @@ -0,0 +1,29 @@ +use axum_test::TestServer; +use navsrv::app::build_app; +use navsrv::auth::session::layer as session_layer; +use navsrv::db::connect_in_memory; +use navsrv::repo::{SqlxConfigRepo, SqlxNavRepo}; +use navsrv::state::AppState; +use std::sync::Arc; +use tempfile::TempDir; + +#[tokio::test] +async fn unknown_path_falls_back_to_index_html() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("index.html"), + "<html><body>SPA</body></html>", + ) + .unwrap(); + + let pool = connect_in_memory().await.unwrap(); + let nav = Arc::new(SqlxNavRepo::new(pool.clone())); + let cfg = Arc::new(SqlxConfigRepo::new(pool.clone())); + let state = AppState::new(nav, cfg, dir.path().to_path_buf()); + + let app = build_app(state, session_layer(pool, false), dir.path().to_path_buf()); + let server = TestServer::new(app).unwrap(); + let res = server.get("/some/spa/route").await; + res.assert_status_ok(); + assert!(res.text().contains("SPA")); +} diff --git a/.eslintignore b/web/.eslintignore similarity index 100% rename from .eslintignore rename to web/.eslintignore diff --git a/.eslintrc.cjs b/web/.eslintrc.cjs similarity index 100% rename from .eslintrc.cjs rename to web/.eslintrc.cjs diff --git a/.npmrc b/web/.npmrc similarity index 100% rename from .npmrc rename to web/.npmrc diff --git a/.prettierignore b/web/.prettierignore similarity index 100% rename from .prettierignore rename to web/.prettierignore diff --git a/.prettierrc b/web/.prettierrc similarity index 100% rename from .prettierrc rename to web/.prettierrc diff --git a/package.json b/web/package.json similarity index 96% rename from package.json rename to web/package.json index 86511db..c1a08a8 100644 --- a/package.json +++ b/web/package.json @@ -2,6 +2,7 @@ "name": "navigation-website", "version": "1.0.0", "type": "module", + "packageManager": "pnpm@9.15.9", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/playwright.config.js b/web/playwright.config.js similarity index 100% rename from playwright.config.js rename to web/playwright.config.js diff --git a/pnpm-lock.yaml b/web/pnpm-lock.yaml similarity index 100% rename from pnpm-lock.yaml rename to web/pnpm-lock.yaml diff --git a/src/app.d.ts b/web/src/app.d.ts similarity index 100% rename from src/app.d.ts rename to web/src/app.d.ts diff --git a/src/app.html b/web/src/app.html similarity index 100% rename from src/app.html rename to web/src/app.html diff --git a/src/app.scss b/web/src/app.scss similarity index 100% rename from src/app.scss rename to web/src/app.scss diff --git a/src/lib/components/Avatar.svelte b/web/src/lib/components/Avatar.svelte similarity index 100% rename from src/lib/components/Avatar.svelte rename to web/src/lib/components/Avatar.svelte diff --git a/src/lib/components/Footer.svelte b/web/src/lib/components/Footer.svelte similarity index 100% rename from src/lib/components/Footer.svelte rename to web/src/lib/components/Footer.svelte diff --git a/src/lib/components/Header.svelte b/web/src/lib/components/Header.svelte similarity index 100% rename from src/lib/components/Header.svelte rename to web/src/lib/components/Header.svelte diff --git a/src/lib/components/Nav.svelte b/web/src/lib/components/Nav.svelte similarity index 100% rename from src/lib/components/Nav.svelte rename to web/src/lib/components/Nav.svelte diff --git a/src/lib/components/SiteSelect.svelte b/web/src/lib/components/SiteSelect.svelte similarity index 100% rename from src/lib/components/SiteSelect.svelte rename to web/src/lib/components/SiteSelect.svelte diff --git a/src/lib/constants/avatar.ts b/web/src/lib/constants/avatar.ts similarity index 100% rename from src/lib/constants/avatar.ts rename to web/src/lib/constants/avatar.ts diff --git a/src/lib/constants/nav.ts b/web/src/lib/constants/nav.ts similarity index 100% rename from src/lib/constants/nav.ts rename to web/src/lib/constants/nav.ts diff --git a/src/lib/constants/siteInfo.ts b/web/src/lib/constants/siteInfo.ts similarity index 100% rename from src/lib/constants/siteInfo.ts rename to web/src/lib/constants/siteInfo.ts diff --git a/src/lib/store/siteStore.ts b/web/src/lib/store/siteStore.ts similarity index 100% rename from src/lib/store/siteStore.ts rename to web/src/lib/store/siteStore.ts diff --git a/src/lib/utils/index.ts b/web/src/lib/utils/index.ts similarity index 100% rename from src/lib/utils/index.ts rename to web/src/lib/utils/index.ts diff --git a/src/lib/utils/isURL.ts b/web/src/lib/utils/isURL.ts similarity index 100% rename from src/lib/utils/isURL.ts rename to web/src/lib/utils/isURL.ts diff --git a/src/routes/+layout.svelte b/web/src/routes/+layout.svelte similarity index 100% rename from src/routes/+layout.svelte rename to web/src/routes/+layout.svelte diff --git a/src/routes/+page.svelte b/web/src/routes/+page.svelte similarity index 100% rename from src/routes/+page.svelte rename to web/src/routes/+page.svelte diff --git a/src/service-worker.ts b/web/src/service-worker.ts similarity index 100% rename from src/service-worker.ts rename to web/src/service-worker.ts diff --git a/static/avatar.png b/web/static/avatar.png similarity index 100% rename from static/avatar.png rename to web/static/avatar.png diff --git a/static/favicon.png b/web/static/favicon.png similarity index 100% rename from static/favicon.png rename to web/static/favicon.png diff --git a/static/manifest.json b/web/static/manifest.json similarity index 100% rename from static/manifest.json rename to web/static/manifest.json diff --git a/static/navIcons/album.png b/web/static/navIcons/album.png similarity index 100% rename from static/navIcons/album.png rename to web/static/navIcons/album.png diff --git a/static/navIcons/blog.png b/web/static/navIcons/blog.png similarity index 100% rename from static/navIcons/blog.png rename to web/static/navIcons/blog.png diff --git a/static/navIcons/esxi.png b/web/static/navIcons/esxi.png similarity index 100% rename from static/navIcons/esxi.png rename to web/static/navIcons/esxi.png diff --git a/static/navIcons/file.png b/web/static/navIcons/file.png similarity index 100% rename from static/navIcons/file.png rename to web/static/navIcons/file.png diff --git a/static/navIcons/homeAssistant.svg b/web/static/navIcons/homeAssistant.svg similarity index 100% rename from static/navIcons/homeAssistant.svg rename to web/static/navIcons/homeAssistant.svg diff --git a/static/navIcons/jackett.png b/web/static/navIcons/jackett.png similarity index 100% rename from static/navIcons/jackett.png rename to web/static/navIcons/jackett.png diff --git a/static/navIcons/jellyfin.svg b/web/static/navIcons/jellyfin.svg similarity index 100% rename from static/navIcons/jellyfin.svg rename to web/static/navIcons/jellyfin.svg diff --git a/static/navIcons/moviePilot.svg b/web/static/navIcons/moviePilot.svg similarity index 100% rename from static/navIcons/moviePilot.svg rename to web/static/navIcons/moviePilot.svg diff --git a/static/navIcons/nasTools.png b/web/static/navIcons/nasTools.png similarity index 100% rename from static/navIcons/nasTools.png rename to web/static/navIcons/nasTools.png diff --git a/static/navIcons/openWRT.png b/web/static/navIcons/openWRT.png similarity index 100% rename from static/navIcons/openWRT.png rename to web/static/navIcons/openWRT.png diff --git a/static/navIcons/phicomm.png b/web/static/navIcons/phicomm.png similarity index 100% rename from static/navIcons/phicomm.png rename to web/static/navIcons/phicomm.png diff --git a/static/navIcons/plex.png b/web/static/navIcons/plex.png similarity index 100% rename from static/navIcons/plex.png rename to web/static/navIcons/plex.png diff --git a/static/navIcons/portainer.svg b/web/static/navIcons/portainer.svg similarity index 100% rename from static/navIcons/portainer.svg rename to web/static/navIcons/portainer.svg diff --git a/static/navIcons/qBittorrent.png b/web/static/navIcons/qBittorrent.png similarity index 100% rename from static/navIcons/qBittorrent.png rename to web/static/navIcons/qBittorrent.png diff --git a/static/navIcons/qinglong.png b/web/static/navIcons/qinglong.png similarity index 100% rename from static/navIcons/qinglong.png rename to web/static/navIcons/qinglong.png diff --git a/static/navIcons/routerOS.png b/web/static/navIcons/routerOS.png similarity index 100% rename from static/navIcons/routerOS.png rename to web/static/navIcons/routerOS.png diff --git a/static/navIcons/singBox.svg b/web/static/navIcons/singBox.svg similarity index 100% rename from static/navIcons/singBox.svg rename to web/static/navIcons/singBox.svg diff --git a/static/navIcons/siyuanNote.png b/web/static/navIcons/siyuanNote.png similarity index 100% rename from static/navIcons/siyuanNote.png rename to web/static/navIcons/siyuanNote.png diff --git a/static/navIcons/surveillanceStation.png b/web/static/navIcons/surveillanceStation.png similarity index 100% rename from static/navIcons/surveillanceStation.png rename to web/static/navIcons/surveillanceStation.png diff --git a/static/navIcons/synology.png b/web/static/navIcons/synology.png similarity index 100% rename from static/navIcons/synology.png rename to web/static/navIcons/synology.png diff --git a/svelte.config.js b/web/svelte.config.js similarity index 100% rename from svelte.config.js rename to web/svelte.config.js diff --git a/tests/test.ts b/web/tests/test.ts similarity index 100% rename from tests/test.ts rename to web/tests/test.ts diff --git a/tsconfig.json b/web/tsconfig.json similarity index 100% rename from tsconfig.json rename to web/tsconfig.json diff --git a/vite.config.ts b/web/vite.config.ts similarity index 53% rename from vite.config.ts rename to web/vite.config.ts index 92ea959..3b1bdc8 100644 --- a/vite.config.ts +++ b/web/vite.config.ts @@ -4,10 +4,15 @@ import type { UserConfig } from 'vite'; const config: UserConfig = { plugins: [sveltekit()], server: { - fs: { - allow: [] - } - } + port: 5173, + strictPort: true, + proxy: { + '/api': { + target: 'http://127.0.0.1:8080', + changeOrigin: false, + }, + }, + }, }; export default config; \ No newline at end of file From cfe7b2e0ba7d1c7e9b5dac3db58871123fcdd82e Mon Sep 17 00:00:00 2001 From: messica <bitxwave@163.com> Date: Wed, 20 May 2026 19:44:49 +0800 Subject: [PATCH 03/77] =?UTF-8?q?chore(web):=20upgrade=20Svelte=203=20?= =?UTF-8?q?=E2=86=92=20Svelte=205=20+=20Kit=202=20+=20Vite=205=20+=20ESLin?= =?UTF-8?q?t=209=20(Plan=201.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend toolchain modernization. Visual zero-regression vs baseline. - Svelte 3.58 → 5.1 (runes API: \$props/\$state/\$derived/\$effect/\$bindable) - SvelteKit next → 2.x stable - Vite 4.3 → 5.4 - ESLint 8 → 9 flat config; eslint-plugin-svelte (svelte3 deprecated) - Prettier 2 → 3 + prettier-plugin-svelte 3 - pnpm 9 (lockfile v6) → 10 (lockfile v9), pinned via packageManager - zod ^3.23 added for Plan 2 API contract validation - 5 .svelte components rewritten for runes (auto-store, {@render}) - siteStore typing fixed for Svelte 5 stricter overloads --- .../2026-05-20-plan-1.5-svelte5-upgrade.md | 1082 ++++++ web/.eslintignore | 13 - web/.eslintrc.cjs | 20 - web/.prettierignore | 3 + web/.prettierrc | 16 +- web/eslint.config.js | 28 + web/package.json | 49 +- web/pnpm-lock.yaml | 3306 ++++++++++------- web/src/app.html | 26 +- web/src/app.scss | 52 +- web/src/lib/components/Footer.svelte | 2 +- web/src/lib/components/Header.svelte | 1 - web/src/lib/components/Nav.svelte | 32 +- web/src/lib/components/SiteSelect.svelte | 69 +- web/src/lib/constants/avatar.ts | 2 +- web/src/lib/constants/nav.ts | 10 +- web/src/lib/constants/siteInfo.ts | 12 +- web/src/lib/store/siteStore.ts | 10 +- web/src/lib/utils/index.ts | 2 +- web/src/lib/utils/isURL.ts | 2 +- web/src/routes/+layout.svelte | 4 +- web/src/service-worker.ts | 4 +- web/static/manifest.json | 2 +- web/svelte.config.js | 19 +- web/tsconfig.json | 3 +- web/vite.config.ts | 10 +- 26 files changed, 3177 insertions(+), 1602 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-20-plan-1.5-svelte5-upgrade.md delete mode 100644 web/.eslintignore delete mode 100644 web/.eslintrc.cjs create mode 100644 web/eslint.config.js diff --git a/docs/superpowers/plans/2026-05-20-plan-1.5-svelte5-upgrade.md b/docs/superpowers/plans/2026-05-20-plan-1.5-svelte5-upgrade.md new file mode 100644 index 0000000..29ed802 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-plan-1.5-svelte5-upgrade.md @@ -0,0 +1,1082 @@ +# Frontend Toolchain Modernization (Plan 1.5) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Upgrade `web/` from Svelte 3.58 + SvelteKit "next" + Vite 4.3 + ESLint 8 + Prettier 2 to the 2026 best-practice baseline — **Svelte 5 (runes API) + SvelteKit 2.x + Vite 5 + ESLint 9 flat config + Prettier 3** — and rewrite the 5 existing `.svelte` files using runes. After this plan, `pnpm dev` still serves the site with **zero visual or behavioral change**, but every subsequent component (Plans 2/3/4) can use modern runes idioms from day one. + +**Architecture:** This plan is purely a toolchain + idiom upgrade. No new features. The site works the same way for end users; the difference is internal — modern reactivity primitives (`$state` / `$derived` / `$effect` / `$props`), flat ESLint config, latest Vite/Kit dev server, deprecated plugin replacements (`eslint-plugin-svelte` instead of `eslint-plugin-svelte3`). + +**Tech Stack (target after this plan):** +- `svelte` ^5.x — runes API (stable since 2024 Q4) +- `@sveltejs/kit` ^2.x stable (no longer `next`) +- `@sveltejs/adapter-static` ^3.x +- `vite` ^5.x +- `typescript` ^5.x (already present, may bump minor) +- `eslint` ^9.x with **flat config** (`eslint.config.js`) +- `eslint-plugin-svelte` ^2.x (replaces deprecated `eslint-plugin-svelte3`) +- `@typescript-eslint/eslint-plugin` ^8.x and `parser` ^8.x (ESLint 9 compat) +- `prettier` ^3.x +- `prettier-plugin-svelte` ^3.x +- `svelte-check` ^4.x +- `@playwright/test` ^1.49 (compatible refresh) +- New addition: `zod` ^3.23 (Plans 2+ depend on this for API contract validation) +- `pnpm-lock.yaml` regenerated under pnpm 10 (lockfile v9). The `packageManager` pin in `web/package.json` is updated from `pnpm@9.15.9` → `pnpm@10.x`. + +**Spec reference:** `docs/superpowers/specs/2026-05-19-rust-navigation-platform-design.md` § 7 (design system) and § 9 (dev workflow). This plan does not change spec semantics; it modernizes the implementation substrate. + +**Predecessors:** +- Plan 1 (Rust backend) merged into `feat/rust-platform` — provides `/api/nav` etc. that this plan still proxies but does not consume yet. + +--- + +## Conventions + +- **Working directory:** every `pnpm` command runs in `web/` unless noted. +- **Sub-branch:** all work on `plan-1.5/svelte5-upgrade`, branched from `feat/rust-platform`. +- **Commits:** Conventional Commits, no `Co-Authored-By` trailer. Each task is one commit. +- **Verification gate:** every task ends with at least one of `pnpm dev` (smoke), `pnpm check` (svelte-check), `pnpm lint`, or `pnpm build`. +- **Visual no-regression:** Phase 1 commits must keep the dev server visually identical to baseline (snapshot from Task 1). The only allowed differences after upgrade are internal (Svelte runes, modern compile output). +- **Pin precise versions** in `package.json` upper bounds (`^X.Y.Z`) — no `next` / `latest` references. + +--- + +## Phase 0: Toolchain Upgrade (Tasks 1–6) + +Goal: get `pnpm dev` and `pnpm check` running cleanly on the new stack **without yet rewriting any `.svelte` file**. Components stay in Svelte 3 syntax; Svelte 5 has a legacy compatibility mode that accepts the old syntax during the transition. Phase 1 then rewrites them. + +### Task 1: Baseline snapshot + +**Files:** +- Read-only verification + +- [ ] **Step 1: Confirm clean tree on `plan-1.5/svelte5-upgrade`** + +```bash +git status +git branch --show-current +``` + +Expected: branch `plan-1.5/svelte5-upgrade`, tree clean. + +- [ ] **Step 2: Boot current dev server and capture title + first 200 bytes** + +```bash +cd web +pnpm install --frozen-lockfile +pnpm dev --host 127.0.0.1 --port 5173 > /tmp/web-baseline.log 2>&1 & +DEV_PID=$! +sleep 6 +curl -sf http://127.0.0.1:5173/ -o /tmp/web-baseline.html +head -c 200 /tmp/web-baseline.html +grep -oE '<title>[^<]+' /tmp/web-baseline.html +kill $DEV_PID 2>/dev/null +cd .. +``` + +Expected: HTML with `XXXX 的小站导航` (or whatever the configured site name is). Save the output for Phase 2 visual no-regression check. + +- [ ] **Step 3: Run lint to capture pre-upgrade state** + +```bash +cd web +pnpm lint 2>&1 | tail -20 || true +cd .. +``` + +Capture the lint output to `/tmp/web-lint-pre.txt`. We expect this to be the last green run on the old stack — Phase 0 will replace the lint setup. + +- [ ] **Step 4: No code changes; do not commit** + +This task is verification only. Tree must remain clean. + +### Task 2: Upgrade `package.json` deps + +**Files:** +- Modify: `web/package.json` + +- [ ] **Step 1: Read current package.json** + +```bash +cat web/package.json +``` + +- [ ] **Step 2: Replace contents** + +```json +{ + "name": "navigation-website", + "version": "1.0.0", + "type": "module", + "packageManager": "pnpm@10.0.0", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "test": "playwright test", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --check . && eslint .", + "format": "prettier --write ." + }, + "devDependencies": { + "@playwright/test": "^1.49.0", + "@sveltejs/adapter-static": "^3.0.6", + "@sveltejs/kit": "^2.8.0", + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@typescript-eslint/eslint-plugin": "^8.13.0", + "@typescript-eslint/parser": "^8.13.0", + "eslint": "^9.14.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-svelte": "^2.46.0", + "globals": "^15.12.0", + "prettier": "^3.3.3", + "prettier-plugin-svelte": "^3.2.7", + "sass": "^1.81.0", + "svelte": "^5.1.16", + "svelte-check": "^4.0.7", + "tslib": "^2.8.1", + "typescript": "^5.6.3", + "typescript-eslint": "^8.13.0", + "vite": "^5.4.10" + }, + "dependencies": { + "zod": "^3.23.8" + } +} +``` + +Notes on the change set: +- Removed `@sveltejs/adapter-auto` (not needed; we always use `adapter-static`). +- Removed `eslint-plugin-svelte3` (deprecated → `eslint-plugin-svelte`). +- Removed `prettier-plugin-svelte` 2.x → 3.x. +- Removed `svelte-preprocess` (Svelte 5 + `@sveltejs/vite-plugin-svelte` 4 handle TS preprocessing natively; the standalone preprocess package is only needed for non-Vite setups). +- Added `@sveltejs/vite-plugin-svelte` ^4 (was a peer of Kit; now explicit). +- Added `globals` ^15 (used by ESLint 9 flat config to declare browser/node globals). +- Added `typescript-eslint` ^8 (the new aggregated package; supersedes the separate parser+plugin pair, but we keep both pinned for explicitness). +- Added `zod` ^3.23 to dependencies (not devDependencies — it's runtime). +- `packageManager` bumped to `pnpm@10`. + +- [ ] **Step 3: Verify JSON parses** + +```bash +node --eval 'JSON.parse(require("fs").readFileSync("web/package.json","utf8"))' && echo OK +``` + +- [ ] **Step 4: Do not run install yet** — Tasks 3 changes lockfile mechanics first. + +- [ ] **Step 5: Commit (config-only commit)** + +```bash +git add web/package.json +git commit -m "chore(web): upgrade deps to Svelte 5 + Kit 2 + Vite 5 + ESLint 9 + Prettier 3" +``` + +### Task 3: Regenerate `pnpm-lock.yaml` under pnpm 10 + +**Files:** +- Modify: `web/pnpm-lock.yaml` (full regenerate) +- Modify: `web/node_modules` (deleted, reinstalled) + +- [ ] **Step 1: Activate pnpm 10 via corepack** + +```bash +corepack enable +corepack prepare pnpm@10.0.0 --activate +pnpm --version +``` + +Expected: `10.x` printed. + +- [ ] **Step 2: Wipe old lockfile + node_modules** + +```bash +cd web +rm -rf node_modules pnpm-lock.yaml .svelte-kit +cd .. +``` + +- [ ] **Step 3: Fresh install (creates lockfile v9)** + +```bash +cd web +pnpm install +cd .. +``` + +Expected: install succeeds, `pnpm-lock.yaml` written. Lockfile header should read `lockfileVersion: '9.0'`. If pnpm still warns about peer-dep mismatches, note them but don't add overrides unless install fails outright. + +- [ ] **Step 4: Verify the lockfile version** + +```bash +head -3 web/pnpm-lock.yaml +``` + +Expected first line: `lockfileVersion: '9.0'` (or higher if pnpm 10 ships v10+; either is acceptable). + +- [ ] **Step 5: Commit lockfile** + +```bash +git add web/pnpm-lock.yaml +git commit -m "chore(web): regenerate pnpm-lock.yaml under pnpm 10 (lockfile v9)" +``` + +### Task 4: Update `svelte.config.js` + `vite.config.ts` for Kit 2 / Vite 5 + +**Files:** +- Modify: `web/svelte.config.js` +- Modify: `web/vite.config.ts` +- Verify: `web/tsconfig.json` (svelte-kit sync regenerates `.svelte-kit/tsconfig.json` automatically; the user-facing tsconfig may need a tiny tweak) + +- [ ] **Step 1: Read current `svelte.config.js`** + +```bash +cat web/svelte.config.js +``` + +It looks like (Svelte 3 era): + +```js +import adapter from '@sveltejs/adapter-static'; +import preprocess from 'svelte-preprocess'; + +const config = { + preprocess: preprocess(), + kit: { adapter: adapter({ fallback: 'index.html' }) }, +}; +export default config; +``` + +- [ ] **Step 2: Replace with Svelte 5 + Kit 2 form** + +```js +// web/svelte.config.js +import adapter from '@sveltejs/adapter-static'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ + pages: 'build', + assets: 'build', + fallback: 'index.html', + precompress: false, + strict: true, + }), + }, +}; + +export default config; +``` + +Key changes: +- `vitePreprocess()` from `@sveltejs/vite-plugin-svelte` (replaces `svelte-preprocess`). +- Explicit adapter options (Kit 2 requires explicit pages/assets paths or omits them; we set both). +- `fallback: 'index.html'` keeps SPA-mode routing — every unknown path serves the SPA shell. The Rust backend's `ServeDir` fallback (Plan 1 Task 31) provides the same behavior in production. + +- [ ] **Step 3: Verify `vite.config.ts` matches Vite 5** + +The current `vite.config.ts` (from Plan 1 Task 3): + +```ts +import { sveltekit } from '@sveltejs/kit/vite'; +import type { UserConfig } from 'vite'; + +const config: UserConfig = { + plugins: [sveltekit()], + server: { + port: 5173, + strictPort: true, + proxy: { + '/api': { + target: 'http://127.0.0.1:8080', + changeOrigin: false, + }, + }, + }, +}; + +export default config; +``` + +Vite 5 / Kit 2 require **no changes** to this file — it stays compatible. Verify by re-reading and confirming. + +- [ ] **Step 4: Tweak `tsconfig.json` if needed** + +Read current: + +```bash +cat web/tsconfig.json +``` + +If it `extends "./.svelte-kit/tsconfig.json"`, leave alone. SvelteKit 2 generates the inner config slightly differently but exports the same path. Make sure `compilerOptions` doesn't override `module` / `moduleResolution` in a way Vite 5 dislikes — if it does, remove those overrides and let svelte-kit own them. + +Recommended user-facing `web/tsconfig.json`: + +```json +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} +``` + +- [ ] **Step 5: Run `svelte-kit sync` to regenerate generated configs** + +```bash +cd web +pnpm exec svelte-kit sync +cd .. +``` + +Expected: `.svelte-kit/tsconfig.json` and `.svelte-kit/types/` regenerated, no errors. + +- [ ] **Step 6: Commit** + +```bash +git add web/svelte.config.js web/tsconfig.json +git commit -m "chore(web): migrate svelte.config.js + tsconfig to Kit 2 / Svelte 5" +``` + +### Task 5: Replace ESLint config with flat-config (ESLint 9) + +**Files:** +- Delete: `web/.eslintrc.cjs`, `web/.eslintignore` +- Create: `web/eslint.config.js` + +- [ ] **Step 1: Inspect old config** + +```bash +cat web/.eslintrc.cjs +cat web/.eslintignore 2>/dev/null || echo "(no eslintignore)" +``` + +- [ ] **Step 2: Remove the legacy config files** + +```bash +git rm web/.eslintrc.cjs +[ -f web/.eslintignore ] && git rm web/.eslintignore +``` + +- [ ] **Step 3: Write `eslint.config.js`** + +```js +// web/eslint.config.js +import js from '@eslint/js'; +import ts from 'typescript-eslint'; +import svelte from 'eslint-plugin-svelte'; +import prettier from 'eslint-config-prettier'; +import globals from 'globals'; + +/** @type {import('eslint').Linter.Config[]} */ +export default [ + js.configs.recommended, + ...ts.configs.recommended, + ...svelte.configs['flat/recommended'], + prettier, + ...svelte.configs['flat/prettier'], + { + languageOptions: { + globals: { ...globals.browser, ...globals.node }, + }, + }, + { + files: ['**/*.svelte'], + languageOptions: { + parserOptions: { parser: ts.parser }, + }, + }, + { + ignores: [ + 'build/', + '.svelte-kit/', + 'node_modules/', + 'static/', + 'pnpm-lock.yaml', + ], + }, +]; +``` + +Notes: +- Flat config replaces `.eslintrc.cjs`. `extends:` is gone — composition is just spreading config arrays. +- `js.configs.recommended` is the new way to opt in to ESLint's built-in recommended rules. +- `ts.configs.recommended` is from the `typescript-eslint` aggregated package. +- `svelte.configs['flat/recommended']` plus `svelte.configs['flat/prettier']` are the eslint-plugin-svelte 2.x flat-config exports. +- `prettier` (from `eslint-config-prettier`) disables stylistic rules that conflict with Prettier. + +- [ ] **Step 4: Add the `@eslint/js` package** + +```bash +cd web +pnpm add -D @eslint/js@^9.14.0 +cd .. +``` + +(This was missed in Task 2's package.json; it's a runtime dep of the flat config above.) + +- [ ] **Step 5: Run lint to verify the config compiles** + +```bash +cd web +pnpm lint 2>&1 | tail -30 +cd .. +``` + +Expected: lint either passes or fails with **rule violations from the actual codebase** (not config errors). Phase 1 will rewrite the offending components, so failures here are acceptable as long as they're rule-driven, not config-driven. + +If you see `Error: Cannot find module ...` or `Failed to load config`, the config has a structural bug — fix before committing. + +- [ ] **Step 6: Commit** + +```bash +git add web/eslint.config.js web/package.json web/pnpm-lock.yaml +git commit -m "chore(web): migrate ESLint to 9.x flat config (eslint.config.js)" +``` + +### Task 6: Update Prettier 3 config + verify svelte-check compiles + +**Files:** +- Modify: `web/.prettierrc` +- Modify (maybe): `web/.prettierignore` + +- [ ] **Step 1: Inspect current `.prettierrc`** + +```bash +cat web/.prettierrc +``` + +It's typically: + +```json +{ + "useTabs": false, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "pluginSearchDirs": ["."], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} +``` + +- [ ] **Step 2: Replace with Prettier 3 form** + +```json +{ + "useTabs": false, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "overrides": [ + { "files": "*.svelte", "options": { "parser": "svelte" } } + ] +} +``` + +Removed `pluginSearchDirs` (deprecated in Prettier 3). The `--plugin-search-dir` flag was already removed from the `lint` script in Task 2. + +- [ ] **Step 3: Verify `.prettierignore` covers build artefacts** + +Make sure it contains at least: + +``` +build/ +.svelte-kit/ +node_modules/ +pnpm-lock.yaml +static/ +``` + +If not, replace with: + +``` +.DS_Store +build/ +.svelte-kit/ +node_modules/ +pnpm-lock.yaml +static/ +``` + +- [ ] **Step 4: Format the entire codebase under Prettier 3** + +```bash +cd web +pnpm format +cd .. +``` + +Prettier 3 may reformat some files (mainly stylistic — e.g. arrow-parens, quote consistency). Inspect the diff with `git diff --stat` — should be small and cosmetic. + +- [ ] **Step 5: Run svelte-check to confirm compile is clean** + +```bash +cd web +pnpm check 2>&1 | tail -25 +cd .. +``` + +Expected: `Done — found 0 errors and 0 warnings`. Svelte 5 will compile the existing Svelte 3 components in legacy mode and print **deprecation warnings** about `export let` and `$:` reactive statements; that's the trigger for Phase 1's rewrite. Note these as expected. + +If you see actual `Error:` lines (not "deprecation"), stop and report — something is genuinely broken. + +- [ ] **Step 6: Commit** + +```bash +git add web/.prettierrc web/.prettierignore web/src +git commit -m "chore(web): bump Prettier to 3.x; reformat under new config" +``` + +(The `web/src` add captures the cosmetic reformat from Step 4.) + +--- + +## Phase 1: Rewrite Components in Runes (Tasks 7–11) + +Goal: replace Svelte 3 idioms (`export let prop`, `$: derived`) with Svelte 5 runes (`let { prop }: Props = $props()`, `$derived`, `$state`, `$effect`). Each task is one component file. The dev server must keep serving the same UI throughout. + +**Runes cheat sheet for this codebase:** +- `export let foo: T` → `let { foo }: { foo: T } = $props();` +- `let count = 0; $: doubled = count * 2;` → `let count = $state(0); let doubled = $derived(count * 2);` +- `$: { sideEffect(count); }` → `$effect(() => { sideEffect(count); });` +- Stores still work the same: `import { foo } from '...'; $foo` reads. +- No ` + +
+ {siteName} +
+ + +``` + +This file already uses no `export let` and no `$:`. **No changes needed.** Svelte 5 accepts it natively. Skip to Step 2. + +- [ ] **Step 2: Footer.svelte** + +Same situation — pure import + render. Read; if it has no `export let` and no `$:`, leave it alone. Otherwise rewrite the affected blocks per the runes cheat sheet. + +- [ ] **Step 3: +layout.svelte** + +Read; the file currently looks like: + +```svelte + + +
+ +
+ +
+ +