diff --git a/docs/superpowers/plans/2026-06-24-photo-wall-server-storage.md b/docs/superpowers/plans/2026-06-24-photo-wall-server-storage.md new file mode 100644 index 0000000..85bd640 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-photo-wall-server-storage.md @@ -0,0 +1,1356 @@ +# 照片墙服务器存图 Implementation Plan + +> **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:** 把照片墙的图片改为按登录用户存到服务器(最多 10 张、严格私有),排版与装饰仍存浏览器。 + +**Architecture:** Rust/Axum 新增 4 个受 JWT 鉴权保护的图片接口(列表/上传/取字节/删除),图片以随机文件名存到 `PHOTO_DIR/<用户>/`;登录改签带用户名的 HS256 JWT。前端用带 `Bearer` 头的 fetch 上传、并把图片取成 `blob` objectURL 显示;每张图的位置/旋转/缩放/标注按图片 id 存 localStorage,缺失则自动散落排版。 + +**Tech Stack:** Rust + Axum 0.8 + jsonwebtoken + base64 + sha2(后端),React 19 + Vite 8 + TypeScript(前端) + +## Global Constraints + +- 设计依据:`docs/superpowers/specs/2026-06-24-photo-wall-server-storage-design.md`(每条任务要求隐含包含本约束)。 +- 后端框架 **axum 0.8**:路径参数用 `{id}` 语法(非 `:id`);`FromRequestParts` 用原生 `async fn`,**不要** `#[async_trait]`。 +- 图片 `id` = 完整文件名,正则 `^[0-9a-f]+\.(jpg|png|gif|webp)$`;非法一律按不存在处理(防目录穿越)。 +- 单图解码后上限 `MAX_BYTES = 8 MiB`;每用户上限 `MAX_PHOTOS = 10`。 +- 允许类型与扩展名映射:`image/jpeg→jpg`、`image/png→png`、`image/gif→gif`、`image/webp→webp`。 +- 用户目录名仅允许 `[A-Za-z0-9_-]`、长度 1–64;不合法拒绝。 +- 鉴权:受保护接口读 `Authorization: Bearer `,校验失败 `401`。`JWT_SECRET` 来自环境变量,缺省用内置开发密钥并告警。 +- 前端 API 基址:`import.meta.env.VITE_API_BASE ?? 'http://localhost:8080'`(生产留空走同源相对路径,与 `api/auth.ts` 一致)。 +- 提交信息结尾加:`Co-Authored-By: Claude Opus 4.8 `。 +- 当前在 `main` 分支:开工前先开特性分支(见 Task 0)。 + +--- + +## File Structure + +后端(`Server/`): +- `Cargo.toml` — 加 `base64` 依赖。 +- `src/auth.rs`(改) — JWT 签发/校验;`issue_token` 改为带用户名。 +- `src/photos.rs`(建) — `PhotoStore` + 纯函数:用户名/ID 校验、data URL 解析、list/save/read/delete/count。 +- `src/routes/extract.rs`(建) — `AuthUser` 提取器。 +- `src/routes/web.rs`(改) — 4 个图片接口、登录改签 JWT、CORS 加 DELETE/Authorization、body 上限。 +- `src/routes/mod.rs`(改) — 声明 `mod extract;`。 +- `src/state.rs`(改) — `AppState` 加 `photos: PhotoStore`。 +- `src/main.rs`(改) — 声明 `mod photos;`。 + +前端(`Client/`): +- `src/hooks/useAuth.ts`(改) — `Auth` 暴露 `token`。 +- `src/api/photos.ts`(建) — list/upload/delete/取 objectUrl。 +- `src/App.tsx`(改) — 把 `token` 传给 `PhotoWallPage`。 +- `src/components/PhotoWallPage.tsx`(改) — 服务器图片接入、持久化结构变更、右键菜单改进。 + +文档/部署: +- `docs/photo-wall.md`(改)、`docs/superpowers/plans/2026-06-18-deploy.md`(改:标注本次是后端全量重部署)。 + +--- + +### Task 0: 开特性分支 + +- [ ] **Step 1: 建并切到分支** + +```bash +cd /home/cc/InternetProject +git checkout -b feat/photo-wall-server-storage +``` +Expected: `Switched to a new branch 'feat/photo-wall-server-storage'`。 + +--- + +### Task 1: 后端 JWT 签发/校验(auth.rs) + +**Files:** +- Modify: `Server/src/auth.rs` +- Test: `Server/src/auth.rs`(`#[cfg(test)]` 内联模块) + +**Interfaces:** +- Produces: `pub fn issue_token(username: &str) -> String`、`pub fn verify_token(token: &str) -> Option`(供 Task 4 的登录与 Task 3 的提取器使用)。 + +- [ ] **Step 1: 写失败测试** + +在 `Server/src/auth.rs` 末尾追加: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn jwt_round_trips_username() { + let secret = b"test-secret"; + let token = sign(secret, "cc", far_future()); + assert_eq!(verify(secret, &token), Some("cc".to_string())); + } + + #[test] + fn jwt_rejects_wrong_secret() { + let token = sign(b"secret-a", "cc", far_future()); + assert_eq!(verify(b"secret-b", &token), None); + } + + #[test] + fn jwt_rejects_garbage() { + assert_eq!(verify(b"s", "not-a-jwt"), None); + } + + #[test] + fn jwt_rejects_expired() { + let token = sign(b"s", "cc", 1); // 1970 年过期 + assert_eq!(verify(b"s", &token), None); + } + + fn far_future() -> usize { + (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 3600) as usize + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd Server && cargo test auth::tests 2>&1 | tail -20` +Expected: 编译失败 —— `sign` / `verify` 未定义。 + +- [ ] **Step 3: 实现 JWT** + +在 `Server/src/auth.rs` 顶部 `use` 区加: + +```rust +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +``` + +把现有的 `pub fn issue_token() -> String { ... }` 整个替换为: + +```rust +/// JWT 载荷:`sub` 为用户名,`exp` 为到期 Unix 秒。 +#[derive(Serialize, Deserialize)] +struct Claims { + sub: String, + exp: usize, +} + +/// 签发登录 token:带用户名的 HS256 JWT,30 天过期。密钥取 `JWT_SECRET`。 +pub fn issue_token(username: &str) -> String { + let exp = now_secs() + 30 * 24 * 3600; + sign(&jwt_secret(), username, exp) +} + +/// 校验 token,成功返回其中的用户名。过期/签名不符/格式错均返回 `None`。 +pub fn verify_token(token: &str) -> Option { + verify(&jwt_secret(), token) +} + +/// 用指定密钥签一个 JWT(便于测试,不读环境变量)。 +fn sign(secret: &[u8], username: &str, exp: usize) -> String { + let claims = Claims { + sub: username.to_string(), + exp, + }; + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret), + ) + .unwrap_or_default() +} + +/// 用指定密钥校验 JWT(便于测试)。默认校验 `exp`。 +fn verify(secret: &[u8], token: &str) -> Option { + decode::( + token, + &DecodingKey::from_secret(secret), + &Validation::default(), + ) + .ok() + .map(|data| data.claims.sub) +} + +/// 从 `JWT_SECRET` 取签名密钥;未设置则用内置开发密钥并告警(生产务必设置)。 +fn jwt_secret() -> Vec { + match std::env::var("JWT_SECRET") { + Ok(s) if !s.trim().is_empty() => s.into_bytes(), + _ => { + eprintln!("[auth] 警告:未设置 JWT_SECRET,使用内置开发密钥(生产环境务必设置)"); + b"dev-insecure-secret-change-me".to_vec() + } + } +} + +fn now_secs() -> usize { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) as usize +} +``` + +> 注:原 `issue_token` 用的 `AtomicU64`/`COUNTER` 若不再被其它代码引用,可一并删除其 `use std::sync::atomic::...`(编译告警会提示)。`SystemTime`/`UNIX_EPOCH` 仍需保留。 + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd Server && cargo test auth::tests 2>&1 | tail -20` +Expected: `test result: ok. 4 passed`。 + +- [ ] **Step 5: 提交** + +```bash +git add Server/src/auth.rs +git commit -m "feat(auth): issue/verify username-bearing HS256 JWT + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 2: 后端图片存储(photos.rs) + +**Files:** +- Create: `Server/src/photos.rs` +- Modify: `Server/Cargo.toml`、`Server/src/main.rs` +- Test: `Server/src/photos.rs`(`#[cfg(test)]` 内联) + +**Interfaces:** +- Produces: + - `pub struct PhotoStore`(`Clone`);`PhotoStore::from_env()`、`PhotoStore::new(base)`。 + - 方法:`list(&self, user) -> Vec`、`save(&self, user, data_url) -> Result`、`read(&self, user, id) -> Option<(&'static str, Vec)>`、`delete(&self, user, id) -> bool`。 + - `pub enum SaveError { Full, TooLarge, BadType, BadUser, Io }`;`pub const MAX_PHOTOS: usize = 10`。 + (供 Task 4 的 handler 使用。) + +- [ ] **Step 1: 加 base64 依赖** + +编辑 `Server/Cargo.toml`,在 `[dependencies]` 下加一行(保持字母序附近即可): + +```toml +base64 = "0.22" +``` + +- [ ] **Step 2: 声明模块** + +编辑 `Server/src/main.rs`,在 `mod monitor;` 后加一行: + +```rust +mod photos; +``` + +- [ ] **Step 3: 写失败测试 + 模块骨架** + +新建 `Server/src/photos.rs`,先只放测试与签名(让它编译失败): + +```rust +//! 照片墙图片存储:按用户隔离到 PHOTO_DIR//,每图随机十六进制文件名。 + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + // 每个测试一个独立临时目录,结束清理(不引入 tempfile 依赖)。 + struct TmpDir(PathBuf); + impl TmpDir { + fn new() -> Self { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let p = std::env::temp_dir().join(format!("pw-test-{nanos}")); + std::fs::create_dir_all(&p).unwrap(); + TmpDir(p) + } + } + impl Drop for TmpDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + // 1x1 PNG 的 data URL(合法图片)。 + const PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + + #[test] + fn sanitize_user_accepts_safe_rejects_traversal() { + assert_eq!(sanitize_user("cc"), Some("cc".to_string())); + assert_eq!(sanitize_user("a_b-1"), Some("a_b-1".to_string())); + assert_eq!(sanitize_user("../etc"), None); + assert_eq!(sanitize_user("a/b"), None); + assert_eq!(sanitize_user(""), None); + } + + #[test] + fn valid_id_guards_extension_and_path() { + assert!(valid_id("abc123.jpg")); + assert!(valid_id("ff.webp")); + assert!(!valid_id("abc.exe")); + assert!(!valid_id("../x.jpg")); + assert!(!valid_id("a/b.png")); + assert!(!valid_id("nodot")); + assert!(!valid_id("XYZ.png")); // 非 hex + } + + #[test] + fn parse_data_url_extracts_ext_and_rejects_nonimage() { + let (ext, bytes) = parse_data_url(PNG_DATA_URL).unwrap(); + assert_eq!(ext, "png"); + assert!(!bytes.is_empty()); + assert_eq!(parse_data_url("data:text/plain;base64,aGk="), Err(SaveError::BadType)); + assert_eq!(parse_data_url("not-a-data-url"), Err(SaveError::BadType)); + } + + #[test] + fn save_list_read_delete_round_trip() { + let tmp = TmpDir::new(); + let store = PhotoStore::new(&tmp.0); + assert_eq!(store.list("cc").len(), 0); + + let id = store.save("cc", PNG_DATA_URL).unwrap(); + assert!(valid_id(&id)); + assert_eq!(store.list("cc"), vec![id.clone()]); + + let (mime, bytes) = store.read("cc", &id).unwrap(); + assert_eq!(mime, "image/png"); + assert!(!bytes.is_empty()); + + assert!(store.delete("cc", &id)); + assert_eq!(store.list("cc").len(), 0); + } + + #[test] + fn save_enforces_max_photos() { + let tmp = TmpDir::new(); + let store = PhotoStore::new(&tmp.0); + for _ in 0..MAX_PHOTOS { + store.save("cc", PNG_DATA_URL).unwrap(); + } + assert_eq!(store.save("cc", PNG_DATA_URL), Err(SaveError::Full)); + } + + #[test] + fn read_rejects_bad_id_and_other_users() { + let tmp = TmpDir::new(); + let store = PhotoStore::new(&tmp.0); + let id = store.save("cc", PNG_DATA_URL).unwrap(); + assert!(store.read("cc", "../secret.jpg").is_none()); + assert!(store.read("dave", &id).is_none()); // 不是 dave 的目录 + assert!(!store.delete("cc", "../secret.jpg")); + } +} +``` + +- [ ] **Step 4: 跑测试确认失败** + +Run: `cd Server && cargo test photos::tests 2>&1 | tail -20` +Expected: 编译失败 —— `PhotoStore`/`sanitize_user`/`valid_id`/`parse_data_url`/`SaveError`/`MAX_PHOTOS` 未定义。 + +- [ ] **Step 5: 实现存储逻辑** + +在 `Server/src/photos.rs` 顶部(`#[cfg(test)]` 之前)插入: + +```rust +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use base64::Engine; +use sha2::{Digest, Sha256}; + +pub const MAX_PHOTOS: usize = 10; +pub const MAX_BYTES: usize = 8 * 1024 * 1024; + +/// 保存失败原因(映射到 HTTP 状态码见 routes/web.rs)。 +#[derive(Debug, PartialEq, Eq)] +pub enum SaveError { + Full, + TooLarge, + BadType, + BadUser, + Io, +} + +/// 按用户隔离的图片仓库。内部只是一个根目录路径,`Clone` 廉价。 +#[derive(Clone)] +pub struct PhotoStore { + base: PathBuf, +} + +impl PhotoStore { + /// 根目录取 `PHOTO_DIR`,默认 `photo-wall`(相对进程 cwd)。 + pub fn from_env() -> Self { + let base = std::env::var("PHOTO_DIR").unwrap_or_else(|_| "photo-wall".to_string()); + Self { base: base.into() } + } + + pub fn new(base: impl Into) -> Self { + Self { base: base.into() } + } + + fn user_dir(&self, user: &str) -> Option { + Some(self.base.join(sanitize_user(user)?)) + } + + pub fn list(&self, user: &str) -> Vec { + self.user_dir(user).map(|d| list_in(&d)).unwrap_or_default() + } + + pub fn save(&self, user: &str, data_url: &str) -> Result { + let dir = self.user_dir(user).ok_or(SaveError::BadUser)?; + let (ext, bytes) = parse_data_url(data_url)?; + std::fs::create_dir_all(&dir).map_err(|_| SaveError::Io)?; + if list_in(&dir).len() >= MAX_PHOTOS { + return Err(SaveError::Full); + } + let id = format!("{}.{}", new_stem(), ext); + std::fs::write(dir.join(&id), &bytes).map_err(|_| SaveError::Io)?; + Ok(id) + } + + pub fn read(&self, user: &str, id: &str) -> Option<(&'static str, Vec)> { + if !valid_id(id) { + return None; + } + let dir = self.user_dir(user)?; + let bytes = std::fs::read(dir.join(id)).ok()?; + let ext = id.rsplit_once('.').map(|(_, e)| e)?; + Some((mime_for_ext(ext), bytes)) + } + + pub fn delete(&self, user: &str, id: &str) -> bool { + if !valid_id(id) { + return false; + } + match self.user_dir(user) { + Some(dir) => std::fs::remove_file(dir.join(id)).is_ok(), + None => false, + } + } +} + +/// 用户名 → 安全目录名:仅允许 `[A-Za-z0-9_-]`、长度 1–64。 +pub fn sanitize_user(user: &str) -> Option { + if user.is_empty() || user.len() > 64 { + return None; + } + if user + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + Some(user.to_string()) + } else { + None + } +} + +/// 图片 id 合法性:`.`,ext 在白名单内;杜绝路径穿越。 +pub fn valid_id(id: &str) -> bool { + let Some((stem, ext)) = id.rsplit_once('.') else { + return false; + }; + matches!(ext, "jpg" | "png" | "gif" | "webp") + && !stem.is_empty() + && stem.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) +} + +/// 解析 data URL(`data:image/png;base64,xxxx`)→ (ext, bytes),校验类型与大小。 +pub fn parse_data_url(data_url: &str) -> Result<(&'static str, Vec), SaveError> { + let rest = data_url.strip_prefix("data:").ok_or(SaveError::BadType)?; + let (meta, b64) = rest.split_once(',').ok_or(SaveError::BadType)?; + let mime = meta.split(';').next().unwrap_or(""); + let ext = ext_for_mime(mime).ok_or(SaveError::BadType)?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(b64.trim()) + .map_err(|_| SaveError::BadType)?; + if bytes.len() > MAX_BYTES { + return Err(SaveError::TooLarge); + } + Ok((ext, bytes)) +} + +fn ext_for_mime(mime: &str) -> Option<&'static str> { + match mime { + "image/jpeg" => Some("jpg"), + "image/png" => Some("png"), + "image/gif" => Some("gif"), + "image/webp" => Some("webp"), + _ => None, + } +} + +fn mime_for_ext(ext: &str) -> &'static str { + match ext { + "jpg" => "image/jpeg", + "png" => "image/png", + "gif" => "image/gif", + "webp" => "image/webp", + _ => "application/octet-stream", + } +} + +fn list_in(dir: &Path) -> Vec { + let mut out = Vec::new(); + if let Ok(rd) = std::fs::read_dir(dir) { + for entry in rd.flatten() { + if let Some(name) = entry.file_name().to_str() { + if valid_id(name) { + out.push(name.to_string()); + } + } + } + } + out.sort(); + out +} + +/// 生成 16 位 hex 文件名干(纳秒 + 进程内计数器哈希;读取已鉴权,故无需密码学随机)。 +fn new_stem() -> String { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let digest = Sha256::digest(format!("{nanos}-{n}").as_bytes()); + digest.iter().take(8).map(|b| format!("{b:02x}")).collect() +} +``` + +- [ ] **Step 6: 跑测试确认通过** + +Run: `cd Server && cargo test photos::tests 2>&1 | tail -20` +Expected: `test result: ok. 6 passed`。 + +- [ ] **Step 7: 提交** + +```bash +git add Server/Cargo.toml Server/Cargo.lock Server/src/main.rs Server/src/photos.rs +git commit -m "feat(photos): per-user image store with size/count/path-safety guards + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 3: AuthUser 提取器(routes/extract.rs) + +**Files:** +- Create: `Server/src/routes/extract.rs` +- Modify: `Server/src/routes/mod.rs` + +**Interfaces:** +- Consumes: `crate::auth::verify_token`。 +- Produces: `pub struct AuthUser(pub String)`,实现 `FromRequestParts`(鉴权失败 `401`)。供 Task 4 用作 handler 参数。 + +- [ ] **Step 1: 建提取器** + +新建 `Server/src/routes/extract.rs`: + +```rust +//! 鉴权提取器:从 `Authorization: Bearer ` 解出当前用户名。 +//! +//! axum 0.8 的 `FromRequestParts` 用原生 `async fn`,无需 `#[async_trait]`。 + +use axum::extract::FromRequestParts; +use axum::http::{header, request::Parts, StatusCode}; + +use crate::auth; + +/// 受保护接口的参数:产出已校验的用户名;缺失/非法/过期 token 一律 `401`。 +pub struct AuthUser(pub String); + +impl FromRequestParts for AuthUser +where + S: Send + Sync, +{ + type Rejection = StatusCode; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let header = parts + .headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + let token = header.strip_prefix("Bearer ").ok_or(StatusCode::UNAUTHORIZED)?; + auth::verify_token(token) + .map(AuthUser) + .ok_or(StatusCode::UNAUTHORIZED) + } +} +``` + +- [ ] **Step 2: 声明模块** + +编辑 `Server/src/routes/mod.rs`,在 `mod client;` 后加: + +```rust +mod extract; +``` + +- [ ] **Step 3: 编译确认** + +Run: `cd Server && cargo build 2>&1 | tail -20` +Expected: 编译通过(可能有 `AuthUser`/`extract` 未使用的告警,Task 4 会用上)。 + +- [ ] **Step 4: 提交** + +```bash +git add Server/src/routes/extract.rs Server/src/routes/mod.rs +git commit -m "feat(routes): AuthUser extractor validating Bearer JWT + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 4: 图片接口 + 登录改签 JWT(state.rs, web.rs) + +**Files:** +- Modify: `Server/src/state.rs`、`Server/src/routes/web.rs` + +**Interfaces:** +- Consumes: `PhotoStore`、`SaveError`、`MAX_PHOTOS`(Task 2)、`AuthUser`(Task 3)、`auth::issue_token`(Task 1)。 +- Produces: HTTP 接口 `GET/POST /api/web/photos`、`GET/DELETE /api/web/photos/{id}`(Task 6 前端消费)。 + +- [ ] **Step 1: AppState 加 PhotoStore** + +编辑 `Server/src/state.rs`: + +在 `use crate::weather::WeatherService;` 后加: +```rust +use crate::photos::PhotoStore; +``` + +在结构体里 `pub weather: ...` 后加字段: +```rust + /// 照片墙图片仓库(按用户隔离的磁盘目录)。 + pub photos: PhotoStore, +``` + +在 `Self { ... }` 初始化里 `weather: ...` 后加: +```rust + photos: PhotoStore::from_env(), +``` + +- [ ] **Step 2: 登录改签带用户名的 JWT** + +编辑 `Server/src/routes/web.rs`,把 `login` 里: +```rust + token: crate::auth::issue_token(), +``` +改为: +```rust + token: crate::auth::issue_token(&req.username), +``` + +- [ ] **Step 3: 加图片 handler 与路由** + +编辑 `Server/src/routes/web.rs`。顶部 `use` 区补充: + +```rust +use axum::extract::{DefaultBodyLimit, Path as AxPath}; +use axum::response::{IntoResponse, Response}; +use axum::routing::delete; + +use crate::photos::{SaveError, MAX_PHOTOS}; +use crate::routes::extract::AuthUser; +``` + +> 说明:`get`/`post` 已在原有 `routing::{get, post}` 引入;这里补 `delete`。 +> 若 `routing` 那行是 `use axum::routing::{get, post};`,改成 `use axum::routing::{delete, get, post};`,并删掉上面单独的 `use axum::routing::delete;` 以免重复。 + +CORS 改为放行 DELETE 与 Authorization 头 —— 把: +```rust + .allow_methods([Method::GET, Method::POST]) + .allow_headers([header::CONTENT_TYPE]); +``` +改成: +```rust + .allow_methods([Method::GET, Method::POST, Method::DELETE]) + .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]); +``` + +路由表里,在 `.route("/api/web/weather", get(weather))` 后加: +```rust + .route("/api/web/photos", get(list_photos).post(upload_photo)) + .route("/api/web/photos/{id}", get(get_photo).delete(delete_photo)) + .layer(DefaultBodyLimit::max(16 * 1024 * 1024)) +``` + +在文件末尾(`client_ip` 函数后)追加 handler: + +```rust +/// 当前用户的图片 id 列表 + 数量上限。 +#[derive(Serialize)] +struct PhotoList { + items: Vec, + max: usize, +} +#[derive(Serialize)] +struct PhotoItem { + id: String, +} + +async fn list_photos(AuthUser(user): AuthUser, State(state): State) -> Json { + let items = state + .photos + .list(&user) + .into_iter() + .map(|id| PhotoItem { id }) + .collect(); + Json(PhotoList { + items, + max: MAX_PHOTOS, + }) +} + +/// 上传请求体:base64 data URL(前端 FileReader 读出的)。 +#[derive(Deserialize)] +struct UploadRequest { + #[serde(rename = "dataUrl")] + data_url: String, +} +#[derive(Serialize)] +struct UploadResponse { + id: String, +} + +async fn upload_photo( + AuthUser(user): AuthUser, + State(state): State, + Json(req): Json, +) -> Result<(StatusCode, Json), StatusCode> { + match state.photos.save(&user, &req.data_url) { + Ok(id) => Ok((StatusCode::CREATED, Json(UploadResponse { id }))), + Err(SaveError::Full) => Err(StatusCode::CONFLICT), + Err(SaveError::TooLarge) => Err(StatusCode::PAYLOAD_TOO_LARGE), + Err(SaveError::BadType) => Err(StatusCode::UNSUPPORTED_MEDIA_TYPE), + Err(SaveError::BadUser) => Err(StatusCode::BAD_REQUEST), + Err(SaveError::Io) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +async fn get_photo( + AuthUser(user): AuthUser, + State(state): State, + AxPath(id): AxPath, +) -> Response { + match state.photos.read(&user, &id) { + Some((mime, bytes)) => ([(header::CONTENT_TYPE, mime)], bytes).into_response(), + None => StatusCode::NOT_FOUND.into_response(), + } +} + +async fn delete_photo( + AuthUser(user): AuthUser, + State(state): State, + AxPath(id): AxPath, +) -> StatusCode { + if state.photos.delete(&user, &id) { + StatusCode::NO_CONTENT + } else { + StatusCode::NOT_FOUND + } +} +``` + +- [ ] **Step 4: 编译** + +Run: `cd Server && cargo build 2>&1 | tail -20` +Expected: 编译通过。 + +- [ ] **Step 5: 本地起服 + curl 冒烟** + +本地需要 token:用内置兜底用户 `cc/192118Lht`(无 DB 时)。开两个终端,或后台起服: + +```bash +cd Server +JWT_SECRET=local-test PHOTO_DIR=/tmp/pw-photos PORT=8080 cargo run >/tmp/pw-server.log 2>&1 & +sleep 2 +# 1) 登录拿 token +TOKEN=$(curl -s -X POST localhost:8080/api/web/login -H 'Content-Type: application/json' \ + -d '{"username":"cc","password":"192118Lht"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4) +echo "token: ${TOKEN:0:24}..." +# 2) 无 token 应 401 +curl -s -o /dev/null -w 'no-token -> %{http_code}\n' localhost:8080/api/web/photos +# 3) 带 token 列表应 200 空列表 +curl -s -H "Authorization: Bearer $TOKEN" localhost:8080/api/web/photos +# 4) 上传 1x1 png +curl -s -X POST localhost:8080/api/web/photos -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"dataUrl":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="}' +echo +# 5) 列表应有 1 张,取其 id 验证可下载 +curl -s -H "Authorization: Bearer $TOKEN" localhost:8080/api/web/photos +``` +Expected:`no-token -> 401`;列表先 `{"items":[],"max":10}`,上传返回 `{"id":"....png"}`,再列表含该 id。 +清理:`pkill -f 'target/.*RustServer' ; pkill -f 'cargo run'`(或记录 PID kill)。 + +- [ ] **Step 6: 提交** + +```bash +git add Server/src/state.rs Server/src/routes/web.rs +git commit -m "feat(web): per-user photo endpoints (list/upload/get/delete) behind JWT + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 5: 前端图片 API + useAuth 暴露 token + +**Files:** +- Modify: `Client/src/hooks/useAuth.ts`、`Client/src/App.tsx` +- Create: `Client/src/api/photos.ts` + +**Interfaces:** +- Produces: `Auth.token: string | null`;`Client/src/api/photos.ts` 导出 `listPhotos`、`uploadPhoto`、`deletePhoto`、`fetchPhotoObjectUrl`、`PhotoItem`、`UploadError`。供 Task 6 消费。 + +- [ ] **Step 1: useAuth 暴露 token** + +编辑 `Client/src/hooks/useAuth.ts`: + +`interface Auth` 里 `username: string | null` 后加: +```ts + /** 当前会话 token(JWT),未登录为 null。用于受保护接口的 Authorization 头 */ + token: string | null +``` + +`return` 那行改为: +```ts + return { username: session?.username ?? null, token: session?.token ?? null, signIn, signOut } +``` + +- [ ] **Step 2: 新建图片 API** + +新建 `Client/src/api/photos.ts`: + +```ts +// 照片墙图片接口封装。基址与 auth.ts 一致:生产留空走同源相对路径。 +const API_BASE = + (import.meta.env.VITE_API_BASE as string | undefined) ?? 'http://localhost:8080' + +export interface PhotoItem { + id: string +} + +/** 上传失败的可区分原因,供 UI 给出对应提示 */ +export type UploadError = 'full' | 'too-large' | 'bad-type' | 'failed' + +function authHeaders(token: string): Record { + return { Authorization: `Bearer ${token}` } +} + +/** 列出当前用户的图片 id 与数量上限 */ +export async function listPhotos(token: string): Promise<{ items: PhotoItem[]; max: number }> { + const res = await fetch(`${API_BASE}/api/web/photos`, { headers: authHeaders(token) }) + if (!res.ok) throw new Error(`list failed: ${res.status}`) + return (await res.json()) as { items: PhotoItem[]; max: number } +} + +/** 上传一张图片(base64 data URL),返回服务器分配的图片 id */ +export async function uploadPhoto(token: string, dataUrl: string): Promise { + const res = await fetch(`${API_BASE}/api/web/photos`, { + method: 'POST', + headers: { ...authHeaders(token), 'Content-Type': 'application/json' }, + body: JSON.stringify({ dataUrl }), + }) + if (res.ok) return ((await res.json()) as { id: string }).id + const reason: UploadError = + res.status === 409 + ? 'full' + : res.status === 413 + ? 'too-large' + : res.status === 415 + ? 'bad-type' + : 'failed' + throw new Error(reason) +} + +/** 删除一张图片 */ +export async function deletePhoto(token: string, id: string): Promise { + const res = await fetch(`${API_BASE}/api/web/photos/${id}`, { + method: 'DELETE', + headers: authHeaders(token), + }) + if (!res.ok && res.status !== 404) throw new Error(`delete failed: ${res.status}`) +} + +/** 取图片字节为 objectURL(带鉴权,故不能直接用作 的服务器 URL)。 + * 调用方负责在不用时 URL.revokeObjectURL 释放。 */ +export async function fetchPhotoObjectUrl(token: string, id: string): Promise { + const res = await fetch(`${API_BASE}/api/web/photos/${id}`, { headers: authHeaders(token) }) + if (!res.ok) throw new Error(`get failed: ${res.status}`) + return URL.createObjectURL(await res.blob()) +} +``` + +- [ ] **Step 3: App 传 token 给照片墙** + +编辑 `Client/src/App.tsx`,把: +```tsx + ? +``` +改为: +```tsx + ? +``` + +- [ ] **Step 4: 类型检查 + lint** + +Run: `cd Client && npm run build 2>&1 | tail -5 && npm run lint 2>&1 | tail -8` +Expected: build 成功;新增/改动文件无 lint 报错。 +(此时 `PhotoWallPage` 还没声明 `token` prop,TS 会报错 —— 这是预期的,下一任务补上。若想本步即过,可先在 `PhotoWallPageProps` 加 `token: string` 占位,但 Task 6 会正式用它。**为避免红灯阻断,本步把 Task 6 Step 1 一起做了再验证。**) + +- [ ] **Step 5: 提交** + +```bash +git add Client/src/hooks/useAuth.ts Client/src/api/photos.ts Client/src/App.tsx +git commit -m "feat(client): photos API client + expose auth token + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 6: PhotoWallPage 接服务器图片 + 持久化结构变更 + +**Files:** +- Modify: `Client/src/components/PhotoWallPage.tsx` + +**Interfaces:** +- Consumes: `Client/src/api/photos.ts`(Task 5)。 +- Produces: 接 `token` prop 的照片墙;localStorage 结构 `{ bgIndex, decorations: El[], photoLayout: Record }`。 + +> 说明:这是对现有大组件的改造。以下按"加字段 / 改持久化 / 改加载 / 改上传删除"分步,每步给出完整替换代码块与锚点。 + +- [ ] **Step 1: 扩展 props 与 El 类型** + +`interface PhotoWallPageProps` 改为: +```tsx +interface PhotoWallPageProps { + /** 退出照片墙(清登录态 + 抹掉地址栏 #photo,回到导航页) */ + onExit: () => void + /** 当前会话 token,用于图片接口鉴权 */ + token: string +} +``` +函数签名改为: +```tsx +export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) { +``` +`interface El` 里加一个可选字段(photo 专用,关联服务器图片 id): +```tsx + /** 服务器图片 id(仅 type==='photo'),也是 localStorage 排版的 key */ + photoId?: string +``` + +- [ ] **Step 2: 定义持久化结构与加载函数** + +把现有的 `interface SavedState`、`loadInitial`、以及顶部 `STORAGE_KEY` 相关部分按下面替换/补充。 + +新增类型(放在 `interface El` 之后): +```tsx +/** 每张照片的本地排版覆盖(按服务器图片 id 存浏览器) */ +interface PhotoLayout { + x: number + y: number + rot: number + scale: number + z: number + w: number + caption: string +} + +interface SavedState { + bgIndex: number + decorations: El[] + photoLayout: Record +} +``` + +把 `loadInitial` 整个替换为(只恢复装饰与排版,不含图片字节;兼容旧结构): +```tsx +function loadInitial() { + let decorations: El[] = [] + let photoLayout: Record = {} + let bgIndex = 0 + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (raw) { + const data = JSON.parse(raw) as Partial & { els?: El[] } + if (typeof data.bgIndex === 'number') bgIndex = data.bgIndex + if (Array.isArray(data.decorations)) { + decorations = data.decorations + } else if (Array.isArray(data.els)) { + // 旧结构(照片带 base64 存本地):只保留装饰,丢弃旧照片。 + decorations = data.els.filter((e) => e.type !== 'photo') + } + if (data.photoLayout && typeof data.photoLayout === 'object') { + photoLayout = data.photoLayout + } + } + } catch { + /* 损坏的存档当作空墙 */ + } + const nextId = decorations.reduce((m, e) => Math.max(m, e.id), 0) + 1 + const maxZ = Math.max(10, ...decorations.map((e) => e.z || 10)) + return { decorations, photoLayout, bgIndex, nextId, maxZ } +} +``` + +- [ ] **Step 3: 初始化 state 与排版 ref** + +把组件内: +```tsx + const initial = useMemo(() => loadInitial(), []) + + const [els, setEls] = useState(initial.els) +``` +改为: +```tsx + const initial = useMemo(() => loadInitial(), []) + + const [els, setEls] = useState(initial.decorations) + // 每张图的本地排版(按服务器图片 id)。runtime 维护,保存时连同装饰一起落盘。 + const layoutRef = useRef>(initial.photoLayout) + // 本组件创建的所有 objectURL,卸载时统一释放。 + const objUrls = useRef([]) +``` + +- [ ] **Step 4: 改自动保存 effect 写新结构** + +把保存 effect 内 `localStorage.setItem(...)` 那行替换为(拆分装饰与照片排版): +```tsx + const decorations = els.filter((e) => e.type !== 'photo') + const photoLayout: Record = {} + for (const e of els) { + if (e.type === 'photo' && e.photoId) { + photoLayout[e.photoId] = { + x: e.x, y: e.y, rot: e.rot, scale: e.scale, z: e.z, + w: e.w ?? 160, caption: e.caption ?? '', + } + } + } + layoutRef.current = photoLayout + localStorage.setItem(STORAGE_KEY, JSON.stringify({ bgIndex, decorations, photoLayout })) +``` +(该 effect 的依赖数组 `[els, bgIndex, showToast]` 不变。) + +- [ ] **Step 5: 挂载时从服务器加载图片** + +在组件内(`useEffect` 群里,紧接自动保存 effect 之后)新增加载 effect: +```tsx + // 登录后从服务器拉本用户图片:取字节为 objectURL,套用本地排版或自动散落。 + useEffect(() => { + if (!token) return + let alive = true + ;(async () => { + try { + const { items } = await listPhotos(token) + for (const item of items) { + let url: string + try { + url = await fetchPhotoObjectUrl(token, item.id) + } catch { + continue + } + if (!alive) { + URL.revokeObjectURL(url) + return + } + objUrls.current.push(url) + const layout = layoutRef.current[item.id] + // 取自然尺寸算宽高比;无本地排版时随机散落。 + const dims = await new Promise<{ w: number; h: number }>((resolve) => { + const img = new Image() + img.onload = () => resolve({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 }) + img.onerror = () => resolve({ w: 1, h: 1 }) + img.src = url + }) + if (!alive) return + const ratio = dims.h / dims.w + const roll = Math.random() + const fix: El['fix'] = roll < 0.34 ? 'tape' : roll < 0.67 ? 'pin' : 'tape2' + const pos = spawnPos(40) + const base: El = layout + ? { + id: idRef.current++, type: 'photo', src: url, photoId: item.id, + w: layout.w, h: Math.round(layout.w * ratio), + x: layout.x, y: layout.y, rot: layout.rot, scale: layout.scale, + z: layout.z, caption: layout.caption, + fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3), + } + : { + id: idRef.current++, type: 'photo', src: url, photoId: item.id, + w: 170, h: Math.round(170 * ratio), + x: pos.x, y: pos.y, rot: rand(-15, 15), scale: 1, z: ++zRef.current, + caption: '', fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3), + } + if (layout) zRef.current = Math.max(zRef.current, layout.z) + setEls((prev) => [...prev, base]) + } + } catch (e) { + if (alive) showToast('图片加载失败(请重新登录或检查网络)') + } + })() + return () => { + alive = false + } + // 仅在挂载/换 token 时跑一次 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]) + + // 卸载时释放所有 objectURL + useEffect(() => { + return () => { + for (const u of objUrls.current) URL.revokeObjectURL(u) + } + }, []) +``` +并在文件顶部 `import` 区加: +```tsx +import { listPhotos, uploadPhoto, deletePhoto, fetchPhotoObjectUrl, type UploadError } from '../api/photos' +``` + +- [ ] **Step 6: 上传改走服务器** + +把现有 `handleFiles` 的回调体中"读出后直接 `makePhoto`"的逻辑改为先上传。具体:将 `reader.onload` 内: +```tsx + const img = new Image() + img.onload = () => { + const w = randInt(140, 200) + const h = Math.round(w * (img.height / img.width)) + makePhoto(src, w, h) + } + img.src = src +``` +替换为: +```tsx + // 满 10 张直接拦下 + const count = elsRef.current.filter((e) => e.type === 'photo').length + if (count >= 10) { + showToast('每位用户最多 10 张照片') + return + } + uploadPhoto(token, src) + .then((id) => { + const img = new Image() + img.onload = () => { + const w = randInt(140, 200) + const h = Math.round(w * (img.height / img.width)) + const pos = spawnPos(40) + const roll = Math.random() + const fix: El['fix'] = roll < 0.34 ? 'tape' : roll < 0.67 ? 'pin' : 'tape2' + addEl({ + type: 'photo', src, photoId: id, w, h, + x: pos.x, y: pos.y, rot: rand(-15, 15), scale: 1, + caption: '', fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3), + }) + } + img.src = src + }) + .catch((err: Error) => { + const reason = err.message as UploadError + showToast( + reason === 'full' ? '每位用户最多 10 张照片' + : reason === 'too-large' ? '图片过大(上限 8MB)' + : reason === 'bad-type' ? '不支持的图片格式' + : '上传失败', + ) + }) +``` +(`makePhoto` 不再被 `handleFiles` 使用;若无其它引用可删除该函数及其依赖项。`addEl` 已存在。) + +- [ ] **Step 7: 删除改走服务器** + +把 `removeEl` 替换为: +```tsx + const removeEl = (id: number) => { + const target = elsRef.current.find((e) => e.id === id) + if (target?.type === 'photo' && target.photoId) { + deletePhoto(token, target.photoId).catch(() => {}) + delete layoutRef.current[target.photoId] + if (target.src?.startsWith('blob:')) { + URL.revokeObjectURL(target.src) + objUrls.current = objUrls.current.filter((u) => u !== target.src) + } + } + mutate((p) => p.filter((e) => e.id !== id)) + nodeRefs.current.delete(id) + setMenu(null) + } +``` + +- [ ] **Step 8: 类型检查 + lint** + +Run: `cd Client && npm run build 2>&1 | tail -8 && npm run lint 2>&1 | tail -8` +Expected: build 成功;新增/改动文件无 lint 报错(`CursorFollower.tsx` 既有告警不计)。 + +- [ ] **Step 9: 提交** + +```bash +git add Client/src/components/PhotoWallPage.tsx +git commit -m "feat(photo-wall): load/upload/delete photos via server, split local layout + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 7: 右键菜单调整幅度加大 + 角度/比例预设 + +**Files:** +- Modify: `Client/src/components/PhotoWallPage.tsx` + +**Interfaces:** +- Consumes: 现有 `mutate`、`rotate`、`scaleEl`、`resizePhoto`、`menu` 状态。 + +- [ ] **Step 1: 默认步进调大** + +在右键菜单 JSX 里,把旋转项的 `±5` 改为 `±15`,照片放大/缩小 `±10` 改为 `±30`,其它元素 scale `±0.12` 改为 `±0.25`。即: +- `rotate(menu.id, 5)` → `rotate(menu.id, 15)`,文案 `旋转 +15°`;`-5` → `-15`。 +- `resizePhoto(menu.id, 10)` → `resizePhoto(menu.id, 30)`;`-10` → `-30`。 +- `scaleEl(menu.id, 0.12)` → `scaleEl(menu.id, 0.25)`;`-0.12` → `-0.25`。 + +- [ ] **Step 2: 加"绝对设定"辅助函数** + +在其它菜单动作(`recolorNote` 附近)加: +```tsx + const setRotation = (id: number, deg: number) => + mutate((p) => p.map((e) => (e.id === id ? { ...e, rot: deg } : e))) + const setScale = (id: number, s: number) => + mutate((p) => p.map((e) => (e.id === id ? { ...e, scale: s } : e))) +``` + +- [ ] **Step 3: 菜单加预设行** + +在右键菜单 JSX 里,"移到最前/最后"之后、删除之前,加两组预设(沿用 `.pw-colorrow` 的横排样式做成小药丸按钮): +```tsx +
+
+ 旋转 + {[0, 15, 30, 45, 90].map((d) => ( + + ))} +
+
+ 比例 + {[0.5, 0.75, 1, 1.5, 2].map((s) => ( + + ))} +
+``` + +- [ ] **Step 4: 加预设样式** + +在 `Client/src/index.css` 的照片墙段落里(`.pw-colorrow` 附近)加: +```css +.pw-preset { display: flex; align-items: center; gap: 5px; padding: 5px 8px; flex-wrap: wrap; } +.pw-preset-label { font-size: 12px; color: #888; width: 28px; } +.pw-chip { + border: 1px solid #e0e0e0; background: #fff; border-radius: 12px; + padding: 3px 9px; font-size: 12px; cursor: pointer; color: #444; +} +.pw-chip:hover { background: #f5f5f5; } +``` + +- [ ] **Step 5: 类型检查 + lint + 手动冒烟** + +Run: `cd Client && npm run build 2>&1 | tail -5 && npm run lint 2>&1 | tail -5` +Expected: 通过。 +手动:`npm run dev` → `localhost:5173/#photo` 登录(需本地后端,见 Task 4 Step 5 起服方式,加 `DATABASE_URL` 留空即用内置 `cc`)→ 右键照片,确认旋转/比例预设生效、步进更大。 + +- [ ] **Step 6: 提交** + +```bash +git add Client/src/components/PhotoWallPage.tsx Client/src/index.css +git commit -m "feat(photo-wall): larger adjust steps + rotation/scale presets in context menu + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 8: 部署(后端全量)+ 配置 + 文档 + +**Files:** +- Modify: `docs/photo-wall.md`、`docs/superpowers/plans/2026-06-18-deploy.md` +- Remote: `weather.env`(加 `JWT_SECRET`)、重启 RustServer + +**Interfaces:** +- Consumes: 全部前述任务。 + +- [ ] **Step 1: 服务器加 JWT_SECRET** + +```bash +SECRET=$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n') +ssh root@8.130.143.54 "grep -q '^JWT_SECRET=' /opt/internet-project/weather.env || echo 'JWT_SECRET=$SECRET' >> /opt/internet-project/weather.env" +ssh root@8.130.143.54 "grep -c JWT_SECRET /opt/internet-project/weather.env" +``` +Expected: 末行打印 `1`。 + +- [ ] **Step 2: 构建前端 + 交叉编译后端** + +```bash +cd /home/cc/InternetProject/Client && npm run build 2>&1 | tail -3 +cd /home/cc/InternetProject/Server && cargo build --release 2>&1 | tail -3 +file target/release/RustServer +``` +Expected: 前端 `✓ built`;后端 `Finished release`;`ELF 64-bit ... x86-64`。 + +- [ ] **Step 3: 上传二进制 + 前端静态** + +```bash +cd /home/cc/InternetProject +scp Server/target/release/RustServer root@8.130.143.54:/opt/internet-project/RustServer.new +ssh root@8.130.143.54 "rm -rf /opt/internet-project/static/assets && mkdir -p /opt/internet-project/static" +scp -r Client/dist/* root@8.130.143.54:/opt/internet-project/static/ +``` + +- [ ] **Step 4: 用 restart 脚本切换二进制并重启** + +```bash +ssh root@8.130.143.54 "cat /opt/internet-project/restart_rust.sh" +# 若脚本期望新二进制名/路径,按其约定放置;否则手动替换 + 重启: +ssh root@8.130.143.54 "cd /opt/internet-project && pkill RustServer; sleep 1; cp RustServer RustServer.bak; mv RustServer.new RustServer; chmod +x RustServer; nohup ./RustServer > server.log 2>&1 & sleep 1; tail -3 server.log" +``` +Expected: 日志出现 `backend listening on http://0.0.0.0:8548`。 + +- [ ] **Step 5: 线上冒烟** + +```bash +TOKEN=$(curl -s -X POST http://8.130.143.54:8548/api/web/login -H 'Content-Type: application/json' \ + -d '{"username":"cc","password":"192118Lht"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4) +curl -s -o /dev/null -w 'no-token -> %{http_code}\n' http://8.130.143.54:8548/api/web/photos +curl -s -H "Authorization: Bearer $TOKEN" http://8.130.143.54:8548/api/web/photos +``` +Expected: `no-token -> 401`;带 token 返回 `{"items":[...],"max":10}`。 + +- [ ] **Step 6: 更新文档** + +编辑 `docs/photo-wall.md`:把"持久化"一节改为说明"图片存服务器(按用户≤10,私有,JWT 鉴权),排版/装饰存浏览器",并补接口与存储路径(`/opt/internet-project/photo-wall/<用户>/`)。 +编辑 `docs/superpowers/plans/2026-06-18-deploy.md`:在运维参考里注明"照片墙服务器存图引入了 `PHOTO_DIR` 与 `JWT_SECRET`;该功能改了后端,需全量重部署(非 frontend-only),上线后用户需重新登录一次"。 + +- [ ] **Step 7: 提交并合回** + +```bash +cd /home/cc/InternetProject +git add docs/photo-wall.md docs/superpowers/plans/2026-06-18-deploy.md +git commit -m "docs: photo wall server-side image storage + deploy notes + +Co-Authored-By: Claude Opus 4.8 " +git checkout main && git merge --no-ff feat/photo-wall-server-storage +``` + +- [ ] **Step 8: 手动端到端验证** + +浏览器 `http://8.130.143.54:8548/#photo` → 重新登录 → 上传几张图 → 拖动/旋转/缩放(用新预设)→ 刷新页面确认图片仍在、排版保留 → 换个浏览器登录确认图片在、排版回到自动散落。 + +--- + +## Self-Review 记录 + +- **Spec 覆盖**:职责切分(Task 6) · JWT(Task 1) · 接口与存储(Task 2/4) · 私有读取鉴权(Task 3/4) · 前端数据流(Task 5/6) · 右键菜单改进(Task 7) · 部署/CORS/上限/JWT_SECRET(Task 4/8)。均有对应任务。 +- **占位符**:无 TBD/TODO;每个代码步给出完整代码。 +- **类型一致**:`PhotoStore`/`SaveError`/`MAX_PHOTOS`(Task 2) 在 Task 4 同名使用;`AuthUser`(Task 3) 在 Task 4 使用;`issue_token(&str)`(Task 1) 在 Task 4 调用;前端 `photos.ts` 导出名(Task 5) 在 Task 6 import 一致;`PhotoLayout`/`SavedState`(Task 6) 自洽。 +- **axum 0.8**:路径用 `{id}`、`FromRequestParts` 无 `async_trait`,已在 Global Constraints 与 Task 3/4 明确。 diff --git a/docs/superpowers/specs/2026-06-24-photo-wall-server-storage-design.md b/docs/superpowers/specs/2026-06-24-photo-wall-server-storage-design.md new file mode 100644 index 0000000..2ea9d4a --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-photo-wall-server-storage-design.md @@ -0,0 +1,94 @@ +# 设计:照片墙服务器存图(按用户、最多 10 张) + +> 把照片墙从「纯前端 localStorage 存图」升级为「图片存服务器、按登录用户隔离」。 +> 排版与装饰仍存浏览器。前置阅读:[photo-wall.md](../../photo-wall.md)、 +> [auth-login-gate.md](../../auth-login-gate.md)。 + +## 1. 目标与职责切分 + +| 存哪 | 内容 | 跨设备 | +|---|---|---| +| **服务器**(`PHOTO_DIR/<用户>/`,≤10 张/用户) | 图片字节本身 | ✅ 任意设备登录可见 | +| **浏览器 localStorage** | 背景、装饰(便签/胶带/贴纸/印章/手绘)、**每张图的排版覆盖**(位置/旋转/缩放/层级/标注/宽度,按图片 id 关联) | ❌ 各浏览器独立 | + +**加载逻辑**:登录后拉取本用户图片列表 → 每张取字节显示 → 若 localStorage 有该 id +的排版记录则套用,否则**自动散落排版**(沿用软木板随机摆放风格,按数量铺开避免重叠)。 +图片严格私有:读取必须带登录凭证,后端校验身份后才返回字节。 + +## 2. 鉴权(JWT) + +现状:登录返回的 `token` 是时间戳 SHA256,**无任何接口校验**。本设计赋予其真实含义。 + +- `auth::issue_token(username)` 改为签发 **HS256 JWT**(`jsonwebtoken`,已是依赖): + claims `sub = 用户名`、`exp = 现在 + 30 天`,密钥取环境变量 `JWT_SECRET`。 + - 未设 `JWT_SECRET` → 用内置开发兜底密钥并打印警告(仅本地开发用;生产必须设)。 +- 新增 `auth::verify_token(token) -> Option`(返回用户名)。 +- 新增 axum 提取器 `AuthUser(String)`:读 `Authorization: Bearer `,校验通过产出 + 用户名,否则 `401`。受保护接口用它。 +- **影响**:token 格式变化,上线后现有登录态失效,需**重新登录一次**。 + +## 3. 后端接口与存储 + +存储布局: +- 根目录 `PHOTO_DIR`(默认 `photo-wall`,相对进程 cwd;线上 cwd 为 + `/opt/internet-project/`,即 `/opt/internet-project/photo-wall/`)。 +- 每用户一子目录,目录名 = 用户名经字符集白名单 `[A-Za-z0-9_-]` 校验后使用; + 含非法字符的用户名拒绝(`400`)。 +- 每图文件名 `<16字节随机 hex>.`,`ext ∈ {jpg,png,gif,webp}`。 + **无独立元数据存储**:列目录即得列表(无需数据库)。 +- **图片 `id` 即完整文件名**(含扩展名,如 `a1b2…f9.jpg`)。`GET /:id` 据扩展名 + 决定 `Content-Type`。`id` 仅允许 `[0-9a-f]+\.(jpg|png|gif|webp)`,否则 `404`。 + +接口(全部经 `AuthUser`,挂在 web 路由): + +| 方法 | 路径 | 行为 | 返回 | +|---|---|---|---| +| GET | `/api/web/photos` | 列出当前用户图片 | `{ items: [{ id }], max: 10 }` | +| POST | `/api/web/photos` | 上传一张(body `{ dataUrl }` base64 data URL) | `201 { id }` | +| GET | `/api/web/photos/:id` | 流式返回字节(属主校验,文件须在该用户目录) | `200` + `Content-Type` | +| DELETE | `/api/web/photos/:id` | 删除一张 | `204` | + +约束与错误: +- 满 10 张再传 → `409`(前端也禁用上传按钮)。 +- 单图解码后 > 8 MB → `413`。 +- 类型不在白名单 → `415`。 +- `:id` 含路径分隔/`..` 或文件不存在 → `404`(防目录穿越;id 仅允许 hex+扩展名)。 +- 放宽对应路由的 axum `DefaultBodyLimit` 至 ~16 MB(容纳 base64)。 + +## 4. 前端数据流 + +- `useAuth`:在 `Auth` 接口里暴露 `token`(当前已存 session,仅未对外返回)。 + `App.tsx` 把 `auth.token` 传给 `PhotoWallPage`。 +- 新增 `Client/src/api/photos.ts`:`listPhotos(token)`、`uploadPhoto(token, dataUrl)`、 + `deletePhoto(token, id)`、`fetchPhotoObjectUrl(token, id)`(带 `Bearer` 头 fetch → + `blob` → `URL.createObjectURL`)。 +- `PhotoWallPage`: + - 挂载时:`listPhotos` → 并发取每张 objectUrl → 建照片元素;按图片 id 从 + localStorage 合并排版,缺失则自动散落(需图片自然宽高算比例,载入后取 + `naturalWidth/Height`)。 + - 持久化结构改为 + `{ bgIndex, decorations: El[], photoLayout: { [id]: {x,y,rot,scale,z,caption,w} } }` + ——**照片字节不再进 localStorage**。读到旧结构(照片带 base64)时只取其装饰部分。 + - 上传:选图 → 读 dataURL → `uploadPhoto` → 成功后插入照片元素(自动摆放)。满 10 禁用。 + - 删除(右键):`deletePhoto` → 移除元素 + 清该 id 本地排版 + `revokeObjectURL`。 + - 卸载时释放所有 objectUrl。 +- **右键菜单改进**(用户反馈:当前调整幅度太小): + - 默认步进调大:旋转 ±15°、照片放大/缩小 ±30px、其它元素 scale ±0.25。 + - 新增「旋转角度」子项:预设角度(如 0/±15/±30/±45/90)直接设定。 + - 新增「放大比例」子项:预设比例(如 0.5×/0.75×/1×/1.5×/2×)直接设定。 + +## 5. 部署与运维 + +- 这是**后端改动** → 全量重部署:重编 Rust → 传二进制 → `restart_rust.sh` 重启 + (非 frontend-only)。 +- 服务器 `weather.env` 增加 `JWT_SECRET=<随机串>`;可选 `PHOTO_DIR`(默认即对)。 +- 首次上传自动创建 `photo-wall/<用户>/`。 +- CORS(仅影响 dev 跨源):在 `routes/web.rs` 的 `CorsLayer` 增加 `DELETE` 方法与 + `Authorization` 请求头;生产同源不受影响。 +- 上线后提示用户**重新登录一次**。 + +## 6. 不做(YAGNI) + +- 服务器端图片压缩/缩放(小规模,35GB 盘够用;前端显示尺寸小,将来需要再加)。 +- 排版/装饰上云(明确只存浏览器)。 +- 图片重命名/相册/分享链接等。