From d31a527f6b6bc965a94de9c0b0a8f20f767a8c22 Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 23 Mar 2026 16:11:15 +0800 Subject: [PATCH 01/15] feat(fonts): add workspace font pipeline and resources tabs Introduce end-to-end font management with upload, async PBF glyph generation, publish/unpublish, and public glyph serving, while adding a resources section in the frontend with fonts as the first managed asset. --- Cargo.lock | 231 +++++- backend/Cargo.toml | 1 + backend/src/db.rs | 31 + backend/src/font_handlers.rs | 609 ++++++++++++++++ backend/src/font_processor.rs | 164 +++++ backend/src/lib.rs | 2 + backend/src/routes.rs | 16 +- frontend/package-lock.json | 721 +++++++++++++++++++ frontend/package.json | 3 + frontend/src/App.jsx | 182 +++-- frontend/src/FontsPanel.jsx | 417 +++++++++++ frontend/src/IconsPanel.jsx | 8 + frontend/src/ResourcesPanel.jsx | 72 ++ frontend/src/StylesPanel.jsx | 8 + frontend/src/api.js | 71 ++ frontend/src/i18n/locales/en.json | 47 ++ frontend/src/i18n/locales/zh.json | 47 ++ frontend/tests/unit/resources-panel.test.jsx | 86 +++ frontend/vitest.config.js | 2 +- 19 files changed, 2628 insertions(+), 90 deletions(-) create mode 100644 backend/src/font_handlers.rs create mode 100644 backend/src/font_processor.rs create mode 100644 frontend/src/FontsPanel.jsx create mode 100644 frontend/src/IconsPanel.jsx create mode 100644 frontend/src/ResourcesPanel.jsx create mode 100644 frontend/src/StylesPanel.jsx create mode 100644 frontend/tests/unit/resources-panel.test.jsx diff --git a/Cargo.lock b/Cargo.lock index b1b72697..a8a0a923 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,7 +122,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -133,7 +133,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -479,6 +479,7 @@ dependencies = [ "lazy_static", "moka", "mvt-reader", + "pbf_font_tools", "rand 0.8.5", "regex", "rusqlite", @@ -1035,7 +1036,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1079,6 +1080,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1138,6 +1145,28 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "freetype-rs" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d228d6de56c90dd7585341f341849441b3490180c62d27133e525eb726809b4" +dependencies = [ + "bitflags 2.10.0", + "freetype-sys", + "libc", +] + +[[package]] +name = "freetype-sys" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eab537ce43cab850c64b4cdc390ce7e4f47f877485ddc323208e268280c308ae" +dependencies = [ + "cc", + "libz-sys", + "pkg-config", +] + [[package]] name = "funty" version = "2.0.0" @@ -1152,6 +1181,7 @@ checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", + "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -1174,6 +1204,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.31" @@ -1209,6 +1250,7 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1837,6 +1879,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libz-sys" +version = "1.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52f4c29e2a68ac30c9087e1b772dc9f44a2b66ed44edf2266cf2be9b03dafc1" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1991,6 +2045,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "mvt-reader" version = "2.3.0" @@ -1999,7 +2059,7 @@ checksum = "5057362d82a08bc3c84e420c42939463aff5c3e21a91a41d8b7af94c8f061189" dependencies = [ "geo-types", "num-traits", - "prost", + "prost 0.13.5", ] [[package]] @@ -2008,7 +2068,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2185,6 +2245,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pbf_font_tools" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f621f25023f31c597de8b20a44d511d97eacea1943ccbcb709e6ed27156c144d" +dependencies = [ + "futures", + "prost 0.14.3", + "prost-build", + "protoc-bin-vendored", + "sdf_glyph_renderer", + "thiserror", + "tokio", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -2201,6 +2276,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.13.1" @@ -2350,7 +2436,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.13.5", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive 0.14.3", +] + +[[package]] +name = "prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost 0.14.3", + "prost-types", + "regex", + "syn 2.0.117", + "tempfile", ] [[package]] @@ -2366,6 +2481,92 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost 0.14.3", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" + [[package]] name = "ptr_meta" version = "0.1.4" @@ -2438,7 +2639,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -2726,7 +2927,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2782,6 +2983,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdf_glyph_renderer" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "672e58ce949c46be42fae549e86cc32c869a292fb40913d5f985decb45c5bde3" +dependencies = [ + "freetype-rs", + "thiserror", +] + [[package]] name = "seahash" version = "4.1.0" @@ -3128,10 +3339,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ccdcc198..6ee251f0 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -43,6 +43,7 @@ aes-gcm = "0.10" base64 = "0.22" rand = "0.8" sha2 = "0.10" +pbf_font_tools = { version = "3.0", features = ["freetype"] } [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_System_Console"] } diff --git a/backend/src/db.rs b/backend/src/db.rs index f8b8e2f0..fbfef421 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -531,6 +531,37 @@ pub fn init_database(db_path: &Path) -> duckdb::Connection { ) .expect("Failed to create workspace tables"); + conn.execute_batch( + r" + CREATE TABLE IF NOT EXISTS fonts ( + id VARCHAR PRIMARY KEY, + workspace_id VARCHAR NOT NULL REFERENCES workspaces(id), + name VARCHAR NOT NULL, + fontstack VARCHAR NOT NULL, + family VARCHAR, + style VARCHAR, + original_path VARCHAR NOT NULL, + glyphs_path VARCHAR NOT NULL, + glyph_count INTEGER, + start_cp INTEGER, + end_cp INTEGER, + status VARCHAR NOT NULL DEFAULT 'processing', + error VARCHAR, + is_public BOOLEAN DEFAULT FALSE, + slug VARCHAR UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + published_at TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_fonts_workspace + ON fonts(workspace_id); + + CREATE INDEX IF NOT EXISTS idx_fonts_slug + ON fonts(slug); + ", + ) + .expect("Failed to create fonts table"); + ensure_workspace_schema_and_backfill(&conn); conn diff --git a/backend/src/font_handlers.rs b/backend/src/font_handlers.rs new file mode 100644 index 00000000..1f0bc991 --- /dev/null +++ b/backend/src/font_handlers.rs @@ -0,0 +1,609 @@ +use axum::{ + extract::{Multipart, Path as AxumPath, State}, + http::{header, StatusCode}, + response::IntoResponse, + Json, +}; +use axum_login::AuthSession; +use chrono::Utc; +use duckdb::OptionalExt; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use tokio::{ + fs, + io::{AsyncWriteExt, BufWriter}, +}; +use tracing::{error, info, warn}; +use uuid::Uuid; + +use crate::{ + font_processor::{process_font, FontMetadata}, + http_errors::{bad_request, internal_error}, + models::ErrorResponse, + AppState, AuthBackend, +}; + +fn create_id() -> String { + Uuid::new_v4().to_string() +} + +async fn get_workspace_id( + auth_session: &AuthSession, + state: &AppState, +) -> Result)> { + match &auth_session.user { + Some(user) => { + let workspace_id = user.current_workspace_id.clone().ok_or_else(|| { + ( + StatusCode::CONFLICT, + Json(ErrorResponse { + error: "No active workspace available, please switch workspace".to_string(), + }), + ) + })?; + + let conn = state.db.lock().await; + let active_workspace: Option = conn + .query_row( + r" + SELECT w.id + FROM workspaces w + JOIN workspace_members wm ON w.id = wm.workspace_id + WHERE w.id = ? AND wm.user_id = ? AND w.deleted_at IS NULL + LIMIT 1 + ", + duckdb::params![&workspace_id, &user.id], + |row| row.get(0), + ) + .optional() + .map_err(internal_error)?; + drop(conn); + + if active_workspace.is_none() { + return Err(( + StatusCode::CONFLICT, + Json(ErrorResponse { + error: + "Current workspace is archived or inaccessible, please switch workspace" + .to_string(), + }), + )); + } + + Ok(workspace_id) + } + None => Err(( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse { + error: "Not authenticated".to_string(), + }), + )), + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FontItem { + pub id: String, + pub name: String, + pub fontstack: String, + pub family: Option, + pub style: Option, + pub glyph_count: Option, + pub start_cp: Option, + pub end_cp: Option, + pub status: String, + pub error: Option, + pub is_public: bool, + pub slug: Option, + pub created_at: String, + pub published_at: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FontUploadResponse { + pub id: String, + pub status: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PublishFontRequest { + pub slug: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PublishFontResponse { + pub url: String, + pub slug: String, + pub is_public: bool, +} + +pub async fn upload_font( + auth_session: AuthSession, + State(state): State, + mut multipart: Multipart, +) -> Result)> { + let workspace_id = get_workspace_id(&auth_session, &state).await?; + + let mut field = loop { + let next = multipart.next_field().await.map_err(|e| { + let message = format!("Invalid multipart form: {e}"); + bad_request(&message) + })?; + match next { + Some(field) if field.name() == Some("file") => break field, + Some(_) => continue, + None => return Err(bad_request("No file uploaded")), + } + }; + + let original_name = field + .file_name() + .map(|name| name.to_string()) + .ok_or_else(|| bad_request("Missing file name"))?; + + let safe_name = Path::new(&original_name) + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| bad_request("Invalid file name"))? + .to_string(); + + let ext = Path::new(&safe_name) + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_lowercase()) + .ok_or_else(|| bad_request("Unsupported file type. Use .ttf or .otf"))?; + + if !["ttf", "otf"].contains(&ext.as_str()) { + return Err(bad_request("Unsupported file type. Use .ttf or .otf")); + } + + let font_id = create_id(); + info!(font_id = %font_id, filename = %safe_name, "Font upload started"); + + let fonts_dir = state.upload_dir.join("fonts").join(&font_id); + fs::create_dir_all(&fonts_dir) + .await + .map_err(internal_error)?; + + let original_path = fonts_dir.join("original"); + let mut file = BufWriter::new( + fs::File::create(&original_path) + .await + .map_err(internal_error)?, + ); + + let mut size: u64 = 0; + let max_size = *state.max_size.read().await; + let max_size_label = state.max_size_label.read().await.clone(); + while let Some(chunk) = field.chunk().await.map_err(internal_error)? { + size = size.saturating_add(chunk.len() as u64); + if size > max_size { + drop(file); + let _ = fs::remove_file(&original_path).await; + let _ = fs::remove_dir(&fonts_dir).await; + let message = format!("File too large (max {})", max_size_label); + return Err(bad_request(&message)); + } + file.write_all(&chunk).await.map_err(internal_error)?; + } + file.flush().await.map_err(internal_error)?; + file.get_ref().sync_all().await.map_err(internal_error)?; + drop(file); + + let glyphs_dir = fonts_dir.join("glyphs"); + let original_rel = format!("./uploads/fonts/{}/original", &font_id); + let glyphs_rel = format!("./uploads/fonts/{}/glyphs", &font_id); + + let created_at = Utc::now().to_rfc3339(); + let display_name = Path::new(&safe_name) + .file_stem() + .and_then(|n| n.to_str()) + .unwrap_or(&safe_name) + .to_string(); + + let conn = state.db.lock().await; + conn.execute( + "INSERT INTO fonts (id, workspace_id, name, fontstack, original_path, glyphs_path, status, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + duckdb::params![ + &font_id, + &workspace_id, + &display_name, + &display_name, + &original_rel, + &glyphs_rel, + "processing", + &created_at, + ], + ) + .map_err(internal_error)?; + drop(conn); + + let state_clone = state.clone(); + let font_id_clone = font_id.clone(); + let original_path_clone = original_path.clone(); + let glyphs_dir_clone = glyphs_dir.clone(); + + tokio::spawn(async move { + let original = original_path_clone.clone(); + let glyphs = glyphs_dir_clone.clone(); + let font_id_for_blocking = font_id_clone.clone(); + + let result = tokio::task::spawn_blocking(move || process_font(&original, &glyphs)).await; + + match result { + Ok(Ok((metadata, ranges))) => { + if let Err(e) = + update_font_ready(&state_clone, &font_id_clone, &metadata, ranges.len()).await + { + error!(font_id = %font_id_clone, error = %e, "Failed to update font status"); + } + } + Ok(Err(e)) => { + error!(font_id = %font_id_clone, error = %e, "Failed to process font"); + update_font_error(&state_clone, &font_id_clone, &e.to_string()).await; + } + Err(e) => { + error!(font_id = %font_id_for_blocking, error = %e, "Failed to spawn blocking task"); + update_font_error(&state_clone, &font_id_for_blocking, &e.to_string()).await; + } + } + }); + + Ok(Json(FontUploadResponse { + id: font_id, + status: "processing".to_string(), + })) +} + +async fn update_font_error(state: &AppState, font_id: &str, error: &str) { + let conn = state.db.lock().await; + let _ = conn.execute( + "UPDATE fonts SET status = 'failed', error = ? WHERE id = ?", + duckdb::params![error, font_id], + ); +} + +async fn update_font_ready( + state: &AppState, + font_id: &str, + metadata: &FontMetadata, + _range_count: usize, +) -> Result<(), String> { + let conn = state.db.lock().await; + conn.execute( + "UPDATE fonts SET status = 'ready', fontstack = ?, family = ?, style = ?, glyph_count = ?, start_cp = ?, end_cp = ?, error = NULL WHERE id = ?", + duckdb::params![ + &metadata.fontstack, + &metadata.family, + &metadata.style, + metadata.glyph_count as i32, + metadata.start_cp as i32, + metadata.end_cp as i32, + font_id, + ], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +pub async fn list_fonts( + auth_session: AuthSession, + State(state): State, +) -> Result)> { + let workspace_id = get_workspace_id(&auth_session, &state).await?; + + let conn = state.db.lock().await; + let mut stmt = conn + .prepare( + "SELECT id, name, fontstack, family, style, glyph_count, start_cp, end_cp, status, error, is_public, slug, created_at, published_at + FROM fonts + WHERE workspace_id = ? + ORDER BY created_at DESC", + ) + .map_err(internal_error)?; + + let fonts_iter = stmt + .query_map(duckdb::params![&workspace_id], |row| { + let created_at: chrono::NaiveDateTime = row.get(12)?; + let published_at: Option = row.get(13)?; + Ok(FontItem { + id: row.get(0)?, + name: row.get(1)?, + fontstack: row.get(2)?, + family: row.get(3)?, + style: row.get(4)?, + glyph_count: row.get(5)?, + start_cp: row.get(6)?, + end_cp: row.get(7)?, + status: row.get(8)?, + error: row.get(9)?, + is_public: row.get(10)?, + slug: row.get(11)?, + created_at: created_at.and_utc().to_rfc3339(), + published_at: published_at.map(|t| t.and_utc().to_rfc3339()), + }) + }) + .map_err(internal_error)?; + + let mut fonts = Vec::new(); + for font in fonts_iter { + fonts.push(font.map_err(internal_error)?); + } + + drop(conn); + Ok(Json(fonts)) +} + +pub async fn get_font( + auth_session: AuthSession, + State(state): State, + AxumPath(id): AxumPath, +) -> Result)> { + let workspace_id = get_workspace_id(&auth_session, &state).await?; + + let conn = state.db.lock().await; + let font: Option = conn + .query_row( + "SELECT id, name, fontstack, family, style, glyph_count, start_cp, end_cp, status, error, is_public, slug, created_at, published_at + FROM fonts + WHERE id = ? AND workspace_id = ?", + duckdb::params![&id, &workspace_id], + |row| { + let created_at: chrono::NaiveDateTime = row.get(12)?; + let published_at: Option = row.get(13)?; + Ok(FontItem { + id: row.get(0)?, + name: row.get(1)?, + fontstack: row.get(2)?, + family: row.get(3)?, + style: row.get(4)?, + glyph_count: row.get(5)?, + start_cp: row.get(6)?, + end_cp: row.get(7)?, + status: row.get(8)?, + error: row.get(9)?, + is_public: row.get(10)?, + slug: row.get(11)?, + created_at: created_at.and_utc().to_rfc3339(), + published_at: published_at.map(|t| t.and_utc().to_rfc3339()), + }) + }, + ) + .optional() + .map_err(internal_error)?; + + drop(conn); + + match font { + Some(f) => Ok(Json(f)), + None => Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Font not found".to_string(), + }), + )), + } +} + +pub async fn delete_font( + auth_session: AuthSession, + State(state): State, + AxumPath(id): AxumPath, +) -> Result)> { + let workspace_id = get_workspace_id(&auth_session, &state).await?; + + let conn = state.db.lock().await; + + let glyphs_path: Option = conn + .query_row( + "SELECT glyphs_path FROM fonts WHERE id = ? AND workspace_id = ?", + duckdb::params![&id, &workspace_id], + |row| row.get(0), + ) + .optional() + .map_err(internal_error)?; + + let Some(glyphs_path) = glyphs_path else { + drop(conn); + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Font not found".to_string(), + }), + )); + }; + + conn.execute( + "DELETE FROM fonts WHERE id = ? AND workspace_id = ?", + duckdb::params![&id, &workspace_id], + ) + .map_err(internal_error)?; + + drop(conn); + + let glyphs_path = glyphs_path.trim_start_matches("./uploads/"); + let glyphs_dir = state.upload_dir.join(glyphs_path); + let font_dir = glyphs_dir + .parent() + .map_or(glyphs_dir.clone(), std::path::Path::to_path_buf); + if let Err(e) = tokio::fs::remove_dir_all(&font_dir).await { + warn!(font_dir = %font_dir.display(), error = %e, "Failed to remove font directory"); + } + + info!(font_id = %id, "Font deleted"); + Ok(StatusCode::NO_CONTENT) +} + +fn validate_slug(slug: &str) -> Result { + let slug = slug.trim().to_lowercase(); + if slug.is_empty() { + return Err("Slug cannot be empty".to_string()); + } + if slug.len() > 100 { + return Err("Slug too long (max 100 characters)".to_string()); + } + if !slug + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err("Slug can only contain letters, numbers, hyphens, and underscores".to_string()); + } + Ok(slug) +} + +pub async fn publish_font( + auth_session: AuthSession, + State(state): State, + AxumPath(id): AxumPath, + Json(req): Json, +) -> Result)> { + let workspace_id = get_workspace_id(&auth_session, &state).await?; + + let slug = match req.slug { + Some(s) => validate_slug(&s).map_err(|e| bad_request(&e))?, + None => validate_slug(&id).map_err(|e| bad_request(&e))?, + }; + + info!(font_id = %id, slug = %slug, workspace_id = %workspace_id, "Publish font request"); + + let conn = state.db.lock().await; + + let status: Option = conn + .query_row( + "SELECT status FROM fonts WHERE id = ? AND workspace_id = ?", + duckdb::params![&id, &workspace_id], + |row| row.get(0), + ) + .optional() + .map_err(internal_error)?; + + let Some(status) = status else { + drop(conn); + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Font not found".to_string(), + }), + )); + }; + + if status != "ready" { + drop(conn); + return Err(( + StatusCode::CONFLICT, + Json(ErrorResponse { + error: format!("Font is not ready for publishing (status: {})", status), + }), + )); + } + + let published_at = Utc::now().naive_utc(); + let result = conn.execute( + "UPDATE fonts SET is_public = TRUE, slug = ?, published_at = ? WHERE id = ? AND workspace_id = ?", + duckdb::params![&slug, published_at, &id, &workspace_id], + ); + + match result { + Ok(_) => { + drop(conn); + info!(font_id = %id, slug = %slug, "Font published"); + let url = format!("/fonts/{}/glyphs/{{fontstack}}/{{range}}.pbf", slug); + Ok(Json(PublishFontResponse { + url, + slug, + is_public: true, + })) + } + Err(e) => { + let err_msg = e.to_string(); + if err_msg.contains("UNIQUE") || err_msg.contains("unique") { + drop(conn); + Err(bad_request("Slug already in use")) + } else { + Err(internal_error(e)) + } + } + } +} + +pub async fn unpublish_font( + auth_session: AuthSession, + State(state): State, + AxumPath(id): AxumPath, +) -> Result)> { + let workspace_id = get_workspace_id(&auth_session, &state).await?; + + info!(font_id = %id, "Unpublish font request"); + + let conn = state.db.lock().await; + + let rows_affected = conn + .execute( + "UPDATE fonts SET is_public = FALSE, slug = NULL, published_at = NULL WHERE id = ? AND workspace_id = ? AND is_public = TRUE", + duckdb::params![&id, &workspace_id], + ) + .map_err(internal_error)?; + + drop(conn); + + if rows_affected == 0 { + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Font not published".to_string(), + }), + )); + } + + info!(font_id = %id, "Font unpublished"); + Ok(StatusCode::NO_CONTENT) +} + +pub async fn get_public_glyph( + State(state): State, + AxumPath((slug, _fontstack, range)): AxumPath<(String, String, String)>, +) -> Result)> { + let conn = state.db.lock().await; + + let glyphs_path: Option = conn + .query_row( + "SELECT glyphs_path FROM fonts WHERE slug = ? AND is_public = TRUE", + duckdb::params![&slug], + |row| row.get(0), + ) + .optional() + .map_err(internal_error)?; + + drop(conn); + + let Some(glyphs_path) = glyphs_path else { + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Font not found".to_string(), + }), + )); + }; + + let glyphs_path = glyphs_path.trim_start_matches("./uploads/"); + let normalized_range = range.strip_suffix(".pbf").unwrap_or(range.as_str()); + let pbf_path = state + .upload_dir + .join(glyphs_path) + .join(format!("{}.pbf", normalized_range)); + + match fs::read(&pbf_path).await { + Ok(data) => Ok(( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/x-protobuf")], + data, + )), + Err(_) => Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Glyph range not found".to_string(), + }), + )), + } +} diff --git a/backend/src/font_processor.rs b/backend/src/font_processor.rs new file mode 100644 index 00000000..f56b114a --- /dev/null +++ b/backend/src/font_processor.rs @@ -0,0 +1,164 @@ +use std::path::Path; + +use pbf_font_tools::freetype::{Face, Library}; +use pbf_font_tools::prost::Message; +use pbf_font_tools::{render_sdf_glyph, Fontstack, Glyphs}; +use thiserror::Error; +use tracing::debug; + +const MAX_UNICODE_CP: usize = 0xFFFF; +const CP_RANGE_SIZE: usize = 256; +const FONT_SIZE: usize = 24; +#[allow(clippy::cast_possible_wrap)] +const CHAR_HEIGHT: isize = (FONT_SIZE as isize) << 6; +const BUFFER_SIZE: usize = 3; +const RADIUS: usize = 8; +const CUTOFF: f64 = 0.25_f64; + +#[derive(Debug, Error)] +pub enum FontError { + #[error("FreeType error: {0}")] + FreeType(#[from] pbf_font_tools::freetype::Error), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Font has no family name")] + MissingFamilyName, + + #[error("Font has no glyphs")] + NoGlyphs, + + #[error("Failed to create directory: {0}")] + DirectoryCreation(String), +} + +#[derive(Debug, Clone)] +pub struct FontMetadata { + pub family: String, + pub style: Option, + pub fontstack: String, + pub glyph_count: usize, + pub start_cp: usize, + pub end_cp: usize, +} + +pub fn process_font( + font_path: &Path, + glyphs_dir: &Path, +) -> Result<(FontMetadata, Vec<(usize, usize)>), FontError> { + let lib = Library::init()?; + let mut face = lib.new_face(font_path, 0)?; + + let family = face.family_name().ok_or(FontError::MissingFamilyName)?; + let style = face.style_name(); + + let mut fontstack = family.clone(); + if let Some(ref s) = style { + fontstack.push(' '); + fontstack.push_str(s); + } + fontstack = fontstack + .replace(['/', ','], " ") + .split_whitespace() + .collect::>() + .join(" "); + + let (glyph_count, start_cp, end_cp) = count_glyphs(&mut face)?; + + if glyph_count == 0 { + return Err(FontError::NoGlyphs); + } + + let metadata = FontMetadata { + family, + style, + fontstack, + glyph_count, + start_cp, + end_cp, + }; + + std::fs::create_dir_all(glyphs_dir).map_err(|e| FontError::DirectoryCreation(e.to_string()))?; + + face.set_char_size(0, CHAR_HEIGHT, 0, 0)?; + + let mut ranges = Vec::new(); + + for range_start in (0..=MAX_UNICODE_CP).step_by(CP_RANGE_SIZE) { + let range_end = (range_start + CP_RANGE_SIZE - 1).min(MAX_UNICODE_CP); + + let pbf_data = generate_pbf_range(&mut face, range_start, range_end, &metadata.fontstack)?; + + if !pbf_data.is_empty() { + let filename = format!("{}-{}.pbf", range_start, range_end); + let pbf_path = glyphs_dir.join(&filename); + std::fs::write(&pbf_path, &pbf_data)?; + ranges.push((range_start, range_end)); + debug!( + range = format!("{}-{}", range_start, range_end), + path = %pbf_path.display(), + "Generated glyph range" + ); + } + } + + Ok((metadata, ranges)) +} + +fn count_glyphs(face: &mut Face) -> Result<(usize, usize, usize), FontError> { + let mut count = 0; + let mut first: Option = None; + let mut last = 0; + + for cp in 0..=MAX_UNICODE_CP { + if face.get_char_index(cp).is_some() { + count += 1; + if first.is_none() { + first = Some(cp); + } + last = cp; + } + } + + let start = first.unwrap_or(0); + Ok((count, start, last)) +} + +fn generate_pbf_range( + face: &mut Face, + start_cp: usize, + end_cp: usize, + fontstack_name: &str, +) -> Result, FontError> { + let mut stack = Fontstack { + name: fontstack_name.to_string(), + range: format!("{}-{}", start_cp, end_cp), + ..Default::default() + }; + + for cp in start_cp..=end_cp { + if face.get_char_index(cp).is_some() { + match render_sdf_glyph(face, cp as u32, BUFFER_SIZE, RADIUS, CUTOFF) { + Ok(glyph) => { + stack.glyphs.push(glyph); + } + Err(e) => { + debug!( + codepoint = cp, + error = %e, + "Failed to render glyph, skipping" + ); + } + } + } + } + + if stack.glyphs.is_empty() { + return Ok(Vec::new()); + } + + let mut glyphs = Glyphs::default(); + glyphs.stacks.push(stack); + Ok(glyphs.encode_to_vec()) +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 9ad42db4..1da49a33 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -8,6 +8,8 @@ mod auth_routes; mod config; mod crs; mod db; +mod font_handlers; +mod font_processor; mod handlers; mod http_errors; mod import; diff --git a/backend/src/routes.rs b/backend/src/routes.rs index 7aab1e69..bd7b9236 100644 --- a/backend/src/routes.rs +++ b/backend/src/routes.rs @@ -12,6 +12,10 @@ use tower_http::{ use tower_sessions::SessionManagerLayer; use crate::{ + font_handlers::{ + delete_font, get_font, get_public_glyph, list_fonts, publish_font, unpublish_font, + upload_font, + }, handlers::{ check_is_initialized, get_feature_properties, get_file_schema, get_preview_meta, get_public_url, get_settings, health_check, list_files, publish_file, unpublish_file, @@ -79,7 +83,11 @@ fn build_api_router_with_auth(state: AppState, with_auth: bool) -> Router { "/tiles/{slug}", get(get_public_pmtiles).head(head_public_pmtiles), ) - .route("/tiles/{slug}/meta", get(get_public_tile_meta)); + .route("/tiles/{slug}/meta", get(get_public_tile_meta)) + .route( + "/fonts/{slug}/glyphs/{fontstack}/{*range}", + get(get_public_glyph), + ); let mut api_router = Router::new() .route("/api/files", get(list_files)) @@ -137,7 +145,11 @@ fn build_api_router_with_auth(state: AppState, with_auth: bool) -> Router { .route( "/api/workspaces/{id}/members/{user_id}", delete(remove_member), - ); + ) + .route("/api/fonts", get(list_fonts).post(upload_font)) + .route("/api/fonts/{id}", get(get_font).delete(delete_font)) + .route("/api/fonts/{id}/publish", post(publish_font)) + .route("/api/fonts/{id}/unpublish", post(unpublish_font)); if with_auth { api_router = api_router.route_layer(axum_login::login_required!(crate::AuthBackend)); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a18887ec..5314685f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,11 +21,82 @@ "devDependencies": { "@biomejs/biome": "^2.4.7", "@playwright/test": "^1.50.0", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^6.0.1", + "jsdom": "^29.0.1", "vite": "^8.0.0", "vitest": "^4.1.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz", + "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/runtime": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", @@ -198,6 +269,159 @@ "node": ">=14.21.3" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", + "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", @@ -232,6 +456,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -583,6 +825,69 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -594,6 +899,14 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -772,6 +1085,42 @@ "unzipit": "1.4.3" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -782,6 +1131,16 @@ "node": ">=12" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -812,6 +1171,52 @@ "url": "https://opencollective.com/express" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -822,10 +1227,31 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/earcut": { "version": "3.0.2", "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -906,6 +1332,19 @@ "node": ">=10.19" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -955,6 +1394,62 @@ "@babel/runtime": "^7.23.2" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", + "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lerc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz", @@ -1222,6 +1717,27 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1232,6 +1748,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1313,6 +1836,19 @@ "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==", "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1399,10 +1935,36 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/protocol-buffers-schema": { "version": "3.6.0", "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/quick-lru": { "version": "6.1.2", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", @@ -1474,6 +2036,14 @@ } } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-router": { "version": "7.13.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", @@ -1518,6 +2088,16 @@ "integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==", "license": "MIT" }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-protobuf-schema": { "version": "2.1.0", "license": "MIT", @@ -1566,6 +2146,19 @@ "dev": true, "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1605,6 +2198,13 @@ "dev": true, "license": "MIT" }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "dev": true, @@ -1647,6 +2247,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", + "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.27" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", + "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1655,6 +2301,16 @@ "license": "0BSD", "optional": true }, + "node_modules/undici": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz", + "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/unzipit": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/unzipit/-/unzipit-1.4.3.tgz", @@ -1864,12 +2520,60 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-worker": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", "license": "Apache-2.0" }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "dev": true, @@ -1885,12 +2589,29 @@ "node": ">=8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xml-utils": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", "integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==", "license": "CC0-1.0" }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/zarrita": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.6.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9045a115..c3d0c581 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,7 +28,10 @@ "devDependencies": { "@biomejs/biome": "^2.4.7", "@playwright/test": "^1.50.0", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^6.0.1", + "jsdom": "^29.0.1", "vite": "^8.0.0", "vitest": "^4.1.0" } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 231becd9..eff1e5de 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,6 +19,7 @@ import { } from './api.js'; import { formatSize, parseType, validateSlug } from './utils.js'; import LanguageSwitcher from './LanguageSwitcher.jsx'; +import ResourcesPanel from './ResourcesPanel.jsx'; const INITIAL_POSTGIS_FORM = { connectionName: '', @@ -1386,6 +1387,7 @@ export default function App() { ); const [showWorkspaceDropdown, setShowWorkspaceDropdown] = useState(false); const [workspaceLoading, setWorkspaceLoading] = useState(false); + const [mainTab, setMainTab] = useState('data'); useEffect(() => { loadFilesFailedMessageRef.current = t('app.loadFilesFailed'); @@ -1821,90 +1823,116 @@ export default function App() { - {errorMessage ?
{errorMessage}
: null} -
-
-

{t('app.dataSource')}

- {t('app.supportedFormatsHint')} +
+ +
-
-
- {isLoading ? ( -
{t('app.loading')}
- ) : orderedFiles.length === 0 ? ( -
- {t('app.noFiles')} -
- ) : ( -
-
-
{t('fileList.name')}
-
{t('fileList.type')}
-
{t('fileList.size')}
-
{t('fileList.uploadTime')}
-
{t('fileList.status')}
-
+ {errorMessage ?
{errorMessage}
: null} + + {mainTab === 'data' ? ( +
+
+ {isLoading ? ( +
{t('app.loading')}
+ ) : orderedFiles.length === 0 ? ( +
+ {t('app.noFiles')}
- {orderedFiles.map((item) => ( - - ))} -
- )} -
+ {item.name} + + {item.type} + {item.tileSource === 'postgis' ? ' · PostGIS' : ''} + + {formatSize(item.size || 0)} + + {item.uploadedAt + ? dateTimeFormatter.format(new Date(item.uploadedAt)) + : '--'} + + + {getStatusLabel(t, item.status)} + + + {item.status === 'ready' && ( + { + e.stopPropagation(); + }} + > + {t('fileList.view')} + + )} + + + ))} +
+ )} +
-
- +
+ +
-
+ ) : ( + + )}
{showPostgisModal && ( diff --git a/frontend/src/FontsPanel.jsx b/frontend/src/FontsPanel.jsx new file mode 100644 index 00000000..536653f6 --- /dev/null +++ b/frontend/src/FontsPanel.jsx @@ -0,0 +1,417 @@ +import React, { useCallback, useEffect, useState, useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { listFonts, uploadFont, deleteFont, publishFont, unpublishFont } from './api.js'; +import { validateSlug } from './utils.js'; + +const FONT_STATUS_LABELS = { + processing: 'font.status.processing', + ready: 'font.status.ready', + failed: 'font.status.failed', +}; + +function getFontStatusLabel(t, status) { + const key = FONT_STATUS_LABELS[status]; + return key ? t(key) : status; +} + +export default function FontsPanel() { + const { t, i18n } = useTranslation(); + const [fonts, setFonts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [selectedId, setSelectedId] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + const [isUploading, setIsUploading] = useState(false); + const [publishSlug, setPublishSlug] = useState(''); + const [publishError, setPublishError] = useState(''); + const [isPublishing, setIsPublishing] = useState(false); + const [copySuccess, setCopySuccess] = useState(false); + const fileInputRef = useRef(null); + + const dateTimeFormatter = useMemo( + () => + new Intl.DateTimeFormat(i18n.resolvedLanguage || i18n.language || undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }), + [i18n.language, i18n.resolvedLanguage], + ); + + const selectedFont = fonts.find((f) => f.id === selectedId); + + const refreshFonts = useCallback(async () => { + try { + const data = await listFonts(); + setFonts(Array.isArray(data) ? data : []); + setErrorMessage(''); + } catch (err) { + setErrorMessage(err.message || t('font.loadFailed')); + } finally { + setIsLoading(false); + } + }, [t]); + + useEffect(() => { + refreshFonts(); + }, [refreshFonts]); + + const hasProcessingFonts = useMemo( + () => fonts.some((font) => font.status === 'processing'), + [fonts], + ); + + useEffect(() => { + if (!hasProcessingFonts) return; + + const interval = setInterval(() => { + refreshFonts(); + }, 2000); + + return () => clearInterval(interval); + }, [hasProcessingFonts, refreshFonts]); + + async function handleFileChange(e) { + const file = e.target.files[0]; + if (!file) return; + + const ext = file.name.split('.').pop().toLowerCase(); + if (!['ttf', 'otf'].includes(ext)) { + setErrorMessage(t('font.unsupportedFormat')); + e.target.value = ''; + return; + } + + setIsUploading(true); + setErrorMessage(''); + try { + await uploadFont(file); + await refreshFonts(); + } catch (err) { + setErrorMessage(err.message || t('font.uploadFailed')); + } finally { + setIsUploading(false); + e.target.value = ''; + } + } + + async function handleDelete(fontId) { + if (!confirm(t('font.deleteConfirm'))) return; + try { + await deleteFont(fontId); + if (selectedId === fontId) { + setSelectedId(null); + } + await refreshFonts(); + } catch (err) { + setErrorMessage(err.message || t('font.deleteFailed')); + } + } + + async function handlePublish(fontId) { + setPublishError(''); + setIsPublishing(true); + try { + await publishFont(fontId, { slug: publishSlug.trim() || undefined }); + setPublishSlug(''); + await refreshFonts(); + } catch (err) { + setPublishError(err.message || t('font.publishFailed')); + } finally { + setIsPublishing(false); + } + } + + async function handleUnpublish(fontId) { + if (!confirm(t('font.unpublishConfirm'))) return; + try { + await unpublishFont(fontId); + await refreshFonts(); + } catch (err) { + setErrorMessage(err.message || t('font.unpublishFailed')); + } + } + + function copyPublicUrl(slug) { + const url = `${window.location.origin}/fonts/${slug}/glyphs/{fontstack}/{range}.pbf`; + navigator.clipboard + .writeText(url) + .then(() => { + setCopySuccess(true); + setTimeout(() => setCopySuccess(false), 2000); + }) + .catch(() => { + alert(t('font.copyFailed')); + }); + } + + const slugValidationError = useMemo(() => { + return validateSlug(publishSlug.trim(), { + tooLong: t('file.detail.slugTooLong'), + invalidChars: t('file.detail.slugInvalidChars'), + }).error; + }, [publishSlug, t]); + + return ( +
+ {errorMessage &&
{errorMessage}
} + +
+
+ +
+ + {isLoading ? ( +
{t('common.loading')}
+ ) : fonts.length === 0 ? ( +
+ {t('font.noFonts')} +
+ ) : ( +
+
+
{t('font.name')}
+
{t('font.family')}
+
{t('font.glyphCount')}
+
{t('font.statusLabel')}
+
+
+ {fonts.map((font) => ( + + ))} +
+ )} +
+ +
+ {!selectedFont ? ( +
+

{t('font.selectFontToView')}

+
+ ) : ( +
+
+
+

{selectedFont.name}

+ {selectedFont.id} +
+ +
+
{t('font.fontstack')}
+
{selectedFont.fontstack}
+
+ + {selectedFont.family && ( +
+
{t('font.family')}
+
{selectedFont.family}
+
+ )} + + {selectedFont.style && ( +
+
{t('font.style')}
+
{selectedFont.style}
+
+ )} + + {selectedFont.glyphCount && ( +
+
{t('font.glyphCount')}
+
{selectedFont.glyphCount.toLocaleString()}
+
+ )} + + {selectedFont.startCp != null && selectedFont.endCp != null && ( +
+
{t('font.unicodeRange')}
+
+ U+{selectedFont.startCp.toString(16).toUpperCase().padStart(4, '0')} - U+ + {selectedFont.endCp.toString(16).toUpperCase().padStart(4, '0')} +
+
+ )} + +
+
{t('font.statusLabel')}
+
+ {getFontStatusLabel(t, selectedFont.status)} +
+
+ +
+
{t('font.uploadTime')}
+
+ {selectedFont.createdAt + ? dateTimeFormatter.format(new Date(selectedFont.createdAt)) + : '-'} +
+
+ + {selectedFont.status === 'failed' && selectedFont.error && ( +
+ {t('font.errorLabel')}: {selectedFont.error} +
+ )} + + {selectedFont.status === 'ready' && ( + <> + {!selectedFont.isPublic ? ( +
+
{t('font.publishStatus')}
+
+
+ {t('font.notPublished')} + +
+ + setPublishSlug(e.target.value)} + placeholder={selectedFont.id} + className="form-input" + style={{ width: '100%' }} + data-testid="font-publish-slug-input" + /> + {slugValidationError && ( +
+ {slugValidationError} +
+ )} + {t('font.publishSlugHint')} +
+ +
+
+ {t('font.publicUrl')} +
+
+ /fonts/{publishSlug.trim() || selectedFont.id}/glyphs/{'{fontstack}'}/ + {'{range}'}.pbf +
+
+ + {publishError && ( +
+ {publishError} +
+ )} + + +
+
+
+ ) : ( + <> +
+
{t('font.publishStatus')}
+
+ + {t('font.published')} + +
+
+ +
+
{t('font.publicUrl')}
+
+
+
+ /fonts/{selectedFont.slug}/glyphs/{'{fontstack}'}/{'{range}'}.pbf +
+ +
+
+
+ +
+ +
+ + )} + + )} + +
+ +
+
+
+ )} +
+
+ ); +} diff --git a/frontend/src/IconsPanel.jsx b/frontend/src/IconsPanel.jsx new file mode 100644 index 00000000..edd3ceed --- /dev/null +++ b/frontend/src/IconsPanel.jsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +export default function IconsPanel() { + const { t } = useTranslation(); + + return
{t('app.resourceIconsComingSoon')}
; +} diff --git a/frontend/src/ResourcesPanel.jsx b/frontend/src/ResourcesPanel.jsx new file mode 100644 index 00000000..203bed62 --- /dev/null +++ b/frontend/src/ResourcesPanel.jsx @@ -0,0 +1,72 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import FontsPanel from './FontsPanel.jsx'; +import IconsPanel from './IconsPanel.jsx'; +import StylesPanel from './StylesPanel.jsx'; + +export default function ResourcesPanel() { + const { t } = useTranslation(); + const [resourceTab, setResourceTab] = useState('fonts'); + + return ( +
+
+ + + +
+ + {resourceTab === 'fonts' && ( +
+ +
+ )} + + {resourceTab === 'icons' && ( +
+ +
+ )} + + {resourceTab === 'styles' && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/StylesPanel.jsx b/frontend/src/StylesPanel.jsx new file mode 100644 index 00000000..5b0c9b11 --- /dev/null +++ b/frontend/src/StylesPanel.jsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +export default function StylesPanel() { + const { t } = useTranslation(); + + return
{t('app.resourceStylesComingSoon')}
; +} diff --git a/frontend/src/api.js b/frontend/src/api.js index f62aa9fb..22d04a69 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -305,3 +305,74 @@ export async function leaveWorkspace(workspaceId) { } return null; } + +export async function listFonts() { + const res = await fetchWithAuth('/api/fonts'); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(extractBackendError(data)); + } + return res.json(); +} + +export async function uploadFont(file, onProgress) { + const formData = new FormData(); + formData.append('file', file); + + const res = await fetchWithAuth('/api/fonts', { + method: 'POST', + body: formData, + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(extractBackendError(data)); + } + return res.json(); +} + +export async function getFont(fontId) { + const res = await fetchWithAuth(`/api/fonts/${fontId}`); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(extractBackendError(data)); + } + return res.json(); +} + +export async function deleteFont(fontId) { + const res = await fetchWithAuth(`/api/fonts/${fontId}`, { + method: 'DELETE', + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(extractBackendError(data)); + } + return null; +} + +export async function publishFont(fontId, options = {}) { + const body = {}; + if (options.slug) body.slug = options.slug; + + const res = await fetchWithAuth(`/api/fonts/${fontId}/publish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(extractBackendError(data)); + } + return res.json(); +} + +export async function unpublishFont(fontId) { + const res = await fetchWithAuth(`/api/fonts/${fontId}/unpublish`, { + method: 'POST', + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(extractBackendError(data)); + } + return null; +} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 3dc044ae..d8b84f12 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -49,6 +49,13 @@ "app": { "title": "MapFlow", "subtitle": "Data Source Management", + "mainTabData": "Data", + "mainTabResources": "Resources", + "resourceTabFonts": "Fonts", + "resourceTabIcons": "Icon Sets", + "resourceTabStyles": "Styles", + "resourceIconsComingSoon": "Icon set features are coming soon", + "resourceStylesComingSoon": "Style features are coming soon", "settings": "Settings", "connectPostgis": "Connect PostGIS", "upload": "Upload", @@ -60,6 +67,46 @@ "loadFilesFailed": "Failed to load file list", "uploadFailed": "Upload failed" }, + "font": { + "name": "Name", + "family": "Family", + "style": "Style", + "fontstack": "Font Stack", + "glyphCount": "Glyphs", + "unicodeRange": "Unicode Range", + "statusLabel": "Status", + "uploadTime": "Upload Time", + "errorLabel": "Error", + "uploadFont": "Upload Font", + "uploading": "Uploading...", + "noFonts": "No fonts uploaded yet", + "selectFontToView": "Select a font to view details", + "loadFailed": "Failed to load font list", + "unsupportedFormat": "Unsupported file format. Only .ttf and .otf are allowed", + "uploadFailed": "Font upload failed", + "deleteBtn": "Delete Font", + "deleteConfirm": "Are you sure you want to delete this font?", + "deleteFailed": "Delete font failed", + "publishStatus": "Publish Status", + "notPublished": "Not Published", + "published": "Published", + "publishSlug": "URL Slug (Optional)", + "publishSlugHint": "Leave empty to use font ID. Only letters, numbers, hyphens and underscores", + "publicUrl": "Public URL", + "publishBtn": "Publish", + "publishing": "Publishing...", + "publishFailed": "Publish font failed", + "copyUrl": "Copy URL", + "copyFailed": "Copy failed, please copy manually", + "unpublishBtn": "Unpublish", + "unpublishConfirm": "Are you sure you want to unpublish this font?", + "unpublishFailed": "Unpublish font failed", + "status": { + "processing": "Processing", + "ready": "Ready", + "failed": "Failed" + } + }, "fileList": { "name": "Name", "type": "Type", diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index f8af4e2f..8e0438b4 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -49,6 +49,13 @@ "app": { "title": "MapFlow", "subtitle": "数据源管理与列表", + "mainTabData": "数据", + "mainTabResources": "资源", + "resourceTabFonts": "字体", + "resourceTabIcons": "图标集", + "resourceTabStyles": "样式", + "resourceIconsComingSoon": "图标集功能即将上线", + "resourceStylesComingSoon": "样式功能即将上线", "settings": "设置", "connectPostgis": "连接 PostGIS", "upload": "上传", @@ -60,6 +67,46 @@ "loadFilesFailed": "无法加载文件列表", "uploadFailed": "上传失败" }, + "font": { + "name": "名称", + "family": "字体家族", + "style": "字体样式", + "fontstack": "字体栈", + "glyphCount": "字形数量", + "unicodeRange": "Unicode 范围", + "statusLabel": "状态", + "uploadTime": "上传时间", + "errorLabel": "错误", + "uploadFont": "上传字体", + "uploading": "上传中...", + "noFonts": "暂无字体", + "selectFontToView": "选择一个字体查看详情", + "loadFailed": "加载字体列表失败", + "unsupportedFormat": "不支持的文件格式,仅允许 .ttf 和 .otf", + "uploadFailed": "字体上传失败", + "deleteBtn": "删除字体", + "deleteConfirm": "确定要删除该字体吗?", + "deleteFailed": "删除字体失败", + "publishStatus": "发布状态", + "notPublished": "未发布", + "published": "已发布", + "publishSlug": "URL 标识(可选)", + "publishSlugHint": "留空则使用字体 ID。仅支持字母、数字、连字符和下划线", + "publicUrl": "公开地址", + "publishBtn": "发布", + "publishing": "发布中...", + "publishFailed": "发布字体失败", + "copyUrl": "复制地址", + "copyFailed": "复制失败,请手动复制", + "unpublishBtn": "取消发布", + "unpublishConfirm": "确定要取消发布该字体吗?", + "unpublishFailed": "取消发布字体失败", + "status": { + "processing": "处理中", + "ready": "已就绪", + "failed": "失败" + } + }, "fileList": { "name": "名称", "type": "类型", diff --git a/frontend/tests/unit/resources-panel.test.jsx b/frontend/tests/unit/resources-panel.test.jsx new file mode 100644 index 00000000..d4d13d6b --- /dev/null +++ b/frontend/tests/unit/resources-panel.test.jsx @@ -0,0 +1,86 @@ +/* @vitest-environment jsdom */ + +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import ResourcesPanel from '../../src/ResourcesPanel.jsx'; + +vi.mock('../../src/FontsPanel.jsx', () => ({ + default: function MockFontsPanel() { + return
fonts-panel
; + }, +})); + +vi.mock('../../src/IconsPanel.jsx', () => ({ + default: function MockIconsPanel() { + return
icons-panel
; + }, +})); + +vi.mock('../../src/StylesPanel.jsx', () => ({ + default: function MockStylesPanel() { + return
styles-panel
; + }, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key) => { + const labels = { + 'app.resourceTabFonts': 'Fonts', + 'app.resourceTabIcons': 'Icon Sets', + 'app.resourceTabStyles': 'Styles', + }; + return labels[key] || key; + }, + }), +})); + +afterEach(() => { + cleanup(); +}); + +describe('ResourcesPanel', () => { + it('shows fonts tab content by default', () => { + render(); + + expect(screen.getByTestId('fonts-panel')).toBeTruthy(); + expect(screen.queryByTestId('icons-panel')).toBeNull(); + expect(screen.queryByTestId('styles-panel')).toBeNull(); + }); + + it('switches to icon sets and styles tabs', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('resource-tab-icons')); + expect(screen.queryByTestId('fonts-panel')).toBeNull(); + expect(screen.getByTestId('icons-panel')).toBeTruthy(); + expect(screen.queryByTestId('styles-panel')).toBeNull(); + + await user.click(screen.getByTestId('resource-tab-styles')); + expect(screen.queryByTestId('fonts-panel')).toBeNull(); + expect(screen.queryByTestId('icons-panel')).toBeNull(); + expect(screen.getByTestId('styles-panel')).toBeTruthy(); + }); + + it('updates aria-selected state when switching tabs', async () => { + const user = userEvent.setup(); + render(); + + const fontsTab = screen.getByTestId('resource-tab-fonts'); + const iconsTab = screen.getByTestId('resource-tab-icons'); + const stylesTab = screen.getByTestId('resource-tab-styles'); + + expect(fontsTab.getAttribute('aria-selected')).toBe('true'); + expect(iconsTab.getAttribute('aria-selected')).toBe('false'); + expect(stylesTab.getAttribute('aria-selected')).toBe('false'); + + await user.click(iconsTab); + expect(fontsTab.getAttribute('aria-selected')).toBe('false'); + expect(iconsTab.getAttribute('aria-selected')).toBe('true'); + expect(stylesTab.getAttribute('aria-selected')).toBe('false'); + }); +}); diff --git a/frontend/vitest.config.js b/frontend/vitest.config.js index bb3b02a9..1077c959 100644 --- a/frontend/vitest.config.js +++ b/frontend/vitest.config.js @@ -3,7 +3,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', - include: ['tests/unit/**/*.test.js'], + include: ['tests/unit/**/*.test.{js,jsx}'], reporters: 'default', }, }); From c763d7917f3cb6474fc83ad7ede529df22be419a Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 23 Mar 2026 16:38:43 +0800 Subject: [PATCH 02/15] fix(i18n): remove fallback Chinese tab labels Drop inline Chinese defaultValue strings from main tab translations so the i18n CI detector no longer flags hardcoded Chinese text. --- frontend/src/App.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index eff1e5de..1789ce99 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1834,7 +1834,7 @@ export default function App() { aria-controls="main-tabpanel-data" data-testid="main-tab-data" > - {t('app.mainTabData', { defaultValue: '数据' })} + {t('app.mainTabData')} From c7e095e9e05e1f5db264ff827d627b2642065156 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 24 Mar 2026 09:35:41 +0800 Subject: [PATCH 03/15] fix(fonts): align API casing and harden public glyph path validation --- backend/src/font_handlers.rs | 105 ++++++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 3 deletions(-) diff --git a/backend/src/font_handlers.rs b/backend/src/font_handlers.rs index 1f0bc991..64be6b2f 100644 --- a/backend/src/font_handlers.rs +++ b/backend/src/font_handlers.rs @@ -82,6 +82,7 @@ async fn get_workspace_id( } #[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct FontItem { pub id: String, pub name: String, @@ -111,6 +112,7 @@ pub struct PublishFontRequest { } #[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct PublishFontResponse { pub url: String, pub slug: String, @@ -452,6 +454,25 @@ fn validate_slug(slug: &str) -> Result { Ok(slug) } +fn parse_glyph_range(range: &str) -> Option<(u32, u32)> { + let normalized = range.strip_suffix(".pbf").unwrap_or(range); + if normalized.contains('/') || normalized.contains('\\') { + return None; + } + + let mut parts = normalized.split('-'); + let start = parts.next()?.parse::().ok()?; + let end = parts.next()?.parse::().ok()?; + if parts.next().is_some() { + return None; + } + if end < start || end - start > 255 { + return None; + } + + Some((start, end)) +} + pub async fn publish_font( auth_session: AuthSession, State(state): State, @@ -564,6 +585,9 @@ pub async fn get_public_glyph( State(state): State, AxumPath((slug, _fontstack, range)): AxumPath<(String, String, String)>, ) -> Result)> { + let (start, end) = parse_glyph_range(&range) + .ok_or_else(|| bad_request("Invalid glyph range format, expected -.pbf"))?; + let conn = state.db.lock().await; let glyphs_path: Option = conn @@ -587,13 +611,33 @@ pub async fn get_public_glyph( }; let glyphs_path = glyphs_path.trim_start_matches("./uploads/"); - let normalized_range = range.strip_suffix(".pbf").unwrap_or(range.as_str()); let pbf_path = state .upload_dir .join(glyphs_path) - .join(format!("{}.pbf", normalized_range)); + .join(format!("{}-{}.pbf", start, end)); + + let canonical_path = match fs::canonicalize(&pbf_path).await { + Ok(path) => path, + Err(_) => { + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Glyph range not found".to_string(), + }), + )) + } + }; + + if !canonical_path.starts_with(&state.upload_dir_canonical) { + return Err(( + StatusCode::FORBIDDEN, + Json(ErrorResponse { + error: "Access denied".to_string(), + }), + )); + } - match fs::read(&pbf_path).await { + match fs::read(&canonical_path).await { Ok(data) => Ok(( StatusCode::OK, [(header::CONTENT_TYPE, "application/x-protobuf")], @@ -607,3 +651,58 @@ pub async fn get_public_glyph( )), } } + +#[cfg(test)] +mod tests { + use super::{parse_glyph_range, FontItem}; + + #[test] + fn parse_glyph_range_accepts_valid_patterns() { + assert_eq!(parse_glyph_range("0-255"), Some((0, 255))); + assert_eq!(parse_glyph_range("256-511.pbf"), Some((256, 511))); + } + + #[test] + fn parse_glyph_range_rejects_invalid_patterns() { + assert_eq!(parse_glyph_range("abc-def"), None); + assert_eq!(parse_glyph_range("10-300"), None); + assert_eq!(parse_glyph_range("300-10"), None); + assert_eq!(parse_glyph_range("../0-255"), None); + assert_eq!(parse_glyph_range("0-255/extra"), None); + } + + #[test] + fn font_item_serializes_as_camel_case() { + let item = FontItem { + id: "id-1".to_string(), + name: "Noto Sans".to_string(), + fontstack: "Noto Sans Regular".to_string(), + family: Some("Noto Sans".to_string()), + style: Some("Regular".to_string()), + glyph_count: Some(1024), + start_cp: Some(0), + end_cp: Some(1023), + status: "ready".to_string(), + error: None, + is_public: true, + slug: Some("noto-sans".to_string()), + created_at: "2024-01-01T00:00:00Z".to_string(), + published_at: Some("2024-01-01T00:01:00Z".to_string()), + }; + + let value = serde_json::to_value(item).expect("serialize FontItem"); + let obj = value.as_object().expect("json object"); + + assert!(obj.contains_key("glyphCount")); + assert!(obj.contains_key("startCp")); + assert!(obj.contains_key("endCp")); + assert!(obj.contains_key("isPublic")); + assert!(obj.contains_key("createdAt")); + assert!(obj.contains_key("publishedAt")); + + assert!(!obj.contains_key("glyph_count")); + assert!(!obj.contains_key("start_cp")); + assert!(!obj.contains_key("is_public")); + assert!(!obj.contains_key("created_at")); + } +} From 2cd7b99f2770fd45f7edaffdb01e252cc8eb4103 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 24 Mar 2026 09:59:33 +0800 Subject: [PATCH 04/15] fix(fonts): reconcile processing font jobs on startup --- backend/src/bin/mapflow-desktop.rs | 5 +++++ backend/src/db.rs | 10 ++++++++++ backend/src/lib.rs | 4 ++-- backend/src/main.rs | 5 +++++ 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/backend/src/bin/mapflow-desktop.rs b/backend/src/bin/mapflow-desktop.rs index 97550b04..114d4dda 100644 --- a/backend/src/bin/mapflow-desktop.rs +++ b/backend/src/bin/mapflow-desktop.rs @@ -147,6 +147,11 @@ async fn main() -> Result<()> { Err(e) => tracing::warn!(error = %e, "Failed to reconcile processing files on startup"), } + match backend::reconcile_processing_fonts(&state.db).await { + Ok(count) => tracing::info!(reconciled = count, "Reconciled processing fonts on startup"), + Err(e) => tracing::warn!(error = %e, "Failed to reconcile processing fonts on startup"), + } + let web_dist = std::env::var("WEB_DIST").unwrap_or_else(|_| "frontend/dist".to_string()); let web_dist_path = PathBuf::from(&web_dist); let public_viewer_available = backend::detect_public_viewer_available(&web_dist_path); diff --git a/backend/src/db.rs b/backend/src/db.rs index fbfef421..b378e2bc 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -67,6 +67,16 @@ pub async fn reconcile_processing_files( ) } +pub async fn reconcile_processing_fonts( + db: &Arc>, +) -> Result { + let conn = db.lock().await; + conn.execute( + "UPDATE fonts SET status = 'failed', error = ? WHERE status = 'processing'", + duckdb::params![PROCESSING_RECONCILIATION_ERROR], + ) +} + fn ensure_workspace_schema_and_backfill(conn: &duckdb::Connection) { let _ = conn.execute( "ALTER TABLE users ADD COLUMN current_workspace_id VARCHAR", diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 1da49a33..30857e7b 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -37,8 +37,8 @@ pub use config::{ read_preview_zoom_config, }; pub use db::{ - ensure_app_secret, init_database, is_initialized, reconcile_processing_files, set_initialized, - DEFAULT_DB_PATH, PROCESSING_RECONCILIATION_ERROR, + ensure_app_secret, init_database, is_initialized, reconcile_processing_files, + reconcile_processing_fonts, set_initialized, DEFAULT_DB_PATH, PROCESSING_RECONCILIATION_ERROR, }; pub use handlers::validate_slug; pub use models::{ diff --git a/backend/src/main.rs b/backend/src/main.rs index 49e1e038..62cc3a18 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -140,6 +140,11 @@ async fn main() -> Result<()> { Err(e) => tracing::warn!(error = %e, "Failed to reconcile processing files on startup"), } + match backend::reconcile_processing_fonts(&state.db).await { + Ok(count) => tracing::info!(reconciled = count, "Reconciled processing fonts on startup"), + Err(e) => tracing::warn!(error = %e, "Failed to reconcile processing fonts on startup"), + } + let web_dist = std::env::var("WEB_DIST").unwrap_or_else(|_| "frontend/dist".to_string()); let web_dist_path = PathBuf::from(&web_dist); let public_viewer_available = backend::detect_public_viewer_available(&web_dist_path); From 1f9b45436463a78bd24a71a53c47cb49ee496092 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 24 Mar 2026 10:40:59 +0800 Subject: [PATCH 05/15] fix(fonts): enforce workspace visibility and test-mode auth parity --- backend/src/font_handlers.rs | 73 ++++++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/backend/src/font_handlers.rs b/backend/src/font_handlers.rs index 64be6b2f..27a7e09c 100644 --- a/backend/src/font_handlers.rs +++ b/backend/src/font_handlers.rs @@ -72,12 +72,65 @@ async fn get_workspace_id( Ok(workspace_id) } - None => Err(( - StatusCode::UNAUTHORIZED, - Json(ErrorResponse { - error: "Not authenticated".to_string(), - }), - )), + None => { + if std::env::var("MAPFLOW_TEST_MODE").as_deref() == Ok("1") { + let conn = state.db.lock().await; + + let workspace_id: Option = conn + .query_row( + "SELECT id FROM workspaces WHERE is_personal = true AND deleted_at IS NULL LIMIT 1", + [], + |row| row.get(0), + ) + .ok() + .flatten(); + + if let Some(wid) = workspace_id { + drop(conn); + return Ok(wid); + } + + let existing_user_id: Option = conn + .query_row("SELECT id FROM users LIMIT 1", [], |row| row.get(0)) + .ok() + .flatten(); + + let user_id = match existing_user_id { + Some(uid) => uid, + None => { + let new_user_id = uuid::Uuid::new_v4().to_string(); + conn.execute( + "INSERT INTO users (id, username, password_hash, role, current_workspace_id, created_at) VALUES (?, ?, '', 'user', NULL, CURRENT_TIMESTAMP)", + duckdb::params![&new_user_id, format!("test_user_{}", &new_user_id[..8])], + ).ok(); + new_user_id + } + }; + + let workspace_id = uuid::Uuid::new_v4().to_string(); + let workspace_name = "Test Workspace".to_string(); + + conn.execute( + "INSERT INTO workspaces (id, name, owner_id, is_personal, created_at) VALUES (?, ?, ?, true, CURRENT_TIMESTAMP)", + duckdb::params![&workspace_id, &workspace_name, &user_id], + ).ok(); + + conn.execute( + "INSERT INTO workspace_members (workspace_id, user_id, joined_at) VALUES (?, ?, CURRENT_TIMESTAMP)", + duckdb::params![&workspace_id, &user_id], + ).ok(); + + drop(conn); + Ok(workspace_id) + } else { + Err(( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse { + error: "Not authenticated".to_string(), + }), + )) + } + } } } @@ -592,7 +645,13 @@ pub async fn get_public_glyph( let glyphs_path: Option = conn .query_row( - "SELECT glyphs_path FROM fonts WHERE slug = ? AND is_public = TRUE", + "SELECT f.glyphs_path + FROM fonts f + WHERE f.slug = ? + AND f.is_public = TRUE + AND EXISTS ( + SELECT 1 FROM workspaces w WHERE w.id = f.workspace_id AND w.deleted_at IS NULL + )", duckdb::params![&slug], |row| row.get(0), ) From 8922915356d6b5b9648df77ee9b3da1d40e0d935 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 24 Mar 2026 11:18:02 +0800 Subject: [PATCH 06/15] test(fonts): cover archived glyph visibility and test-mode auth behavior --- backend/tests/api_tests.rs | 144 +++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/backend/tests/api_tests.rs b/backend/tests/api_tests.rs index 784a5a67..9573883f 100644 --- a/backend/tests/api_tests.rs +++ b/backend/tests/api_tests.rs @@ -7919,6 +7919,150 @@ async fn test_delete_workspace_unpublishes_public_tiles() { assert!(!is_public); } +#[tokio::test] +async fn test_public_font_glyphs_blocked_when_workspace_archived() { + ensure_test_mode(); + let temp_dir = TempDir::new().expect("temp dir"); + let upload_dir = temp_dir.path().join("uploads"); + std::fs::create_dir_all(&upload_dir).expect("create upload dir"); + let upload_dir_canonical = upload_dir + .canonicalize() + .unwrap_or_else(|_| upload_dir.clone()); + + let db_path = temp_dir + .path() + .join("public-font-glyph-archived-workspace.duckdb"); + let conn = init_database(&db_path); + let db = Arc::new(tokio::sync::Mutex::new(conn)); + + let state = AppState { + upload_dir, + upload_dir_canonical, + db: db.clone(), + max_size: Arc::new(RwLock::new(10 * 1024 * 1024)), + max_size_label: Arc::new(RwLock::new("10MB".to_string())), + auth_backend: AuthBackend::new(db.clone()), + session_store: DuckDBStore::new(db.clone()), + }; + let app = build_test_router(state); + + let active_workspace_id = "ws-font-active"; + let archived_workspace_id = "ws-font-archived"; + + { + let conn = db.lock().await; + + conn.execute( + "INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)", + duckdb::params![ + "user-font-public", + "fontpublic", + "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36IgQE0VrqQ6EJdNpO5mLY", + "admin" + ], + ) + .unwrap(); + + conn.execute( + "INSERT INTO workspaces (id, name, owner_id, is_personal, created_at) VALUES (?, ?, ?, FALSE, CURRENT_TIMESTAMP)", + duckdb::params![active_workspace_id, "Font Active", "user-font-public"], + ) + .unwrap(); + + conn.execute( + "INSERT INTO workspaces (id, name, owner_id, is_personal, deleted_at, created_at) VALUES (?, ?, ?, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + duckdb::params![archived_workspace_id, "Font Archived", "user-font-public"], + ) + .unwrap(); + } + + let active_font_id = "font-public-active"; + let archived_font_id = "font-public-archived"; + let active_slug = "public-active-font"; + let archived_slug = "public-archived-font"; + + for (font_id, workspace_id) in [ + (active_font_id, active_workspace_id), + (archived_font_id, archived_workspace_id), + ] { + let glyph_file_path = temp_dir + .path() + .join("uploads") + .join("fonts") + .join(font_id) + .join("glyphs") + .join("0-255.pbf"); + std::fs::create_dir_all(glyph_file_path.parent().expect("glyphs dir")).unwrap(); + std::fs::write(&glyph_file_path, b"glyph-data").unwrap(); + + let original_rel = format!("./uploads/fonts/{}/original", font_id); + let glyphs_rel = format!("./uploads/fonts/{}/glyphs", font_id); + let slug = if font_id == active_font_id { + active_slug + } else { + archived_slug + }; + + let conn = db.lock().await; + conn.execute( + "INSERT INTO fonts (id, workspace_id, name, fontstack, original_path, glyphs_path, status, is_public, slug, created_at) + VALUES (?, ?, ?, ?, ?, ?, 'ready', TRUE, ?, CURRENT_TIMESTAMP)", + duckdb::params![ + font_id, + workspace_id, + "Public Font", + "Public Font", + &original_rel, + &glyphs_rel, + slug, + ], + ) + .unwrap(); + } + + let active_glyph_request = Request::builder() + .method("GET") + .uri(format!( + "/fonts/{}/glyphs/Public%20Font/0-255.pbf", + active_slug + )) + .body(Body::empty()) + .unwrap(); + let active_glyph_response = app.clone().oneshot(active_glyph_request).await.unwrap(); + assert_eq!(active_glyph_response.status(), axum::http::StatusCode::OK); + + let archived_glyph_request = Request::builder() + .method("GET") + .uri(format!( + "/fonts/{}/glyphs/Public%20Font/0-255.pbf", + archived_slug + )) + .body(Body::empty()) + .unwrap(); + let archived_glyph_response = app.clone().oneshot(archived_glyph_request).await.unwrap(); + assert_eq!( + archived_glyph_response.status(), + axum::http::StatusCode::NOT_FOUND + ); +} + +#[tokio::test] +async fn test_fonts_api_supports_test_mode_without_auth() { + let (app, _temp) = setup_app().await; + + let request = Request::builder() + .method("GET") + .uri("/api/fonts") + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let _items: Vec = serde_json::from_slice(&body).unwrap(); +} + #[tokio::test] async fn test_delete_workspace_rolls_back_public_state_when_archive_update_fails() { ensure_test_mode(); From 4001b2322d86b14b4fbfed92f5ca09d4efc54928 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 24 Mar 2026 12:47:44 +0800 Subject: [PATCH 07/15] test(fonts): add open-source fixture and full font API lifecycle coverage --- backend/tests/fixtures/fonts/OFL.txt | 93 +++++++ .../fixtures/fonts/PressStart2P-Regular.ttf | Bin 0 -> 118204 bytes backend/tests/font_api_tests.rs | 233 ++++++++++++++++++ 3 files changed, 326 insertions(+) create mode 100644 backend/tests/fixtures/fonts/OFL.txt create mode 100644 backend/tests/fixtures/fonts/PressStart2P-Regular.ttf create mode 100644 backend/tests/font_api_tests.rs diff --git a/backend/tests/fixtures/fonts/OFL.txt b/backend/tests/fixtures/fonts/OFL.txt new file mode 100644 index 00000000..22796dfe --- /dev/null +++ b/backend/tests/fixtures/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2012 The Press Start 2P Project Authors (cody@zone38.net), with Reserved Font Name "Press Start 2P". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/backend/tests/fixtures/fonts/PressStart2P-Regular.ttf b/backend/tests/fixtures/fonts/PressStart2P-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..39adf42efa597906e53be689474ac82214112124 GIT binary patch literal 118204 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 Ap8x;= literal 0 HcmV?d00001 diff --git a/backend/tests/font_api_tests.rs b/backend/tests/font_api_tests.rs new file mode 100644 index 00000000..d69a5487 --- /dev/null +++ b/backend/tests/font_api_tests.rs @@ -0,0 +1,233 @@ +#![allow(clippy::unwrap_used)] + +use axum::body::Body; +use axum::http::Request; +use backend::{build_test_router, init_database, AppState, AuthBackend, DuckDBStore}; +use http_body_util::BodyExt; +use serde_json::Value; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tempfile::TempDir; +use tokio::sync::RwLock; +use tower::ServiceExt; + +static TEST_MODE_SET: std::sync::Once = std::sync::Once::new(); + +fn ensure_test_mode() { + TEST_MODE_SET.call_once(|| { + std::env::set_var("MAPFLOW_TEST_MODE", "1"); + }); +} + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("repo root") + .to_path_buf() +} + +fn read_fixture_bytes(rel_path_from_repo_root: &str) -> Vec { + let p = repo_root().join(rel_path_from_repo_root); + std::fs::read(&p).unwrap_or_else(|e| panic!("Failed to read fixture {p:?}: {e}")) +} + +fn multipart_body(boundary: &str, filename: &str, bytes: &[u8]) -> Vec { + let mut body = Vec::new(); + body.extend_from_slice( + format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\n\r\n" + ) + .as_bytes(), + ); + body.extend_from_slice(bytes); + body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); + body +} + +async fn setup_app() -> (axum::Router, TempDir) { + ensure_test_mode(); + let temp_dir = TempDir::new().expect("temp dir"); + let upload_dir = temp_dir.path().join("uploads"); + std::fs::create_dir_all(&upload_dir).expect("create upload dir"); + let upload_dir_canonical = upload_dir + .canonicalize() + .unwrap_or_else(|_| upload_dir.clone()); + + let db_path = temp_dir.path().join("font-api-tests.duckdb"); + let conn = init_database(&db_path); + let db = Arc::new(tokio::sync::Mutex::new(conn)); + + let state = AppState { + upload_dir, + upload_dir_canonical, + db: db.clone(), + max_size: Arc::new(RwLock::new(10 * 1024 * 1024)), + max_size_label: Arc::new(RwLock::new("10MB".to_string())), + auth_backend: AuthBackend::new(db.clone()), + session_store: DuckDBStore::new(db.clone()), + }; + + let router = build_test_router(state); + (router, temp_dir) +} + +async fn wait_until_font_ready(app: &axum::Router, font_id: &str) -> Value { + let mut last_status: Option = None; + let mut last_error: Option = None; + + for _ in 0..120 { + let request = Request::builder() + .method("GET") + .uri("/api/fonts") + .body(Body::empty()) + .unwrap(); + let response = app.clone().oneshot(request).await.unwrap(); + let body_bytes = response.into_body().collect().await.unwrap().to_bytes(); + let fonts: Vec = serde_json::from_slice(&body_bytes).unwrap(); + + if let Some(item) = fonts + .into_iter() + .find(|f| f.get("id").and_then(Value::as_str) == Some(font_id)) + { + let status = item + .get("status") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + last_status = Some(status.clone()); + last_error = item + .get("error") + .and_then(Value::as_str) + .map(ToString::to_string); + if status == "ready" { + return item; + } + if status == "failed" { + panic!("Font processing failed: {:?}", last_error); + } + } + + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + + panic!( + "Timeout waiting for font ready (last_status={:?}, last_error={:?})", + last_status, last_error + ); +} + +#[tokio::test] +async fn test_font_upload_publish_and_public_glyph_lifecycle() { + let (app, _temp_dir) = setup_app().await; + + let font_bytes = read_fixture_bytes("backend/tests/fixtures/fonts/PressStart2P-Regular.ttf"); + let boundary = "------------------------boundaryFONT"; + let body = multipart_body(boundary, "PressStart2P-Regular.ttf", &font_bytes); + + let upload_request = Request::builder() + .method("POST") + .uri("/api/fonts") + .header( + "content-type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .unwrap(); + + let upload_response = app.clone().oneshot(upload_request).await.unwrap(); + let upload_status = upload_response.status(); + if upload_status != axum::http::StatusCode::OK { + let upload_body = upload_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + panic!( + "expected upload 200 but got {} with body {}", + upload_status, + String::from_utf8_lossy(&upload_body) + ); + } + + let upload_body = upload_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + let uploaded: Value = serde_json::from_slice(&upload_body).unwrap(); + let font_id = uploaded + .get("id") + .and_then(Value::as_str) + .expect("font id") + .to_string(); + + let ready_font = wait_until_font_ready(&app, &font_id).await; + let glyph_count = ready_font + .get("glyphCount") + .and_then(Value::as_i64) + .unwrap_or(0); + assert!(glyph_count > 0); + + let publish_request = Request::builder() + .method("POST") + .uri(format!("/api/fonts/{}/publish", font_id)) + .header("content-type", "application/json") + .body(Body::from(r#"{"slug":"pressstart2p-test"}"#)) + .unwrap(); + let publish_response = app.clone().oneshot(publish_request).await.unwrap(); + assert_eq!(publish_response.status(), axum::http::StatusCode::OK); + + let public_glyph_request = Request::builder() + .method("GET") + .uri("/fonts/pressstart2p-test/glyphs/Press%20Start%202P%20Regular/0-255.pbf") + .body(Body::empty()) + .unwrap(); + let public_glyph_response = app.clone().oneshot(public_glyph_request).await.unwrap(); + assert_eq!(public_glyph_response.status(), axum::http::StatusCode::OK); + assert_eq!( + public_glyph_response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()), + Some("application/x-protobuf") + ); + + let invalid_range_request = Request::builder() + .method("GET") + .uri("/fonts/pressstart2p-test/glyphs/Press%20Start%202P%20Regular/0-999.pbf") + .body(Body::empty()) + .unwrap(); + let invalid_range_response = app.clone().oneshot(invalid_range_request).await.unwrap(); + assert_eq!( + invalid_range_response.status(), + axum::http::StatusCode::BAD_REQUEST + ); + + let unpublish_request = Request::builder() + .method("POST") + .uri(format!("/api/fonts/{}/unpublish", font_id)) + .body(Body::empty()) + .unwrap(); + let unpublish_response = app.clone().oneshot(unpublish_request).await.unwrap(); + assert_eq!( + unpublish_response.status(), + axum::http::StatusCode::NO_CONTENT + ); + + let public_after_unpublish_request = Request::builder() + .method("GET") + .uri("/fonts/pressstart2p-test/glyphs/Press%20Start%202P%20Regular/0-255.pbf") + .body(Body::empty()) + .unwrap(); + let public_after_unpublish_response = app + .clone() + .oneshot(public_after_unpublish_request) + .await + .unwrap(); + assert_eq!( + public_after_unpublish_response.status(), + axum::http::StatusCode::NOT_FOUND + ); +} From 962bae27db6255000e6a96f6e973ad8c696bb2a5 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 24 Mar 2026 12:56:38 +0800 Subject: [PATCH 08/15] test(fonts): expand regression coverage for publish, delete, and schema contracts --- backend/tests/font_api_tests.rs | 295 +++++++++++++++++++++++++++----- 1 file changed, 250 insertions(+), 45 deletions(-) diff --git a/backend/tests/font_api_tests.rs b/backend/tests/font_api_tests.rs index d69a5487..c3e930cf 100644 --- a/backend/tests/font_api_tests.rs +++ b/backend/tests/font_api_tests.rs @@ -44,7 +44,11 @@ fn multipart_body(boundary: &str, filename: &str, bytes: &[u8]) -> Vec { body } -async fn setup_app() -> (axum::Router, TempDir) { +async fn setup_app() -> ( + axum::Router, + TempDir, + Arc>, +) { ensure_test_mode(); let temp_dir = TempDir::new().expect("temp dir"); let upload_dir = temp_dir.path().join("uploads"); @@ -68,7 +72,52 @@ async fn setup_app() -> (axum::Router, TempDir) { }; let router = build_test_router(state); - (router, temp_dir) + (router, temp_dir, db) +} + +async fn upload_fixture_font(app: &axum::Router) -> String { + let font_bytes = read_fixture_bytes("backend/tests/fixtures/fonts/PressStart2P-Regular.ttf"); + let boundary = "------------------------boundaryFONT"; + let body = multipart_body(boundary, "PressStart2P-Regular.ttf", &font_bytes); + + let upload_request = Request::builder() + .method("POST") + .uri("/api/fonts") + .header( + "content-type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .unwrap(); + + let upload_response = app.clone().oneshot(upload_request).await.unwrap(); + let upload_status = upload_response.status(); + if upload_status != axum::http::StatusCode::OK { + let upload_body = upload_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + panic!( + "expected upload 200 but got {} with body {}", + upload_status, + String::from_utf8_lossy(&upload_body) + ); + } + + let upload_body = upload_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + let uploaded: Value = serde_json::from_slice(&upload_body).unwrap(); + uploaded + .get("id") + .and_then(Value::as_str) + .expect("font id") + .to_string() } async fn wait_until_font_ready(app: &axum::Router, font_id: &str) -> Value { @@ -118,50 +167,9 @@ async fn wait_until_font_ready(app: &axum::Router, font_id: &str) -> Value { #[tokio::test] async fn test_font_upload_publish_and_public_glyph_lifecycle() { - let (app, _temp_dir) = setup_app().await; + let (app, _temp_dir, _db) = setup_app().await; - let font_bytes = read_fixture_bytes("backend/tests/fixtures/fonts/PressStart2P-Regular.ttf"); - let boundary = "------------------------boundaryFONT"; - let body = multipart_body(boundary, "PressStart2P-Regular.ttf", &font_bytes); - - let upload_request = Request::builder() - .method("POST") - .uri("/api/fonts") - .header( - "content-type", - format!("multipart/form-data; boundary={boundary}"), - ) - .body(Body::from(body)) - .unwrap(); - - let upload_response = app.clone().oneshot(upload_request).await.unwrap(); - let upload_status = upload_response.status(); - if upload_status != axum::http::StatusCode::OK { - let upload_body = upload_response - .into_body() - .collect() - .await - .unwrap() - .to_bytes(); - panic!( - "expected upload 200 but got {} with body {}", - upload_status, - String::from_utf8_lossy(&upload_body) - ); - } - - let upload_body = upload_response - .into_body() - .collect() - .await - .unwrap() - .to_bytes(); - let uploaded: Value = serde_json::from_slice(&upload_body).unwrap(); - let font_id = uploaded - .get("id") - .and_then(Value::as_str) - .expect("font id") - .to_string(); + let font_id = upload_fixture_font(&app).await; let ready_font = wait_until_font_ready(&app, &font_id).await; let glyph_count = ready_font @@ -231,3 +239,200 @@ async fn test_font_upload_publish_and_public_glyph_lifecycle() { axum::http::StatusCode::NOT_FOUND ); } + +#[tokio::test] +async fn test_publish_font_rejects_invalid_slug() { + let (app, _temp_dir, _db) = setup_app().await; + let font_id = upload_fixture_font(&app).await; + let _ = wait_until_font_ready(&app, &font_id).await; + + let publish_request = Request::builder() + .method("POST") + .uri(format!("/api/fonts/{}/publish", font_id)) + .header("content-type", "application/json") + .body(Body::from(r#"{"slug":"bad slug!*"}"#)) + .unwrap(); + let response = app.clone().oneshot(publish_request).await.unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_publish_font_rejects_slug_conflict() { + let (app, _temp_dir, _db) = setup_app().await; + let font_id_1 = upload_fixture_font(&app).await; + let font_id_2 = upload_fixture_font(&app).await; + let _ = wait_until_font_ready(&app, &font_id_1).await; + let _ = wait_until_font_ready(&app, &font_id_2).await; + + let publish_1 = Request::builder() + .method("POST") + .uri(format!("/api/fonts/{}/publish", font_id_1)) + .header("content-type", "application/json") + .body(Body::from(r#"{"slug":"duplicate-font-slug"}"#)) + .unwrap(); + let response_1 = app.clone().oneshot(publish_1).await.unwrap(); + assert_eq!(response_1.status(), axum::http::StatusCode::OK); + + let publish_2 = Request::builder() + .method("POST") + .uri(format!("/api/fonts/{}/publish", font_id_2)) + .header("content-type", "application/json") + .body(Body::from(r#"{"slug":"duplicate-font-slug"}"#)) + .unwrap(); + let response_2 = app.clone().oneshot(publish_2).await.unwrap(); + assert_eq!(response_2.status(), axum::http::StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_publish_font_rejects_non_ready_status() { + let (app, _temp_dir, db) = setup_app().await; + let font_id = "font-not-ready-1"; + + let seed_request = Request::builder() + .method("GET") + .uri("/api/fonts") + .body(Body::empty()) + .unwrap(); + let seed_response = app.clone().oneshot(seed_request).await.unwrap(); + assert_eq!(seed_response.status(), axum::http::StatusCode::OK); + + let workspace_id = { + let conn = db.lock().await; + conn.query_row( + "SELECT id FROM workspaces WHERE is_personal = TRUE AND deleted_at IS NULL LIMIT 1", + [], + |row| row.get::<_, String>(0), + ) + .unwrap() + }; + + { + let conn = db.lock().await; + conn.execute( + "INSERT INTO fonts (id, workspace_id, name, fontstack, original_path, glyphs_path, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, 'processing', CURRENT_TIMESTAMP)", + duckdb::params![ + font_id, + &workspace_id, + "Not Ready Font", + "Not Ready Font", + "./uploads/fonts/font-not-ready-1/original", + "./uploads/fonts/font-not-ready-1/glyphs", + ], + ) + .unwrap(); + } + + let publish_request = Request::builder() + .method("POST") + .uri(format!("/api/fonts/{}/publish", font_id)) + .header("content-type", "application/json") + .body(Body::from(r#"{"slug":"not-ready-font"}"#)) + .unwrap(); + let response = app.clone().oneshot(publish_request).await.unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::CONFLICT); +} + +#[tokio::test] +async fn test_delete_font_removes_public_access() { + let (app, temp_dir, _db) = setup_app().await; + let font_id = upload_fixture_font(&app).await; + let _ = wait_until_font_ready(&app, &font_id).await; + + let publish_request = Request::builder() + .method("POST") + .uri(format!("/api/fonts/{}/publish", font_id)) + .header("content-type", "application/json") + .body(Body::from(r#"{"slug":"deletable-font"}"#)) + .unwrap(); + let publish_response = app.clone().oneshot(publish_request).await.unwrap(); + assert_eq!(publish_response.status(), axum::http::StatusCode::OK); + + let delete_request = Request::builder() + .method("DELETE") + .uri(format!("/api/fonts/{}", font_id)) + .body(Body::empty()) + .unwrap(); + let delete_response = app.clone().oneshot(delete_request).await.unwrap(); + assert_eq!(delete_response.status(), axum::http::StatusCode::NO_CONTENT); + + let public_glyph_request = Request::builder() + .method("GET") + .uri("/fonts/deletable-font/glyphs/Press%20Start%202P%20Regular/0-255.pbf") + .body(Body::empty()) + .unwrap(); + let public_glyph_response = app.clone().oneshot(public_glyph_request).await.unwrap(); + assert_eq!( + public_glyph_response.status(), + axum::http::StatusCode::NOT_FOUND + ); + + let font_dir = temp_dir.path().join("uploads").join("fonts").join(font_id); + assert!(!font_dir.exists()); +} + +#[tokio::test] +async fn test_list_and_get_font_use_camel_case_contract() { + let (app, _temp_dir, _db) = setup_app().await; + let font_id = upload_fixture_font(&app).await; + let _ = wait_until_font_ready(&app, &font_id).await; + + let list_request = Request::builder() + .method("GET") + .uri("/api/fonts") + .body(Body::empty()) + .unwrap(); + let list_response = app.clone().oneshot(list_request).await.unwrap(); + assert_eq!(list_response.status(), axum::http::StatusCode::OK); + let list_body = list_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + let items: Vec = serde_json::from_slice(&list_body).unwrap(); + let item = items + .into_iter() + .find(|it| it.get("id").and_then(Value::as_str) == Some(font_id.as_str())) + .expect("uploaded font in list"); + + assert!(item.get("glyphCount").is_some()); + assert!(item.get("startCp").is_some()); + assert!(item.get("endCp").is_some()); + assert!(item.get("isPublic").is_some()); + assert!(item.get("createdAt").is_some()); + assert!(item.get("glyph_count").is_none()); + assert!(item.get("is_public").is_none()); + + let get_request = Request::builder() + .method("GET") + .uri(format!("/api/fonts/{}", font_id)) + .body(Body::empty()) + .unwrap(); + let get_response = app.clone().oneshot(get_request).await.unwrap(); + assert_eq!(get_response.status(), axum::http::StatusCode::OK); + let get_body = get_response.into_body().collect().await.unwrap().to_bytes(); + let got: Value = serde_json::from_slice(&get_body).unwrap(); + assert!(got.get("glyphCount").is_some()); + assert!(got.get("isPublic").is_some()); + assert!(got.get("glyph_count").is_none()); + assert!(got.get("is_public").is_none()); +} + +#[tokio::test] +async fn test_upload_font_rejects_unsupported_extension() { + let (app, _temp_dir, _db) = setup_app().await; + let body = multipart_body("----boundaryTXT", "not-a-font.txt", b"hello"); + + let request = Request::builder() + .method("POST") + .uri("/api/fonts") + .header( + "content-type", + "multipart/form-data; boundary=----boundaryTXT", + ) + .body(Body::from(body)) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST); +} From 13df8b3e93ef988aaaa6fb8e277bc99615af771c Mon Sep 17 00:00:00 2001 From: Lucas Date: Thu, 26 Mar 2026 15:02:06 +0800 Subject: [PATCH 09/15] fix(fonts): eliminate TOCTOU race in publish_font and remove unused param - Merge status check into UPDATE WHERE clause for atomic operation - Remove unused onProgress parameter from uploadFont function --- backend/src/font_handlers.rs | 29 +++++++++++++---------------- frontend/src/api.js | 2 +- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/backend/src/font_handlers.rs b/backend/src/font_handlers.rs index 27a7e09c..4121d058 100644 --- a/backend/src/font_handlers.rs +++ b/backend/src/font_handlers.rs @@ -543,16 +543,15 @@ pub async fn publish_font( let conn = state.db.lock().await; - let status: Option = conn + let font_exists: bool = conn .query_row( - "SELECT status FROM fonts WHERE id = ? AND workspace_id = ?", + "SELECT EXISTS(SELECT 1 FROM fonts WHERE id = ? AND workspace_id = ?)", duckdb::params![&id, &workspace_id], |row| row.get(0), ) - .optional() .map_err(internal_error)?; - let Some(status) = status else { + if !font_exists { drop(conn); return Err(( StatusCode::NOT_FOUND, @@ -560,27 +559,25 @@ pub async fn publish_font( error: "Font not found".to_string(), }), )); - }; - - if status != "ready" { - drop(conn); - return Err(( - StatusCode::CONFLICT, - Json(ErrorResponse { - error: format!("Font is not ready for publishing (status: {})", status), - }), - )); } let published_at = Utc::now().naive_utc(); let result = conn.execute( - "UPDATE fonts SET is_public = TRUE, slug = ?, published_at = ? WHERE id = ? AND workspace_id = ?", + "UPDATE fonts SET is_public = TRUE, slug = ?, published_at = ? WHERE id = ? AND workspace_id = ? AND status = 'ready'", duckdb::params![&slug, published_at, &id, &workspace_id], ); match result { - Ok(_) => { + Ok(rows_affected) => { drop(conn); + if rows_affected == 0 { + return Err(( + StatusCode::CONFLICT, + Json(ErrorResponse { + error: "Font is not ready for publishing".to_string(), + }), + )); + } info!(font_id = %id, slug = %slug, "Font published"); let url = format!("/fonts/{}/glyphs/{{fontstack}}/{{range}}.pbf", slug); Ok(Json(PublishFontResponse { diff --git a/frontend/src/api.js b/frontend/src/api.js index 22d04a69..59c51015 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -315,7 +315,7 @@ export async function listFonts() { return res.json(); } -export async function uploadFont(file, onProgress) { +export async function uploadFont(file) { const formData = new FormData(); formData.append('file', file); From 580e04cf14778a65d5780907fb2b299bb97fd646 Mon Sep 17 00:00:00 2001 From: Lucas Date: Sat, 28 Mar 2026 21:23:46 +0800 Subject: [PATCH 10/15] feat(fonts): public glyph URL by workspace slug and add workspace slug management --- backend/src/auth_routes.rs | 25 +-- backend/src/db.rs | 86 +++++++++- backend/src/font_handlers.rs | 94 ++++++++--- backend/src/handlers.rs | 8 +- backend/src/lib.rs | 1 + backend/src/routes.rs | 2 +- backend/src/upload.rs | 8 +- backend/src/workspace.rs | 124 ++++++++++++++ backend/src/workspace_handlers.rs | 258 +++++++++++++++++++++++------- backend/tests/api_tests.rs | 125 ++++++++++++++- backend/tests/font_api_tests.rs | 22 ++- docs/dev/behaviors.md | 19 ++- docs/internal.md | 5 + frontend/src/FontsPanel.jsx | 61 ++----- frontend/src/Settings.jsx | 188 ++++++++++++++++++++-- frontend/src/api.js | 18 ++- frontend/src/i18n/locales/en.json | 8 + frontend/src/i18n/locales/zh.json | 8 + frontend/src/utils.js | 29 ++++ 19 files changed, 905 insertions(+), 184 deletions(-) diff --git a/backend/src/auth_routes.rs b/backend/src/auth_routes.rs index a47a1d35..2d460bb1 100644 --- a/backend/src/auth_routes.rs +++ b/backend/src/auth_routes.rs @@ -39,6 +39,7 @@ pub struct LoginResponse { pub struct CurrentWorkspace { id: String, name: String, + slug: String, } #[derive(Debug, Serialize)] @@ -98,20 +99,21 @@ async fn login( let current_workspace = if let Some(ref workspace_id) = user.current_workspace_id { let conn = state.db.lock().await; - let workspace_info: Option<(String,)> = conn + let workspace_info: Option<(String, String)> = conn .query_row( - "SELECT name FROM workspaces WHERE id = ? AND deleted_at IS NULL", + "SELECT name, COALESCE(slug, id) FROM workspaces WHERE id = ? AND deleted_at IS NULL", duckdb::params![workspace_id], - |row| Ok((row.get(0)?,)), + |row| Ok((row.get(0)?, row.get(1)?)), ) .optional() .ok() .flatten(); drop(conn); - workspace_info.map(|(name,)| CurrentWorkspace { + workspace_info.map(|(name, slug)| CurrentWorkspace { id: workspace_id.clone(), name, + slug, }) } else { None @@ -140,20 +142,21 @@ async fn check_auth( Some(user) => { let current_workspace = if let Some(ref workspace_id) = user.current_workspace_id { let conn = state.db.lock().await; - let workspace_info: Option<(String,)> = conn + let workspace_info: Option<(String, String)> = conn .query_row( - "SELECT name FROM workspaces WHERE id = ? AND deleted_at IS NULL", + "SELECT name, COALESCE(slug, id) FROM workspaces WHERE id = ? AND deleted_at IS NULL", duckdb::params![workspace_id], - |row| Ok((row.get(0)?,)), + |row| Ok((row.get(0)?, row.get(1)?)), ) .optional() .ok() .flatten(); drop(conn); - workspace_info.map(|(name,)| CurrentWorkspace { + workspace_info.map(|(name, slug)| CurrentWorkspace { id: workspace_id.clone(), name, + slug, }) } else { None @@ -285,10 +288,12 @@ async fn init_system( let workspace_id = uuid::Uuid::new_v4().to_string(); let workspace_name = crate::workspace::make_personal_workspace_name(&req.username); + let workspace_slug = + crate::workspace::workspace_slug_base_from_name_or_id(&workspace_name, &workspace_id); conn.execute( - "INSERT INTO workspaces (id, name, owner_id, is_personal, created_at) VALUES (?, ?, ?, TRUE, ?)", - duckdb::params![&workspace_id, &workspace_name, &user_id, &created_at], + "INSERT INTO workspaces (id, name, slug, owner_id, is_personal, created_at) VALUES (?, ?, ?, ?, TRUE, ?)", + duckdb::params![&workspace_id, &workspace_name, &workspace_slug, &user_id, &created_at], ) .map_err(|e| { conn.execute("ROLLBACK", []).ok(); diff --git a/backend/src/db.rs b/backend/src/db.rs index b378e2bc..c2435ec0 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -83,10 +83,26 @@ fn ensure_workspace_schema_and_backfill(conn: &duckdb::Connection) { [], ); let _ = conn.execute("ALTER TABLE files ADD COLUMN workspace_id VARCHAR", []); + let _ = conn.execute("ALTER TABLE workspaces ADD COLUMN slug VARCHAR", []); let _ = conn.execute( "CREATE INDEX IF NOT EXISTS idx_files_workspace ON files(workspace_id)", [], ); + let _ = conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_slug ON workspaces(slug)", + [], + ); + let _ = conn.execute( + "ALTER TABLE fonts ADD COLUMN is_public BOOLEAN DEFAULT FALSE", + [], + ); + let _ = conn.execute("ALTER TABLE fonts ADD COLUMN slug VARCHAR", []); + let _ = conn.execute("ALTER TABLE fonts ADD COLUMN published_at TIMESTAMP", []); + let _ = conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_fonts_workspace_slug ON fonts(workspace_id, slug)", + [], + ); + let _ = conn.execute("DROP INDEX IF EXISTS idx_fonts_workspace_fontstack", []); recover_detached_workspace_members(conn).expect("Failed to recover detached workspace members"); backfill_workspace_data(conn).expect("Failed to backfill workspace data"); @@ -142,10 +158,11 @@ fn backfill_workspace_data(conn: &duckdb::Connection) -> Result<(), duckdb::Erro &crate::workspace::make_personal_workspace_name(&username), &workspace_id, )?; + let workspace_slug = unique_workspace_slug(conn, &workspace_name, &workspace_id)?; conn.execute( - "INSERT INTO workspaces (id, name, owner_id, is_personal, created_at) VALUES (?, ?, ?, TRUE, ?)", - duckdb::params![&workspace_id, &workspace_name, &user_id, &created_at], + "INSERT INTO workspaces (id, name, slug, owner_id, is_personal, created_at) VALUES (?, ?, ?, ?, TRUE, ?)", + duckdb::params![&workspace_id, &workspace_name, &workspace_slug, &user_id, &created_at], )?; conn.execute( "INSERT INTO workspace_members (workspace_id, user_id, joined_at) VALUES (?, ?, ?)", @@ -209,9 +226,11 @@ fn backfill_workspace_data(conn: &duckdb::Connection) -> Result<(), duckdb::Erro crate::workspace::make_legacy_shared_workspace_name(), &shared_workspace_id, )?; + let workspace_slug = + unique_workspace_slug(conn, &workspace_name, &shared_workspace_id)?; conn.execute( - "INSERT INTO workspaces (id, name, owner_id, is_personal, created_at) VALUES (?, ?, ?, FALSE, ?)", - duckdb::params![&shared_workspace_id, &workspace_name, &owner_id, &created_at], + "INSERT INTO workspaces (id, name, slug, owner_id, is_personal, created_at) VALUES (?, ?, ?, ?, FALSE, ?)", + duckdb::params![&shared_workspace_id, &workspace_name, &workspace_slug, &owner_id, &created_at], )?; } @@ -307,6 +326,22 @@ fn backfill_workspace_data(conn: &duckdb::Connection) -> Result<(), duckdb::Erro } } + let workspaces_without_slug = { + let mut stmt = conn.prepare("SELECT id, name FROM workspaces WHERE slug IS NULL")?; + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })?; + rows.collect::, _>>()? + }; + + for (workspace_id, workspace_name) in workspaces_without_slug { + let workspace_slug = unique_workspace_slug(conn, &workspace_name, &workspace_id)?; + conn.execute( + "UPDATE workspaces SET slug = ? WHERE id = ?", + duckdb::params![&workspace_slug, &workspace_id], + )?; + } + Ok(()) } @@ -332,6 +367,39 @@ fn unique_workspace_name( )) } +fn unique_workspace_slug( + conn: &duckdb::Connection, + workspace_name: &str, + workspace_id: &str, +) -> Result { + let base = crate::workspace::workspace_slug_base_from_name_or_id(workspace_name, workspace_id); + + let mut candidate = base.clone(); + let mut suffix = 1_u32; + loop { + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM workspaces WHERE slug = ?", + duckdb::params![&candidate], + |row| row.get(0), + )?; + if count == 0 { + return Ok(candidate); + } + + let suffix_text = format!("-{suffix}"); + let max_base_len = crate::workspace::WORKSPACE_SLUG_MAX_LEN + .saturating_sub(suffix_text.len()) + .max(crate::workspace::WORKSPACE_SLUG_MIN_LEN); + let truncated_base = if base.len() > max_base_len { + base[..max_base_len].trim_end_matches('-').to_string() + } else { + base.clone() + }; + candidate = format!("{truncated_base}{suffix_text}"); + suffix = suffix.saturating_add(1); + } +} + pub fn init_database(db_path: &Path) -> duckdb::Connection { if let Some(parent) = db_path.parent() { std::fs::create_dir_all(parent).expect("Failed to create database directory"); @@ -509,6 +577,7 @@ pub fn init_database(db_path: &Path) -> duckdb::Connection { CREATE TABLE IF NOT EXISTS workspaces ( id VARCHAR PRIMARY KEY, name VARCHAR UNIQUE NOT NULL, + slug VARCHAR UNIQUE, owner_id VARCHAR NOT NULL REFERENCES users(id), is_personal BOOLEAN NOT NULL DEFAULT FALSE, deleted_at TIMESTAMP, @@ -521,6 +590,9 @@ pub fn init_database(db_path: &Path) -> duckdb::Connection { CREATE INDEX IF NOT EXISTS idx_workspaces_deleted_at ON workspaces(deleted_at); + CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_slug + ON workspaces(slug); + CREATE TABLE IF NOT EXISTS workspace_members ( workspace_id VARCHAR NOT NULL REFERENCES workspaces(id), user_id VARCHAR NOT NULL REFERENCES users(id), @@ -558,7 +630,7 @@ pub fn init_database(db_path: &Path) -> duckdb::Connection { status VARCHAR NOT NULL DEFAULT 'processing', error VARCHAR, is_public BOOLEAN DEFAULT FALSE, - slug VARCHAR UNIQUE, + slug VARCHAR, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, published_at TIMESTAMP ); @@ -566,8 +638,8 @@ pub fn init_database(db_path: &Path) -> duckdb::Connection { CREATE INDEX IF NOT EXISTS idx_fonts_workspace ON fonts(workspace_id); - CREATE INDEX IF NOT EXISTS idx_fonts_slug - ON fonts(slug); + CREATE UNIQUE INDEX IF NOT EXISTS idx_fonts_workspace_slug + ON fonts(workspace_id, slug); ", ) .expect("Failed to create fonts table"); diff --git a/backend/src/font_handlers.rs b/backend/src/font_handlers.rs index 4121d058..1c8be095 100644 --- a/backend/src/font_handlers.rs +++ b/backend/src/font_handlers.rs @@ -109,10 +109,14 @@ async fn get_workspace_id( let workspace_id = uuid::Uuid::new_v4().to_string(); let workspace_name = "Test Workspace".to_string(); + let workspace_slug = crate::workspace::workspace_slug_base_from_name_or_id( + &workspace_name, + &workspace_id, + ); conn.execute( - "INSERT INTO workspaces (id, name, owner_id, is_personal, created_at) VALUES (?, ?, ?, true, CURRENT_TIMESTAMP)", - duckdb::params![&workspace_id, &workspace_name, &user_id], + "INSERT INTO workspaces (id, name, slug, owner_id, is_personal, created_at) VALUES (?, ?, ?, ?, true, CURRENT_TIMESTAMP)", + duckdb::params![&workspace_id, &workspace_name, &workspace_slug, &user_id], ).ok(); conn.execute( @@ -149,6 +153,7 @@ pub struct FontItem { pub error: Option, pub is_public: bool, pub slug: Option, + pub workspace_slug: String, pub created_at: String, pub published_at: Option, } @@ -170,6 +175,7 @@ pub struct PublishFontResponse { pub url: String, pub slug: String, pub is_public: bool, + pub workspace_slug: String, } pub async fn upload_font( @@ -351,17 +357,18 @@ pub async fn list_fonts( let conn = state.db.lock().await; let mut stmt = conn .prepare( - "SELECT id, name, fontstack, family, style, glyph_count, start_cp, end_cp, status, error, is_public, slug, created_at, published_at - FROM fonts - WHERE workspace_id = ? - ORDER BY created_at DESC", + "SELECT f.id, f.name, f.fontstack, f.family, f.style, f.glyph_count, f.start_cp, f.end_cp, f.status, f.error, f.is_public, f.slug, COALESCE(w.slug, w.id), f.created_at, f.published_at + FROM fonts f + JOIN workspaces w ON w.id = f.workspace_id + WHERE f.workspace_id = ? + ORDER BY f.created_at DESC", ) .map_err(internal_error)?; let fonts_iter = stmt .query_map(duckdb::params![&workspace_id], |row| { - let created_at: chrono::NaiveDateTime = row.get(12)?; - let published_at: Option = row.get(13)?; + let created_at: chrono::NaiveDateTime = row.get(13)?; + let published_at: Option = row.get(14)?; Ok(FontItem { id: row.get(0)?, name: row.get(1)?, @@ -375,6 +382,7 @@ pub async fn list_fonts( error: row.get(9)?, is_public: row.get(10)?, slug: row.get(11)?, + workspace_slug: row.get(12)?, created_at: created_at.and_utc().to_rfc3339(), published_at: published_at.map(|t| t.and_utc().to_rfc3339()), }) @@ -400,13 +408,14 @@ pub async fn get_font( let conn = state.db.lock().await; let font: Option = conn .query_row( - "SELECT id, name, fontstack, family, style, glyph_count, start_cp, end_cp, status, error, is_public, slug, created_at, published_at - FROM fonts - WHERE id = ? AND workspace_id = ?", + "SELECT f.id, f.name, f.fontstack, f.family, f.style, f.glyph_count, f.start_cp, f.end_cp, f.status, f.error, f.is_public, f.slug, COALESCE(w.slug, w.id), f.created_at, f.published_at + FROM fonts f + JOIN workspaces w ON w.id = f.workspace_id + WHERE f.id = ? AND f.workspace_id = ?", duckdb::params![&id, &workspace_id], |row| { - let created_at: chrono::NaiveDateTime = row.get(12)?; - let published_at: Option = row.get(13)?; + let created_at: chrono::NaiveDateTime = row.get(13)?; + let published_at: Option = row.get(14)?; Ok(FontItem { id: row.get(0)?, name: row.get(1)?, @@ -420,6 +429,7 @@ pub async fn get_font( error: row.get(9)?, is_public: row.get(10)?, slug: row.get(11)?, + workspace_slug: row.get(12)?, created_at: created_at.and_utc().to_rfc3339(), published_at: published_at.map(|t| t.and_utc().to_rfc3339()), }) @@ -526,6 +536,15 @@ fn parse_glyph_range(range: &str) -> Option<(u32, u32)> { Some((start, end)) } +fn normalize_requested_fontstack(fontstack: &str) -> &str { + fontstack + .split(',') + .next() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(fontstack) +} + pub async fn publish_font( auth_session: AuthSession, State(state): State, @@ -543,15 +562,19 @@ pub async fn publish_font( let conn = state.db.lock().await; - let font_exists: bool = conn + let workspace_meta: Option<(String, String)> = conn .query_row( - "SELECT EXISTS(SELECT 1 FROM fonts WHERE id = ? AND workspace_id = ?)", + "SELECT COALESCE(w.slug, w.id), f.fontstack + FROM fonts f + JOIN workspaces w ON w.id = f.workspace_id + WHERE f.id = ? AND f.workspace_id = ?", duckdb::params![&id, &workspace_id], - |row| row.get(0), + |row| Ok((row.get(0)?, row.get(1)?)), ) + .optional() .map_err(internal_error)?; - if !font_exists { + let Some((workspace_slug, fontstack)) = workspace_meta else { drop(conn); return Err(( StatusCode::NOT_FOUND, @@ -559,6 +582,24 @@ pub async fn publish_font( error: "Font not found".to_string(), }), )); + }; + + let published_conflict: bool = conn + .query_row( + "SELECT EXISTS( + SELECT 1 FROM fonts + WHERE workspace_id = ? AND id != ? AND is_public = TRUE AND fontstack = ? + )", + duckdb::params![&workspace_id, &id, &fontstack], + |row| row.get(0), + ) + .map_err(internal_error)?; + + if published_conflict { + drop(conn); + return Err(bad_request( + "Another published font with the same fontstack already exists in this workspace", + )); } let published_at = Utc::now().naive_utc(); @@ -579,11 +620,12 @@ pub async fn publish_font( )); } info!(font_id = %id, slug = %slug, "Font published"); - let url = format!("/fonts/{}/glyphs/{{fontstack}}/{{range}}.pbf", slug); + let url = format!("/fonts/{}/{{fontstack}}/{{range}}.pbf", workspace_slug); Ok(Json(PublishFontResponse { url, slug, is_public: true, + workspace_slug, })) } Err(e) => { @@ -633,10 +675,11 @@ pub async fn unpublish_font( pub async fn get_public_glyph( State(state): State, - AxumPath((slug, _fontstack, range)): AxumPath<(String, String, String)>, + AxumPath((workspace_slug, fontstack, range)): AxumPath<(String, String, String)>, ) -> Result)> { let (start, end) = parse_glyph_range(&range) .ok_or_else(|| bad_request("Invalid glyph range format, expected -.pbf"))?; + let normalized_fontstack = normalize_requested_fontstack(&fontstack).to_string(); let conn = state.db.lock().await; @@ -644,12 +687,13 @@ pub async fn get_public_glyph( .query_row( "SELECT f.glyphs_path FROM fonts f - WHERE f.slug = ? + JOIN workspaces w ON w.id = f.workspace_id + WHERE COALESCE(w.slug, w.id) = ? + AND f.fontstack = ? AND f.is_public = TRUE - AND EXISTS ( - SELECT 1 FROM workspaces w WHERE w.id = f.workspace_id AND w.deleted_at IS NULL - )", - duckdb::params![&slug], + AND f.status = 'ready' + AND w.deleted_at IS NULL", + duckdb::params![&workspace_slug, &normalized_fontstack], |row| row.get(0), ) .optional() @@ -742,6 +786,7 @@ mod tests { error: None, is_public: true, slug: Some("noto-sans".to_string()), + workspace_slug: "team-alpha".to_string(), created_at: "2024-01-01T00:00:00Z".to_string(), published_at: Some("2024-01-01T00:01:00Z".to_string()), }; @@ -753,6 +798,7 @@ mod tests { assert!(obj.contains_key("startCp")); assert!(obj.contains_key("endCp")); assert!(obj.contains_key("isPublic")); + assert!(obj.contains_key("workspaceSlug")); assert!(obj.contains_key("createdAt")); assert!(obj.contains_key("publishedAt")); diff --git a/backend/src/handlers.rs b/backend/src/handlers.rs index 4d0cd67d..5a3bb438 100644 --- a/backend/src/handlers.rs +++ b/backend/src/handlers.rs @@ -138,10 +138,14 @@ async fn get_workspace_id( let workspace_id = uuid::Uuid::new_v4().to_string(); let workspace_name = "Test Workspace".to_string(); + let workspace_slug = crate::workspace::workspace_slug_base_from_name_or_id( + &workspace_name, + &workspace_id, + ); conn.execute( - "INSERT INTO workspaces (id, name, owner_id, is_personal, created_at) VALUES (?, ?, ?, true, CURRENT_TIMESTAMP)", - duckdb::params![&workspace_id, &workspace_name, &user_id], + "INSERT INTO workspaces (id, name, slug, owner_id, is_personal, created_at) VALUES (?, ?, ?, ?, true, CURRENT_TIMESTAMP)", + duckdb::params![&workspace_id, &workspace_name, &workspace_slug, &user_id], ).ok(); conn.execute( diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 30857e7b..60d06d4d 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -218,6 +218,7 @@ mod tests { CREATE TABLE workspaces ( id VARCHAR PRIMARY KEY, name VARCHAR UNIQUE NOT NULL, + slug VARCHAR UNIQUE, owner_id VARCHAR NOT NULL REFERENCES users(id), is_personal BOOLEAN NOT NULL DEFAULT FALSE, deleted_at TIMESTAMP, diff --git a/backend/src/routes.rs b/backend/src/routes.rs index bd7b9236..d9d85147 100644 --- a/backend/src/routes.rs +++ b/backend/src/routes.rs @@ -85,7 +85,7 @@ fn build_api_router_with_auth(state: AppState, with_auth: bool) -> Router { ) .route("/tiles/{slug}/meta", get(get_public_tile_meta)) .route( - "/fonts/{slug}/glyphs/{fontstack}/{*range}", + "/fonts/{workspace_slug}/{fontstack}/{*range}", get(get_public_glyph), ); diff --git a/backend/src/upload.rs b/backend/src/upload.rs index a0a72d4e..d6521931 100644 --- a/backend/src/upload.rs +++ b/backend/src/upload.rs @@ -122,10 +122,14 @@ pub async fn upload_file( let new_workspace_id = uuid::Uuid::new_v4().to_string(); let workspace_name = "Test Workspace".to_string(); + let workspace_slug = crate::workspace::workspace_slug_base_from_name_or_id( + &workspace_name, + &new_workspace_id, + ); conn.execute( - "INSERT INTO workspaces (id, name, owner_id, is_personal, created_at) VALUES (?, ?, ?, true, CURRENT_TIMESTAMP)", - duckdb::params![&new_workspace_id, &workspace_name, &user_id], + "INSERT INTO workspaces (id, name, slug, owner_id, is_personal, created_at) VALUES (?, ?, ?, ?, true, CURRENT_TIMESTAMP)", + duckdb::params![&new_workspace_id, &workspace_name, &workspace_slug, &user_id], ).ok(); conn.execute( diff --git a/backend/src/workspace.rs b/backend/src/workspace.rs index 106aecba..0f537de9 100644 --- a/backend/src/workspace.rs +++ b/backend/src/workspace.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; pub struct Workspace { pub id: String, pub name: String, + pub slug: String, pub owner_id: String, #[serde(rename = "isPersonal")] pub is_personal: bool, @@ -29,6 +30,7 @@ pub struct WorkspaceMember { pub struct WorkspaceWithMemberCount { pub id: String, pub name: String, + pub slug: String, #[serde(rename = "ownerId")] pub owner_id: String, #[serde(rename = "isPersonal")] @@ -54,6 +56,7 @@ pub struct WorkspaceMemberWithInfo { pub struct WorkspaceResponse { pub id: String, pub name: String, + pub slug: String, #[serde(rename = "ownerId")] pub owner_id: String, #[serde(rename = "isPersonal")] @@ -64,12 +67,15 @@ pub struct WorkspaceResponse { pub struct CurrentWorkspaceResponse { pub id: String, pub name: String, + pub slug: String, #[serde(rename = "isPersonal")] pub is_personal: bool, } pub const WORKSPACE_NAME_MIN_LEN: usize = 3; pub const WORKSPACE_NAME_MAX_LEN: usize = 50; +pub const WORKSPACE_SLUG_MIN_LEN: usize = 3; +pub const WORKSPACE_SLUG_MAX_LEN: usize = 63; pub const LEGACY_SHARED_WORKSPACE_NAME: &str = "Migrated Workspace"; pub fn validate_workspace_name(name: &str) -> Result { @@ -110,6 +116,81 @@ pub fn make_legacy_shared_workspace_name() -> &'static str { LEGACY_SHARED_WORKSPACE_NAME } +pub fn validate_workspace_slug(slug: &str) -> Result { + let slug = slug.trim().to_ascii_lowercase(); + let len = slug.chars().count(); + + if slug.is_empty() { + return Err("工作空间 slug 不能为空".to_string()); + } + if len < WORKSPACE_SLUG_MIN_LEN { + return Err(format!( + "工作空间 slug 至少需要 {} 个字符", + WORKSPACE_SLUG_MIN_LEN + )); + } + if len > WORKSPACE_SLUG_MAX_LEN { + return Err(format!( + "工作空间 slug 不能超过 {} 个字符", + WORKSPACE_SLUG_MAX_LEN + )); + } + if slug.starts_with('-') || slug.ends_with('-') { + return Err("工作空间 slug 不能以连字符开头或结尾".to_string()); + } + if !slug + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + return Err("工作空间 slug 仅支持小写字母、数字和连字符".to_string()); + } + Ok(slug) +} + +pub fn slugify_workspace_name(name: &str) -> String { + let mut out = String::new(); + let mut last_is_dash = false; + + for ch in name.chars() { + let c = ch.to_ascii_lowercase(); + if c.is_ascii_lowercase() || c.is_ascii_digit() { + out.push(c); + last_is_dash = false; + } else if (ch.is_whitespace() || c == '-' || c == '_') && !last_is_dash { + out.push('-'); + last_is_dash = true; + } + } + + let trimmed = out.trim_matches('-').to_string(); + if trimmed.len() > WORKSPACE_SLUG_MAX_LEN { + trimmed[..WORKSPACE_SLUG_MAX_LEN] + .trim_end_matches('-') + .to_string() + } else { + trimmed + } +} + +pub fn fallback_workspace_slug_from_id(workspace_id: &str) -> String { + let suffix = &workspace_id[..workspace_id.len().min(8)]; + format!("ws-{suffix}") +} + +pub fn workspace_slug_base_from_name_or_id(name: &str, workspace_id: &str) -> String { + let from_name = slugify_workspace_name(name); + if validate_workspace_slug(&from_name).is_ok() { + return from_name; + } + + let from_id = fallback_workspace_slug_from_id(workspace_id); + if validate_workspace_slug(&from_id).is_ok() { + return from_id; + } + + "workspace-default".to_string() +} + #[cfg(test)] mod tests { use super::*; @@ -171,4 +252,47 @@ mod tests { assert_eq!(too_long_name.chars().count(), 51); assert!(validate_workspace_name(&too_long_name).is_err()); } + + #[test] + fn validate_workspace_slug_rejects_invalid_chars() { + assert_eq!(validate_workspace_slug("Hello").unwrap(), "hello"); + assert!(validate_workspace_slug("abc_def").is_err()); + assert!(validate_workspace_slug("-abc").is_err()); + assert!(validate_workspace_slug("abc-").is_err()); + } + + #[test] + fn validate_workspace_slug_accepts_lowercase_dash() { + assert_eq!( + validate_workspace_slug("team-alpha-01").unwrap(), + "team-alpha-01" + ); + } + + #[test] + fn slugify_workspace_name_normalizes_to_kebab_case() { + assert_eq!(slugify_workspace_name("Team Alpha 01"), "team-alpha-01"); + assert_eq!(slugify_workspace_name(" Team___Beta "), "team-beta"); + assert_eq!(slugify_workspace_name("中文空间"), ""); + } + + #[test] + fn fallback_workspace_slug_from_id_prefixes_ws() { + assert_eq!( + fallback_workspace_slug_from_id("abcdef123456"), + "ws-abcdef12" + ); + } + + #[test] + fn workspace_slug_base_prefers_name_then_id() { + assert_eq!( + workspace_slug_base_from_name_or_id("Team One", "abcdef123456"), + "team-one" + ); + assert_eq!( + workspace_slug_base_from_name_or_id("中文空间", "abcdef123456"), + "ws-abcdef12" + ); + } } diff --git a/backend/src/workspace_handlers.rs b/backend/src/workspace_handlers.rs index 1b0d1de6..7d723721 100644 --- a/backend/src/workspace_handlers.rs +++ b/backend/src/workspace_handlers.rs @@ -13,13 +13,14 @@ use tracing::info; use crate::{ models::ErrorResponse, workspace::{ - generate_deleted_workspace_name, validate_workspace_name, CurrentWorkspaceResponse, - WorkspaceMemberWithInfo, WorkspaceResponse, WorkspaceWithMemberCount, + generate_deleted_workspace_name, validate_workspace_name, validate_workspace_slug, + workspace_slug_base_from_name_or_id, CurrentWorkspaceResponse, WorkspaceMemberWithInfo, + WorkspaceResponse, WorkspaceWithMemberCount, }, AppState, AuthBackend, User, }; -type WorkspaceRow = (String, String, String, bool, Option, String); +type WorkspaceRow = (String, String, String, String, bool, Option, String); type ApiResult = Result; @@ -66,6 +67,65 @@ fn workspace_name_conflict_or_internal(err: duckdb::Error) -> Response { internal_err(err) } +fn workspace_slug_conflict_or_internal(err: duckdb::Error) -> Response { + if is_unique_constraint_error(&err) { + return bad_req("工作空间 slug 已被使用"); + } + internal_err(err) +} + +fn workspace_slug_conflict() -> Response { + bad_req("工作空间 slug 已被使用") +} + +#[allow(clippy::result_large_err)] +fn build_unique_workspace_slug( + conn: &duckdb::Connection, + requested_slug: Option<&str>, + name: &str, + workspace_id: &str, + exclude_workspace_id: Option<&str>, +) -> ApiResult { + let base = if let Some(raw) = requested_slug { + validate_workspace_slug(raw).map_err(|e| bad_req(&e))? + } else { + workspace_slug_base_from_name_or_id(name, workspace_id) + }; + + let mut candidate = base.clone(); + let mut suffix: u32 = 2; + + loop { + let in_use: i64 = conn + .query_row( + "SELECT COUNT(*) FROM workspaces WHERE slug = ? AND (? IS NULL OR id != ?)", + duckdb::params![&candidate, &exclude_workspace_id, &exclude_workspace_id], + |row| row.get(0), + ) + .map_err(internal_err)?; + + if in_use == 0 { + return Ok(candidate); + } + + if requested_slug.is_some() { + return Err(workspace_slug_conflict()); + } + + let suffix_text = format!("-{suffix}"); + let max_base_len = crate::workspace::WORKSPACE_SLUG_MAX_LEN + .saturating_sub(suffix_text.len()) + .max(crate::workspace::WORKSPACE_SLUG_MIN_LEN); + let truncated_base = if base.len() > max_base_len { + base[..max_base_len].trim_end_matches('-').to_string() + } else { + base.clone() + }; + candidate = format!("{truncated_base}{suffix_text}"); + suffix = suffix.saturating_add(1); + } +} + #[allow(clippy::result_large_err)] fn with_detached_workspace_members( conn: &duckdb::Connection, @@ -135,7 +195,7 @@ async fn require_user(auth_session: &AuthSession) -> ApiResult Result { - let (id, name, owner_id, is_personal, deleted_at_str, created_at_str) = row; + let (id, name, slug, owner_id, is_personal, deleted_at_str, created_at_str) = row; let deleted_at = deleted_at_str .map(|s| { chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S") @@ -151,6 +211,7 @@ fn parse_workspace_row(row: WorkspaceRow) -> Result(3)?, row.get::<_, Option>(4)?, row.get::<_, String>(5)?, - row.get::<_, i64>(6)?, + row.get::<_, String>(6)?, + row.get::<_, i64>(7)?, )) }) .map_err(internal_err)? @@ -205,7 +268,16 @@ pub async fn list_workspaces( let workspaces: Result, String> = rows .into_iter() .map( - |(id, name, owner_id, is_personal, _deleted_at_str, created_at_str, member_count)| { + |( + id, + name, + owner_id, + is_personal, + _deleted_at_str, + created_at_str, + slug, + member_count, + )| { let created_at = chrono::NaiveDateTime::parse_from_str(&created_at_str, "%Y-%m-%d %H:%M:%S") .map_err(|e| format!("Failed to parse created_at: {}", e))? @@ -214,6 +286,7 @@ pub async fn list_workspaces( Ok(WorkspaceWithMemberCount { id, name, + slug, owner_id, is_personal, member_count, @@ -231,6 +304,7 @@ pub async fn list_workspaces( #[derive(Debug, Deserialize)] pub struct CreateWorkspaceRequest { pub name: String, + pub slug: Option, } pub async fn create_workspace( @@ -256,14 +330,15 @@ pub async fn create_workspace( } let workspace_id = uuid::Uuid::new_v4().to_string(); + let slug = build_unique_workspace_slug(&conn, req.slug.as_deref(), &name, &workspace_id, None)?; let now = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); conn.execute_batch("BEGIN TRANSACTION") .map_err(internal_err)?; let insert_result = conn.execute( - "INSERT INTO workspaces (id, name, owner_id, is_personal, created_at) VALUES (?, ?, ?, FALSE, ?)", - duckdb::params![&workspace_id, &name, &user.id, &now], + "INSERT INTO workspaces (id, name, slug, owner_id, is_personal, created_at) VALUES (?, ?, ?, ?, FALSE, ?)", + duckdb::params![&workspace_id, &name, &slug, &user.id, &now], ); if let Err(e) = insert_result { @@ -287,13 +362,14 @@ pub async fn create_workspace( conn.execute_batch("COMMIT").map_err(internal_err)?; - info!(workspace_id = %workspace_id, name = %name, owner_id = %user.id, "Workspace created"); + info!(workspace_id = %workspace_id, name = %name, slug = %slug, owner_id = %user.id, "Workspace created"); Ok(( StatusCode::CREATED, Json(WorkspaceResponse { id: workspace_id, name, + slug, owner_id: user.id, is_personal: false, }), @@ -329,7 +405,7 @@ pub async fn get_workspace( let row: Option = conn .query_row( - "SELECT id, name, owner_id, is_personal, deleted_at, created_at FROM workspaces WHERE id = ?", + "SELECT id, name, COALESCE(slug, id), owner_id, is_personal, deleted_at, created_at FROM workspaces WHERE id = ?", duckdb::params![&workspace_id], |row| { Ok(( @@ -339,6 +415,7 @@ pub async fn get_workspace( row.get(3)?, row.get(4)?, row.get(5)?, + row.get(6)?, )) }, ) @@ -693,16 +770,17 @@ pub async fn switch_workspace( let user = require_user(&auth_session).await?; let conn = state.db.lock().await; - let workspace_info: Option<(String, bool)> = conn + let workspace_info: Option<(String, String, bool)> = conn .query_row( - "SELECT name, is_personal FROM workspaces WHERE id = ? AND deleted_at IS NULL", + "SELECT name, COALESCE(slug, id), is_personal FROM workspaces WHERE id = ? AND deleted_at IS NULL", duckdb::params![&req.workspace_id], - |row| Ok((row.get(0)?, row.get(1)?)), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .optional() .map_err(internal_err)?; - let (name, is_personal) = workspace_info.ok_or_else(|| not_found("Workspace not found"))?; + let (name, slug, is_personal) = + workspace_info.ok_or_else(|| not_found("Workspace not found"))?; let is_member: i64 = conn .query_row( @@ -738,13 +816,15 @@ pub async fn switch_workspace( Ok(Json(CurrentWorkspaceResponse { id: req.workspace_id, name, + slug, is_personal, })) } #[derive(Debug, Deserialize)] pub struct UpdateWorkspaceRequest { - pub name: String, + pub name: Option, + pub slug: Option, } #[allow(clippy::result_large_err)] @@ -755,19 +835,22 @@ pub async fn update_workspace( Json(req): Json, ) -> ApiResult { let user = require_user(&auth_session).await?; - let name = validate_workspace_name(&req.name).map_err(|e| bad_req(&e))?; + if req.name.is_none() && req.slug.is_none() { + return Err(bad_req("至少需要提供 name 或 slug")); + } + let conn = state.db.lock().await; - let workspace_info: Option<(String, bool, Option)> = conn + let workspace_info: Option<(String, bool, Option, String, String)> = conn .query_row( - "SELECT owner_id, is_personal, deleted_at FROM workspaces WHERE id = ?", + "SELECT owner_id, is_personal, deleted_at, name, COALESCE(slug, id) FROM workspaces WHERE id = ?", duckdb::params![&workspace_id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)), ) .optional() .map_err(internal_err)?; - let (owner_id, is_personal, deleted_at) = + let (owner_id, is_personal, deleted_at, current_name, current_slug) = workspace_info.ok_or_else(|| not_found("Workspace not found"))?; if user.id != owner_id { @@ -778,34 +861,62 @@ pub async fn update_workspace( return Err(not_found("Workspace not found")); } - if is_personal { - return Err(bad_req("Cannot rename personal workspace")); - } + let target_name = match req.name { + Some(name_raw) => { + if is_personal { + return Err(bad_req("Cannot rename personal workspace")); + } + validate_workspace_name(&name_raw).map_err(|e| bad_req(&e))? + } + None => current_name.clone(), + }; - let existing: i64 = conn - .query_row( - "SELECT COUNT(*) FROM workspaces WHERE name = ? AND id != ? AND deleted_at IS NULL", - duckdb::params![&name, &workspace_id], - |row| row.get(0), - ) - .map_err(internal_err)?; + if target_name != current_name { + let existing: i64 = conn + .query_row( + "SELECT COUNT(*) FROM workspaces WHERE name = ? AND id != ? AND deleted_at IS NULL", + duckdb::params![&target_name, &workspace_id], + |row| row.get(0), + ) + .map_err(internal_err)?; - if existing > 0 { - return Err(bad_req("工作空间名称已被使用")); + if existing > 0 { + return Err(bad_req("工作空间名称已被使用")); + } } + let target_slug = match req.slug { + Some(slug_raw) => build_unique_workspace_slug( + &conn, + Some(&slug_raw), + &target_name, + &workspace_id, + Some(&workspace_id), + )?, + None => current_slug.clone(), + }; + with_detached_workspace_members(&conn, &workspace_id, |conn| { conn.execute( - "UPDATE workspaces SET name = ? WHERE id = ?", - duckdb::params![&name, &workspace_id], + "UPDATE workspaces SET name = ?, slug = ? WHERE id = ?", + duckdb::params![&target_name, &target_slug, &workspace_id], ) - .map_err(workspace_name_conflict_or_internal)?; + .map_err(|err| { + let err_text = err.to_string(); + if err_text.contains("slug") { + workspace_slug_conflict_or_internal(err) + } else { + workspace_name_conflict_or_internal(err) + } + })?; Ok(()) })?; - info!(workspace_id = %workspace_id, name = %name, "Workspace updated"); + info!(workspace_id = %workspace_id, name = %target_name, slug = %target_slug, "Workspace updated"); - Ok(Json(json!({ "id": workspace_id, "name": name }))) + Ok(Json( + json!({ "id": workspace_id, "name": target_name, "slug": target_slug }), + )) } #[allow(clippy::result_large_err)] @@ -867,7 +978,7 @@ pub async fn delete_workspace( })?; conn.execute( - "UPDATE workspaces SET deleted_at = ?, name = ? WHERE id = ?", + "UPDATE workspaces SET deleted_at = ?, name = ?, slug = NULL WHERE id = ?", duckdb::params![&now, &archived_name, &workspace_id], ) .map_err(|err| { @@ -896,6 +1007,7 @@ pub async fn delete_workspace( #[derive(Debug, Deserialize)] pub struct RestoreWorkspaceRequest { pub name: Option, + pub slug: Option, } #[allow(clippy::result_large_err)] @@ -930,6 +1042,13 @@ pub async fn restore_workspace( if let Some(new_name) = &req.name { let name = validate_workspace_name(new_name).map_err(|e| bad_req(&e))?; + let slug = build_unique_workspace_slug( + &conn, + req.slug.as_deref(), + &name, + &workspace_id, + Some(&workspace_id), + )?; let existing: i64 = conn .query_row( @@ -945,18 +1064,34 @@ pub async fn restore_workspace( with_detached_workspace_members(&conn, &workspace_id, |conn| { conn.execute( - "UPDATE workspaces SET name = ?, deleted_at = NULL WHERE id = ?", - duckdb::params![&name, &workspace_id], + "UPDATE workspaces SET name = ?, slug = ?, deleted_at = NULL WHERE id = ?", + duckdb::params![&name, &slug, &workspace_id], ) - .map_err(workspace_name_conflict_or_internal)?; + .map_err(|err| { + let err_text = err.to_string(); + if err_text.contains("slug") { + workspace_slug_conflict_or_internal(err) + } else { + workspace_name_conflict_or_internal(err) + } + })?; Ok(()) })?; - info!(workspace_id = %workspace_id, name = %name, "Workspace restored with new name"); + info!(workspace_id = %workspace_id, name = %name, slug = %slug, "Workspace restored with new name"); - Ok(Json(json!({ "id": workspace_id, "name": name }))) + Ok(Json( + json!({ "id": workspace_id, "name": name, "slug": slug }), + )) } else { let restored_name = archived_workspace_original_name(¤t_name, &workspace_id); + let restored_slug = build_unique_workspace_slug( + &conn, + req.slug.as_deref(), + &restored_name, + &workspace_id, + Some(&workspace_id), + )?; let existing: i64 = conn .query_row( @@ -972,16 +1107,25 @@ pub async fn restore_workspace( with_detached_workspace_members(&conn, &workspace_id, |conn| { conn.execute( - "UPDATE workspaces SET name = ?, deleted_at = NULL WHERE id = ?", - duckdb::params![&restored_name, &workspace_id], + "UPDATE workspaces SET name = ?, slug = ?, deleted_at = NULL WHERE id = ?", + duckdb::params![&restored_name, &restored_slug, &workspace_id], ) - .map_err(workspace_name_conflict_or_internal)?; + .map_err(|err| { + let err_text = err.to_string(); + if err_text.contains("slug") { + workspace_slug_conflict_or_internal(err) + } else { + workspace_name_conflict_or_internal(err) + } + })?; Ok(()) })?; - info!(workspace_id = %workspace_id, "Workspace restored"); + info!(workspace_id = %workspace_id, slug = %restored_slug, "Workspace restored"); - Ok(Json(json!({ "id": workspace_id, "name": restored_name }))) + Ok(Json( + json!({ "id": workspace_id, "name": restored_name, "slug": restored_slug }), + )) } } @@ -996,6 +1140,7 @@ pub async fn list_archived_workspaces( .prepare( r" SELECT w.id, w.name, w.owner_id, w.is_personal, w.deleted_at, w.created_at + , COALESCE(w.slug, w.id) as slug FROM workspaces w JOIN workspace_members wm ON w.id = wm.workspace_id WHERE wm.user_id = ? AND w.deleted_at IS NOT NULL @@ -1013,6 +1158,7 @@ pub async fn list_archived_workspaces( row.get::<_, bool>(3)?, row.get::<_, Option>(4)?, row.get::<_, String>(5)?, + row.get::<_, String>(6)?, )) }) .map_err(internal_err)? @@ -1023,7 +1169,7 @@ pub async fn list_archived_workspaces( let workspaces: Result, String> = rows .into_iter() .map( - |(id, name, owner_id, is_personal, deleted_at_str, created_at_str)| { + |(id, name, owner_id, is_personal, deleted_at_str, created_at_str, slug)| { let deleted_at = deleted_at_str .map(|s| { chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S") @@ -1041,6 +1187,7 @@ pub async fn list_archived_workspaces( Ok(json!({ "id": id, "name": display_name, + "slug": slug, "ownerId": owner_id, "isPersonal": is_personal, "deletedAt": deleted_at, @@ -1062,18 +1209,18 @@ pub async fn get_current_workspace( let user = require_user(&auth_session).await?; let conn = state.db.lock().await; - let workspace_info: Option<(String, String, bool)> = + let workspace_info: Option<(String, String, String, bool)> = if let Some(current_workspace_id) = &user.current_workspace_id { conn.query_row( r" - SELECT w.id, w.name, w.is_personal + SELECT w.id, w.name, COALESCE(w.slug, w.id), w.is_personal FROM workspaces w JOIN workspace_members wm ON w.id = wm.workspace_id WHERE w.id = ? AND wm.user_id = ? AND w.deleted_at IS NULL LIMIT 1 ", duckdb::params![current_workspace_id, &user.id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), ) .optional() .map_err(internal_err)? @@ -1086,7 +1233,7 @@ pub async fn get_current_workspace( None => conn .query_row( r" - SELECT w.id, w.name, w.is_personal + SELECT w.id, w.name, COALESCE(w.slug, w.id), w.is_personal FROM workspaces w JOIN workspace_members wm ON w.id = wm.workspace_id WHERE wm.user_id = ? AND w.deleted_at IS NULL @@ -1094,16 +1241,17 @@ pub async fn get_current_workspace( LIMIT 1 ", duckdb::params![&user.id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), ) .optional() .map_err(internal_err)?, }; match workspace_info { - Some((id, name, is_personal)) => Ok(Json(CurrentWorkspaceResponse { + Some((id, name, slug, is_personal)) => Ok(Json(CurrentWorkspaceResponse { id, name, + slug, is_personal, })), None => Err(not_found("No workspace found")), diff --git a/backend/tests/api_tests.rs b/backend/tests/api_tests.rs index 9573883f..0cd6a2a1 100644 --- a/backend/tests/api_tests.rs +++ b/backend/tests/api_tests.rs @@ -6812,6 +6812,103 @@ async fn test_update_workspace_maps_unique_conflict_to_400() { assert_eq!(payload["error"], "工作空间名称已被使用"); } +#[tokio::test] +async fn test_update_workspace_slug_persists_and_visible_in_read_models() { + ensure_test_mode(); + let temp_dir = TempDir::new().expect("temp dir"); + let upload_dir = temp_dir.path().join("uploads"); + std::fs::create_dir_all(&upload_dir).expect("create upload dir"); + let upload_dir_canonical = upload_dir + .canonicalize() + .unwrap_or_else(|_| upload_dir.clone()); + + let db_path = temp_dir.path().join("workspace-update-slug-persist.duckdb"); + let conn = init_database(&db_path); + let db = Arc::new(tokio::sync::Mutex::new(conn)); + + let state = AppState { + upload_dir, + upload_dir_canonical, + db: db.clone(), + max_size: Arc::new(RwLock::new(10 * 1024 * 1024)), + max_size_label: Arc::new(RwLock::new("10MB".to_string())), + auth_backend: AuthBackend::new(db.clone()), + session_store: DuckDBStore::new(db.clone()), + }; + let app = build_api_router(state); + let cookie = + create_user_and_session(&app, db.clone(), "user-update-slug-1", "slugger", "admin").await; + + let create_request = Request::builder() + .method("POST") + .uri("/api/workspaces") + .header("cookie", &cookie) + .header("content-type", "application/json") + .body(Body::from(r#"{"name":"Slug Workspace"}"#)) + .unwrap(); + let create_response = app.clone().oneshot(create_request).await.unwrap(); + assert_eq!(create_response.status(), axum::http::StatusCode::CREATED); + let create_body = create_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + let created_workspace: serde_json::Value = serde_json::from_slice(&create_body).unwrap(); + let workspace_id = created_workspace["id"].as_str().unwrap().to_string(); + + let update_request = Request::builder() + .method("PUT") + .uri(format!("/api/workspaces/{}", workspace_id)) + .header("cookie", &cookie) + .header("content-type", "application/json") + .body(Body::from(r#"{"slug":"slug-workspace"}"#)) + .unwrap(); + let update_response = app.clone().oneshot(update_request).await.unwrap(); + assert_eq!(update_response.status(), axum::http::StatusCode::OK); + let update_body = update_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + let updated_payload: serde_json::Value = serde_json::from_slice(&update_body).unwrap(); + assert_eq!(updated_payload["slug"], "slug-workspace"); + + let get_request = Request::builder() + .method("GET") + .uri(format!("/api/workspaces/{}", workspace_id)) + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(); + let get_response = app.clone().oneshot(get_request).await.unwrap(); + assert_eq!(get_response.status(), axum::http::StatusCode::OK); + let get_body = get_response.into_body().collect().await.unwrap().to_bytes(); + let got_workspace: serde_json::Value = serde_json::from_slice(&get_body).unwrap(); + assert_eq!(got_workspace["slug"], "slug-workspace"); + + let list_request = Request::builder() + .method("GET") + .uri("/api/workspaces") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(); + let list_response = app.oneshot(list_request).await.unwrap(); + assert_eq!(list_response.status(), axum::http::StatusCode::OK); + let list_body = list_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + let list_payload: serde_json::Value = serde_json::from_slice(&list_body).unwrap(); + let listed_workspace = list_payload + .as_array() + .and_then(|items| items.iter().find(|item| item["id"] == workspace_id)) + .expect("workspace appears in list"); + assert_eq!(listed_workspace["slug"], "slug-workspace"); +} + #[tokio::test] async fn test_update_workspace_rejects_archived_workspace() { ensure_test_mode(); @@ -7948,6 +8045,8 @@ async fn test_public_font_glyphs_blocked_when_workspace_archived() { let active_workspace_id = "ws-font-active"; let archived_workspace_id = "ws-font-archived"; + let active_workspace_slug = "font-active-workspace"; + let archived_workspace_slug = "font-archived-workspace"; { let conn = db.lock().await; @@ -7964,14 +8063,24 @@ async fn test_public_font_glyphs_blocked_when_workspace_archived() { .unwrap(); conn.execute( - "INSERT INTO workspaces (id, name, owner_id, is_personal, created_at) VALUES (?, ?, ?, FALSE, CURRENT_TIMESTAMP)", - duckdb::params![active_workspace_id, "Font Active", "user-font-public"], + "INSERT INTO workspaces (id, name, slug, owner_id, is_personal, created_at) VALUES (?, ?, ?, ?, FALSE, CURRENT_TIMESTAMP)", + duckdb::params![ + active_workspace_id, + "Font Active", + active_workspace_slug, + "user-font-public" + ], ) .unwrap(); conn.execute( - "INSERT INTO workspaces (id, name, owner_id, is_personal, deleted_at, created_at) VALUES (?, ?, ?, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", - duckdb::params![archived_workspace_id, "Font Archived", "user-font-public"], + "INSERT INTO workspaces (id, name, slug, owner_id, is_personal, deleted_at, created_at) VALUES (?, ?, ?, ?, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + duckdb::params![ + archived_workspace_id, + "Font Archived", + archived_workspace_slug, + "user-font-public" + ], ) .unwrap(); } @@ -8023,8 +8132,8 @@ async fn test_public_font_glyphs_blocked_when_workspace_archived() { let active_glyph_request = Request::builder() .method("GET") .uri(format!( - "/fonts/{}/glyphs/Public%20Font/0-255.pbf", - active_slug + "/fonts/{}/Public%20Font/0-255.pbf", + active_workspace_slug )) .body(Body::empty()) .unwrap(); @@ -8034,8 +8143,8 @@ async fn test_public_font_glyphs_blocked_when_workspace_archived() { let archived_glyph_request = Request::builder() .method("GET") .uri(format!( - "/fonts/{}/glyphs/Public%20Font/0-255.pbf", - archived_slug + "/fonts/{}/Public%20Font/0-255.pbf", + archived_workspace_slug )) .body(Body::empty()) .unwrap(); diff --git a/backend/tests/font_api_tests.rs b/backend/tests/font_api_tests.rs index c3e930cf..53e46d5b 100644 --- a/backend/tests/font_api_tests.rs +++ b/backend/tests/font_api_tests.rs @@ -186,10 +186,22 @@ async fn test_font_upload_publish_and_public_glyph_lifecycle() { .unwrap(); let publish_response = app.clone().oneshot(publish_request).await.unwrap(); assert_eq!(publish_response.status(), axum::http::StatusCode::OK); + let publish_body = publish_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + let publish_payload: Value = serde_json::from_slice(&publish_body).unwrap(); + assert_eq!(publish_payload["workspaceSlug"], "test-workspace"); + assert_eq!( + publish_payload["url"], + "/fonts/test-workspace/{fontstack}/{range}.pbf" + ); let public_glyph_request = Request::builder() .method("GET") - .uri("/fonts/pressstart2p-test/glyphs/Press%20Start%202P%20Regular/0-255.pbf") + .uri("/fonts/test-workspace/Press%20Start%202P%20Regular/0-255.pbf") .body(Body::empty()) .unwrap(); let public_glyph_response = app.clone().oneshot(public_glyph_request).await.unwrap(); @@ -204,7 +216,7 @@ async fn test_font_upload_publish_and_public_glyph_lifecycle() { let invalid_range_request = Request::builder() .method("GET") - .uri("/fonts/pressstart2p-test/glyphs/Press%20Start%202P%20Regular/0-999.pbf") + .uri("/fonts/test-workspace/Press%20Start%202P%20Regular/0-999.pbf") .body(Body::empty()) .unwrap(); let invalid_range_response = app.clone().oneshot(invalid_range_request).await.unwrap(); @@ -226,7 +238,7 @@ async fn test_font_upload_publish_and_public_glyph_lifecycle() { let public_after_unpublish_request = Request::builder() .method("GET") - .uri("/fonts/pressstart2p-test/glyphs/Press%20Start%202P%20Regular/0-255.pbf") + .uri("/fonts/test-workspace/Press%20Start%202P%20Regular/0-255.pbf") .body(Body::empty()) .unwrap(); let public_after_unpublish_response = app @@ -358,7 +370,7 @@ async fn test_delete_font_removes_public_access() { let public_glyph_request = Request::builder() .method("GET") - .uri("/fonts/deletable-font/glyphs/Press%20Start%202P%20Regular/0-255.pbf") + .uri("/fonts/test-workspace/Press%20Start%202P%20Regular/0-255.pbf") .body(Body::empty()) .unwrap(); let public_glyph_response = app.clone().oneshot(public_glyph_request).await.unwrap(); @@ -400,6 +412,7 @@ async fn test_list_and_get_font_use_camel_case_contract() { assert!(item.get("startCp").is_some()); assert!(item.get("endCp").is_some()); assert!(item.get("isPublic").is_some()); + assert!(item.get("workspaceSlug").is_some()); assert!(item.get("createdAt").is_some()); assert!(item.get("glyph_count").is_none()); assert!(item.get("is_public").is_none()); @@ -415,6 +428,7 @@ async fn test_list_and_get_font_use_camel_case_contract() { let got: Value = serde_json::from_slice(&get_body).unwrap(); assert!(got.get("glyphCount").is_some()); assert!(got.get("isPublic").is_some()); + assert!(got.get("workspaceSlug").is_some()); assert!(got.get("glyph_count").is_none()); assert!(got.get("is_public").is_none()); } diff --git a/docs/dev/behaviors.md b/docs/dev/behaviors.md index 83217728..0f2ba1b3 100644 --- a/docs/dev/behaviors.md +++ b/docs/dev/behaviors.md @@ -12,6 +12,7 @@ - 基于角色的权限控制(admin/user) - **工作空间隔离**:用户只能访问所属工作空间的数据 - **公开瓦片服务**:发布后的文件可通过公共 URL 访问,无需认证 +- **公开字体服务**:发布后的字体字形可通过公共 URL 访问,无需认证(按 workspace slug + font slug 路由) **核心概念:** - **工作空间**:数据隔离单元,每个文件属于一个工作空间 @@ -38,6 +39,12 @@ ## 行为契约表 +### 与本次改动相关的 User Stories(可观测) + +1. 作为工作空间 owner,我可以在设置页管理 workspace slug,并立即在 API 读模型中看到新 slug。 +2. 作为字体发布者,我发布字体后拿到可直接写入 Mapbox Style 的 URL 模板:`/fonts/{workspaceSlug}/{fontstack}/{range}.pbf`。 +3. 作为公开访问者,我只能访问“未归档 workspace + 已发布字体”的 glyph 资源;workspace 归档后公开地址立刻失效。 + | ID | 模块 | 可观测行为 | 验证标准 | 验证命令 | 层级 | 优先级 | |----|------|-----------|---------|---------|------|--------| | CLI-001 | 服务启动 | 支持 `--listen [host]:port` 和 `LISTEN` 环境变量。`:port` 表示监听所有接口。CLI 优先于环境变量。默认 `:3000` | 绑定到指定地址 | `cargo test test_listen_*` | Unit | P1 | @@ -66,6 +73,9 @@ | API-017 | 系统设置 | GET/PATCH /api/settings 需要认证。GET 返回 `{maxSizeMb}`。PATCH 仅 admin 可用,更新上传大小限制(最小 1MB),持久化到 system_settings 表,重启后保留 | 200 + `{maxSizeMb}` / 400(值无效) / 401 / 403(非admin) | `cargo test test_get_settings_* test_update_settings_*` | Integration | P1 | | API-018 | PostGIS 连接测试 | POST /api/postgis/connections/test 需要认证。请求体 `{connection:{host,port,database,username,password,sslMode}}`。MVP 仅允许 `sslMode=disable`,成功时返回 PostgreSQL 与 PostGIS 版本 | 200 + `{success,serverVersion,postgisVersion}` / 400(连接失败或参数非法) / 401 | `bash scripts/test/postgis-integration.sh` | Integration | P1 | | API-019 | PostGIS 源注册 | POST /api/postgis/sources/register 需要认证。请求体包含 `connectionName/schema/object/geometryColumn/fidColumn` 等,校验 relation 与列合法后创建 `files` 记录(`type=postgis`,`tileSource=postgis`,`status=ready`)并写入 `postgis_connections/postgis_sources/dataset_columns`。当 relation 当前无非空几何时,只要 geometry 列元数据可推断出正 SRID(typmod/geometry_columns),也允许注册 | 201 + `{fileId,status}` / 400(对象或列校验失败) / 401 / 500(内部存储或加解密失败) | `bash scripts/test/postgis-integration.sh` | Integration | P0 | +| API-020 | 字体上传与查询 | `POST /api/fonts` 上传 `.ttf/.otf`,异步处理后 `GET /api/fonts` / `GET /api/fonts/:id` 返回字体元数据(含 `workspaceSlug`) | 200 + 字体元数据 / 400(格式错误) / 401 | `cargo test --manifest-path backend/Cargo.toml --test font_api_tests test_list_and_get_font_use_camel_case_contract` | Integration | P1 | +| API-021 | 字体发布与取消发布 | `POST /api/fonts/:id/publish` 返回 `{url,slug,isPublic,workspaceSlug}`(其中 `url` 模板不含 `fontSlug`);`POST /api/fonts/:id/unpublish` 取消发布后公开地址失效 | 200 / 204 / 400(冲突) / 404 / 409(非 ready) | `cargo test --manifest-path backend/Cargo.toml --test font_api_tests test_font_upload_publish_and_public_glyph_lifecycle` | Integration | P1 | +| API-022 | 公开字体字形 | `GET /fonts/:workspaceSlug/:fontstack/:range` **无需认证**;仅当 workspace 未归档且存在已发布 fontstack 时返回 pbf 数据 | 200 + `application/x-protobuf` / 400(range 无效) / 404 | `cargo test test_public_font_glyphs_blocked_when_workspace_archived` + `cargo test --manifest-path backend/Cargo.toml --test font_api_tests test_font_upload_publish_and_public_glyph_lifecycle` | Integration | P1 | | AUTH-001 | 首次设置 | POST /api/auth/init 创建初始管理员 | 200 / 400 / 409 / 500 | `frontend/tests/auth.spec.js` | E2E | P0 | | AUTH-002 | 登录 | POST /api/auth/login 验证凭证,设置会话 | 200 / 401 / 500 | `frontend/tests/auth.spec.js` | E2E | P0 | | AUTH-003 | 登出 | POST /api/auth/logout 清除会话 | 204 / 500 | `frontend/tests/auth.spec.js` | E2E | P0 | @@ -115,15 +125,16 @@ | OSM-004 | 瓦片生成(simple polygons) | OSM sf_simple_polygons(10,000 简单多边形,Polygon几何)数据集生成正确瓦片(z=0,10,14 各 5 个样本) | 特征计数匹配 golden 配置 | `cargo test test_tile_golden_osm_simple_polygons_samples` | Integration | P1 | | OSM-005 | 瓦片生成(multipoints) | OSM sf_multipoints(402 多点要素,MultiPoint几何)数据集生成正确瓦片(z=0,10,14 各 5 个样本) | 特征计数匹配 golden 配置 | `cargo test test_tile_golden_osm_multipoints_samples` | Integration | P1 | | OSM-006 | 瓦片生成(multilinestrings) | OSM sf_multilinestrings(511 多线要素,MultiLineString几何)数据集生成正确瓦片(z=0,10,14 各 5 个样本) | 特征计数匹配 golden 配置 | `cargo test test_tile_golden_osm_multilinestrings_samples` | Integration | P1 | -| WS-001 | 工作空间创建 | POST /api/workspaces 创建工作空间,名称全局唯一(3-50字符),创建者自动成为 owner 和成员 | 201 + `{id,name,ownerId,isPersonal}` / 400(名称无效) / 409(名称冲突) / 401 | `cargo test test_create_workspace*` | Integration | P0 | -| WS-002 | 工作空间列表 | GET /api/workspaces 返回用户所属的所有工作空间(不含已删除) | 200 + `[{id,name,ownerId,isPersonal,memberCount,createdAt}]` / 401 | `cargo test test_list_members*` | Integration | P0 | +| WS-001 | 工作空间创建 | POST /api/workspaces 创建工作空间,名称全局唯一(3-50字符),可选 `slug`;创建者自动成为 owner 和成员 | 201 + `{id,name,slug,ownerId,isPersonal}` / 400(名称/slug 无效) / 409(名称冲突) / 401 | `cargo test test_create_workspace*` | Integration | P0 | +| WS-002 | 工作空间列表 | GET /api/workspaces 返回用户所属的所有工作空间(不含已删除) | 200 + `[{id,name,slug,ownerId,isPersonal,memberCount,createdAt}]` / 401 | `cargo test test_list_members*` | Integration | P0 | | WS-003 | 工作空间删除 | DELETE /api/workspaces/:id 软删除工作空间(设置 deleted_at,重命名释放原名),仅 owner 可操作,个人工作空间不可删除 | 204 / 401 / 403(非owner/个人空间) / 404 | `cargo test test_delete_workspace*` | Integration | P0 | -| WS-004 | 工作空间恢复 | POST /api/workspaces/:id/restore 恢复已删除工作空间,若原名被占用需提供新名称 | 200 + `{id,name}` / 400(未删除/名称冲突) / 401 / 403 / 404 | `cargo test test_restore_workspace*` | Integration | P1 | -| WS-005 | 工作空间切换 | PUT /api/session/workspace 切换当前工作空间,更新 session 中的 currentWorkspaceId | 200 + `{workspaceId}` / 401 / 403(不属于该工作空间) / 404 | `cargo test test_switch_workspace*` | Integration | P0 | +| WS-004 | 工作空间恢复 | POST /api/workspaces/:id/restore 恢复已删除工作空间,若原名被占用需提供新名称;恢复时重建可用 slug | 200 + `{id,name,slug}` / 400(未删除/名称或 slug 冲突) / 401 / 403 / 404 | `cargo test test_restore_workspace*` | Integration | P1 | +| WS-005 | 工作空间切换 | PUT /api/workspaces/current 切换当前工作空间,更新 session 中的 currentWorkspaceId | 200 + `{id,name,slug,isPersonal}` / 401 / 403(不属于该工作空间) / 404 | `cargo test test_switch_workspace*` | Integration | P0 | | WS-006 | 成员邀请 | POST /api/workspaces/:id/members 通过用户名邀请成员,任意成员可邀请 | 201 + `{userId,username}` / 400(缺少用户名) / 404(用户不存在) / 409(已是成员) / 401 / 403 | `cargo test test_invite_member*` | Integration | P0 | | WS-007 | 成员移除 | DELETE /api/workspaces/:id/members/:userId 移除成员。Owner 可移除任何人(除自己),成员可移除自己(离开) | 204 / 401 / 403(移除owner) / 404 | `cargo test test_remove_member*` | Integration | P0 | | WS-008 | 数据隔离 | 文件 API(上传/列表/预览/瓦片/发布)自动过滤 currentWorkspaceId,跨工作空间访问返回 404 | 404 / 401 | `cargo test test_*workspace*` | Integration | P0 | | WS-009 | 个人工作空间 | POST /api/auth/init 注册成功后自动创建个人工作空间(名称:`{username}的个人空间`),用户成为 owner 和成员 | 201 + 用户信息,含工作空间 | `cargo test test_init_system_sets_current_workspace_to_personal_workspace` | Integration | P0 | +| WS-010 | 工作空间 slug 管理 | PUT /api/workspaces/:id 支持仅更新 `slug`;owner 可修改,返回最新 `slug`,并在 GET/list/current/auth-check 可观测到 | 200 + `{id,name,slug}` / 400(slug 非法或冲突) / 403 / 404 | `cargo test test_update_workspace_slug_persists_and_visible_in_read_models` | Integration | P1 | ## 参考 diff --git a/docs/internal.md b/docs/internal.md index 94362d99..10d780c3 100644 --- a/docs/internal.md +++ b/docs/internal.md @@ -18,8 +18,13 @@ Windows 桌面集成: - 用户可属于多个工作空间(多对多:workspace_members) - Session 存储 currentWorkspaceId,所有文件操作基于此过滤 - 个人工作空间:注册时自动创建,不可删除,名称格式 `{username}的个人空间` +- 工作空间对外标识使用 `workspaces.slug`(可由 owner 调整);管理 API 仍使用 workspace id - 公开瓦片 URL 通过 slug 访问,不依赖工作空间过滤 +**字体资源公开地址:** +- 字体管理 API 使用 `/api/fonts/:id`(id 语义稳定) +- 公开 glyph URL 使用 `/fonts/:workspaceSlug/:fontstack/:range`(对 style JSON 更稳定,不暴露单字体 slug) + **MBTiles 支持:** - MBTiles 文件不导入 DuckDB,直接读取原始 SQLite 文件 - 通过 `tile_format` 字段区分动态(NULL)、MVT、PNG diff --git a/frontend/src/FontsPanel.jsx b/frontend/src/FontsPanel.jsx index 536653f6..d29d65eb 100644 --- a/frontend/src/FontsPanel.jsx +++ b/frontend/src/FontsPanel.jsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useState, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { listFonts, uploadFont, deleteFont, publishFont, unpublishFont } from './api.js'; -import { validateSlug } from './utils.js'; const FONT_STATUS_LABELS = { processing: 'font.status.processing', @@ -21,7 +20,6 @@ export default function FontsPanel() { const [selectedId, setSelectedId] = useState(null); const [errorMessage, setErrorMessage] = useState(''); const [isUploading, setIsUploading] = useState(false); - const [publishSlug, setPublishSlug] = useState(''); const [publishError, setPublishError] = useState(''); const [isPublishing, setIsPublishing] = useState(false); const [copySuccess, setCopySuccess] = useState(false); @@ -110,8 +108,7 @@ export default function FontsPanel() { setPublishError(''); setIsPublishing(true); try { - await publishFont(fontId, { slug: publishSlug.trim() || undefined }); - setPublishSlug(''); + await publishFont(fontId); await refreshFonts(); } catch (err) { setPublishError(err.message || t('font.publishFailed')); @@ -130,8 +127,12 @@ export default function FontsPanel() { } } - function copyPublicUrl(slug) { - const url = `${window.location.origin}/fonts/${slug}/glyphs/{fontstack}/{range}.pbf`; + function buildPublicPath(workspaceSlug) { + return `/fonts/${workspaceSlug}/{fontstack}/{range}.pbf`; + } + + function copyPublicUrl(workspaceSlug) { + const url = `${window.location.origin}${buildPublicPath(workspaceSlug)}`; navigator.clipboard .writeText(url) .then(() => { @@ -143,13 +144,6 @@ export default function FontsPanel() { }); } - const slugValidationError = useMemo(() => { - return validateSlug(publishSlug.trim(), { - tooLong: t('file.detail.slugTooLong'), - invalidChars: t('file.detail.slugInvalidChars'), - }).error; - }, [publishSlug, t]); - return (
{errorMessage &&
{errorMessage}
} @@ -289,43 +283,12 @@ export default function FontsPanel() {
{t('font.notPublished')} -
- - setPublishSlug(e.target.value)} - placeholder={selectedFont.id} - className="form-input" - style={{ width: '100%' }} - data-testid="font-publish-slug-input" - /> - {slugValidationError && ( -
- {slugValidationError} -
- )} - {t('font.publishSlugHint')} -
-
{t('font.publicUrl')}
- /fonts/{publishSlug.trim() || selectedFont.id}/glyphs/{'{fontstack}'}/ - {'{range}'}.pbf + {buildPublicPath(selectedFont.workspaceSlug || 'workspace')}
@@ -339,7 +302,7 @@ export default function FontsPanel() { type="button" className="btn-primary" style={{ fontSize: '12px', padding: '4px 12px' }} - disabled={isPublishing || !!slugValidationError} + disabled={isPublishing} onClick={() => handlePublish(selectedFont.id)} data-testid="font-publish-button" > @@ -364,13 +327,15 @@ export default function FontsPanel() {
- /fonts/{selectedFont.slug}/glyphs/{'{fontstack}'}/{'{range}'}.pbf + {buildPublicPath(selectedFont.workspaceSlug || 'workspace')}
@@ -363,6 +427,38 @@ export default function Settings() { > {t('workspace.memberCount', { count: ws.memberCount })}
+
+ {t('workspace.slugLabel')}: {ws.slug || '-'} +
+ {editingSlugWorkspaceId === ws.id && ( +
+ setEditingSlugValue(e.target.value)} + className="form-input" + style={{ width: '100%' }} + placeholder="team-alpha" + /> + {slugActionError && ( +
+ {slugActionError} +
+ )} +
+ )}
+ {ws.ownerId === user.id && + (editingSlugWorkspaceId === ws.id ? ( + <> + + + + ) : ( + + ))} {!ws.isPersonal && ws.ownerId === user.id && (
+
+ + setNewWorkspaceSlug(e.target.value)} + className="form-input" + style={{ width: '100%' }} + placeholder="team-alpha" + /> + {t('workspace.slugHint')} +
+ {createError && ( +
+ {createError} +
+ )}
setShowCreateModal(false)} + onClick={() => { + setShowCreateModal(false); + setCreateError(''); + setNewWorkspaceName(''); + setNewWorkspaceSlug(''); + }} > {t('common.cancel')} diff --git a/frontend/src/api.js b/frontend/src/api.js index 59c51015..fd313867 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -176,11 +176,14 @@ export async function listWorkspaces() { return res.json(); } -export async function createWorkspace(name) { +export async function createWorkspace(name, options = {}) { + const body = { name }; + if (options.slug) body.slug = options.slug; + const res = await fetchWithAuth('/api/workspaces', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name }), + body: JSON.stringify(body), }); if (!res.ok) { const data = await res.json().catch(() => ({})); @@ -189,11 +192,18 @@ export async function createWorkspace(name) { return res.json(); } -export async function updateWorkspace(workspaceId, name) { +export async function updateWorkspace(workspaceId, updates) { + const body = + typeof updates === 'string' + ? { name: updates } + : { + ...(updates || {}), + }; + const res = await fetchWithAuth(`/api/workspaces/${workspaceId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name }), + body: JSON.stringify(body), }); if (!res.ok) { const data = await res.json().catch(() => ({})); diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index d8b84f12..676cedf6 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -351,6 +351,14 @@ "noWorkspaces": "No workspaces", "personalBadge": "Personal", "memberCount": "{{count}} member(s)", + "slugLabel": "Slug", + "slugLabelOptional": "Workspace Slug (Optional)", + "slugHint": "Only lowercase letters, numbers, and hyphens. Example: team-alpha", + "editSlug": "Edit Slug", + "slugTooLong": "Workspace slug cannot exceed 63 characters", + "slugTooShort": "Workspace slug must be at least 3 characters", + "slugInvalidChars": "Only lowercase letters, numbers, and hyphens are supported", + "slugEdgeDash": "Workspace slug cannot start or end with a hyphen", "manageMembers": "Manage members", "delete": "Delete", "leave": "Leave", diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index 8e0438b4..d6268059 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -351,6 +351,14 @@ "noWorkspaces": "暂无工作空间", "personalBadge": "个人", "memberCount": "{{count}} 个成员", + "slugLabel": "Slug", + "slugLabelOptional": "工作空间 Slug(可选)", + "slugHint": "仅支持小写字母、数字和连字符,例如:team-alpha", + "editSlug": "编辑 Slug", + "slugTooLong": "工作空间 slug 不能超过 63 个字符", + "slugTooShort": "工作空间 slug 至少需要 3 个字符", + "slugInvalidChars": "工作空间 slug 仅支持小写字母、数字和连字符", + "slugEdgeDash": "工作空间 slug 不能以连字符开头或结尾", "manageMembers": "管理成员", "delete": "删除", "leave": "离开", diff --git a/frontend/src/utils.js b/frontend/src/utils.js index c2b631fc..8775e509 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -36,3 +36,32 @@ export function validateSlug(slug, messages = {}) { } return { valid: true, error: '' }; } + +export function validateWorkspaceSlug(slug, messages = {}) { + if (!slug) return { valid: true, error: '' }; + if (slug.length > 63) { + return { + valid: false, + error: messages.tooLong || 'Workspace slug cannot exceed 63 characters', + }; + } + if (slug.length < 3) { + return { + valid: false, + error: messages.tooShort || 'Workspace slug must be at least 3 characters', + }; + } + if (!/^[a-z0-9-]+$/.test(slug)) { + return { + valid: false, + error: messages.invalidChars || 'Only lowercase letters, numbers, and hyphens are supported', + }; + } + if (slug.startsWith('-') || slug.endsWith('-')) { + return { + valid: false, + error: messages.edgeDash || 'Workspace slug cannot start or end with a hyphen', + }; + } + return { valid: true, error: '' }; +} From 44aa2e6216931124fe135611f34910546c518137 Mon Sep 17 00:00:00 2001 From: Lucas Date: Sat, 28 Mar 2026 23:17:52 +0800 Subject: [PATCH 11/15] fix(fonts): support comma fontstack requests and tighten workspace slug edit validation --- backend/src/font_handlers.rs | 187 +++++++++++++++++++++--------- backend/tests/font_api_tests.rs | 22 ++++ docs/dev/behaviors.md | 2 +- frontend/src/Settings.jsx | 4 + frontend/src/i18n/locales/en.json | 1 + frontend/src/i18n/locales/zh.json | 1 + 6 files changed, 164 insertions(+), 53 deletions(-) diff --git a/backend/src/font_handlers.rs b/backend/src/font_handlers.rs index 1c8be095..f6000745 100644 --- a/backend/src/font_handlers.rs +++ b/backend/src/font_handlers.rs @@ -7,7 +7,10 @@ use axum::{ use axum_login::AuthSession; use chrono::Utc; use duckdb::OptionalExt; +use pbf_font_tools::prost::Message; +use pbf_font_tools::Glyphs; use serde::{Deserialize, Serialize}; +use std::collections::HashSet; use std::path::Path; use tokio::{ fs, @@ -536,13 +539,19 @@ fn parse_glyph_range(range: &str) -> Option<(u32, u32)> { Some((start, end)) } -fn normalize_requested_fontstack(fontstack: &str) -> &str { - fontstack - .split(',') - .next() - .map(str::trim) - .filter(|s| !s.is_empty()) - .unwrap_or(fontstack) +fn parse_requested_fontstacks(fontstack: &str) -> Vec { + let mut seen = HashSet::new(); + let mut parsed = Vec::new(); + for part in fontstack.split(',') { + let trimmed = part.trim(); + if trimmed.is_empty() { + continue; + } + if seen.insert(trimmed.to_string()) { + parsed.push(trimmed.to_string()); + } + } + parsed } pub async fn publish_font( @@ -679,82 +688,147 @@ pub async fn get_public_glyph( ) -> Result)> { let (start, end) = parse_glyph_range(&range) .ok_or_else(|| bad_request("Invalid glyph range format, expected -.pbf"))?; - let normalized_fontstack = normalize_requested_fontstack(&fontstack).to_string(); + let requested_fontstacks = parse_requested_fontstacks(&fontstack); + if requested_fontstacks.is_empty() { + return Err(bad_request("Invalid fontstack")); + } let conn = state.db.lock().await; - let glyphs_path: Option = conn - .query_row( - "SELECT f.glyphs_path - FROM fonts f - JOIN workspaces w ON w.id = f.workspace_id - WHERE COALESCE(w.slug, w.id) = ? - AND f.fontstack = ? - AND f.is_public = TRUE - AND f.status = 'ready' - AND w.deleted_at IS NULL", - duckdb::params![&workspace_slug, &normalized_fontstack], - |row| row.get(0), - ) - .optional() - .map_err(internal_error)?; + let mut glyphs_paths = Vec::new(); + for requested_fontstack in &requested_fontstacks { + let glyphs_path: Option = conn + .query_row( + "SELECT f.glyphs_path + FROM fonts f + JOIN workspaces w ON w.id = f.workspace_id + WHERE COALESCE(w.slug, w.id) = ? + AND f.fontstack = ? + AND f.is_public = TRUE + AND f.status = 'ready' + AND w.deleted_at IS NULL", + duckdb::params![&workspace_slug, requested_fontstack], + |row| row.get(0), + ) + .optional() + .map_err(internal_error)?; + if let Some(path) = glyphs_path { + glyphs_paths.push(path); + } + } drop(conn); - let Some(glyphs_path) = glyphs_path else { + if glyphs_paths.is_empty() { return Err(( StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Font not found".to_string(), }), )); - }; + } - let glyphs_path = glyphs_path.trim_start_matches("./uploads/"); - let pbf_path = state - .upload_dir - .join(glyphs_path) - .join(format!("{}-{}.pbf", start, end)); - - let canonical_path = match fs::canonicalize(&pbf_path).await { - Ok(path) => path, - Err(_) => { + let should_merge = requested_fontstacks.len() > 1 || glyphs_paths.len() > 1; + if !should_merge { + let glyphs_path = glyphs_paths[0].trim_start_matches("./uploads/"); + let pbf_path = state + .upload_dir + .join(glyphs_path) + .join(format!("{}-{}.pbf", start, end)); + + let canonical_path = match fs::canonicalize(&pbf_path).await { + Ok(path) => path, + Err(_) => { + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Glyph range not found".to_string(), + }), + )) + } + }; + + if !canonical_path.starts_with(&state.upload_dir_canonical) { return Err(( + StatusCode::FORBIDDEN, + Json(ErrorResponse { + error: "Access denied".to_string(), + }), + )); + } + + return match fs::read(&canonical_path).await { + Ok(data) => Ok(( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/x-protobuf")], + data, + )), + Err(_) => Err(( StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Glyph range not found".to_string(), }), - )) + )), + }; + } + + let mut merged_glyphs = Glyphs::default(); + let mut has_any_stack = false; + + for glyphs_path in glyphs_paths { + let glyphs_path = glyphs_path.trim_start_matches("./uploads/"); + let pbf_path = state + .upload_dir + .join(glyphs_path) + .join(format!("{}-{}.pbf", start, end)); + + let canonical_path = match fs::canonicalize(&pbf_path).await { + Ok(path) => path, + Err(_) => continue, + }; + + if !canonical_path.starts_with(&state.upload_dir_canonical) { + return Err(( + StatusCode::FORBIDDEN, + Json(ErrorResponse { + error: "Access denied".to_string(), + }), + )); } - }; - if !canonical_path.starts_with(&state.upload_dir_canonical) { - return Err(( - StatusCode::FORBIDDEN, - Json(ErrorResponse { - error: "Access denied".to_string(), - }), - )); + let bytes = match fs::read(&canonical_path).await { + Ok(data) => data, + Err(_) => continue, + }; + + let glyphs = Glyphs::decode(bytes.as_slice()).map_err(internal_error)?; + if glyphs.stacks.is_empty() { + continue; + } + + has_any_stack = true; + merged_glyphs.stacks.extend(glyphs.stacks); } - match fs::read(&canonical_path).await { - Ok(data) => Ok(( - StatusCode::OK, - [(header::CONTENT_TYPE, "application/x-protobuf")], - data, - )), - Err(_) => Err(( + if !has_any_stack { + return Err(( StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Glyph range not found".to_string(), }), - )), + )); } + + Ok(( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/x-protobuf")], + merged_glyphs.encode_to_vec(), + )) } #[cfg(test)] mod tests { - use super::{parse_glyph_range, FontItem}; + use super::{parse_glyph_range, parse_requested_fontstacks, FontItem}; #[test] fn parse_glyph_range_accepts_valid_patterns() { @@ -771,6 +845,15 @@ mod tests { assert_eq!(parse_glyph_range("0-255/extra"), None); } + #[test] + fn parse_requested_fontstacks_splits_and_deduplicates() { + assert_eq!( + parse_requested_fontstacks("Noto Sans Regular, Noto Sans Regular , Arial Unicode"), + vec!["Noto Sans Regular".to_string(), "Arial Unicode".to_string()] + ); + assert_eq!(parse_requested_fontstacks(" , "), Vec::::new()); + } + #[test] fn font_item_serializes_as_camel_case() { let item = FontItem { diff --git a/backend/tests/font_api_tests.rs b/backend/tests/font_api_tests.rs index 53e46d5b..3954565e 100644 --- a/backend/tests/font_api_tests.rs +++ b/backend/tests/font_api_tests.rs @@ -214,6 +214,28 @@ async fn test_font_upload_publish_and_public_glyph_lifecycle() { Some("application/x-protobuf") ); + let fallback_fontstack_request = Request::builder() + .method("GET") + .uri("/fonts/test-workspace/Press%20Start%202P%20Regular,Missing%20Fallback/0-255.pbf") + .body(Body::empty()) + .unwrap(); + let fallback_fontstack_response = app + .clone() + .oneshot(fallback_fontstack_request) + .await + .unwrap(); + assert_eq!( + fallback_fontstack_response.status(), + axum::http::StatusCode::OK + ); + assert_eq!( + fallback_fontstack_response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()), + Some("application/x-protobuf") + ); + let invalid_range_request = Request::builder() .method("GET") .uri("/fonts/test-workspace/Press%20Start%202P%20Regular/0-999.pbf") diff --git a/docs/dev/behaviors.md b/docs/dev/behaviors.md index 0f2ba1b3..ba6e8043 100644 --- a/docs/dev/behaviors.md +++ b/docs/dev/behaviors.md @@ -12,7 +12,7 @@ - 基于角色的权限控制(admin/user) - **工作空间隔离**:用户只能访问所属工作空间的数据 - **公开瓦片服务**:发布后的文件可通过公共 URL 访问,无需认证 -- **公开字体服务**:发布后的字体字形可通过公共 URL 访问,无需认证(按 workspace slug + font slug 路由) +- **公开字体服务**:发布后的字体字形可通过公共 URL 访问,无需认证(按 workspace slug + fontstack + range 路由) **核心概念:** - **工作空间**:数据隔离单元,每个文件属于一个工作空间 diff --git a/frontend/src/Settings.jsx b/frontend/src/Settings.jsx index 1db4a5b7..7fee7f38 100644 --- a/frontend/src/Settings.jsx +++ b/frontend/src/Settings.jsx @@ -223,6 +223,10 @@ export default function Settings() { async function saveWorkspaceSlug(workspaceId) { const slug = editingSlugValue.trim(); + if (!slug) { + setSlugActionError(t('workspace.slugRequired')); + return; + } const slugError = getWorkspaceSlugValidationError(slug); if (slugError) { setSlugActionError(slugError); diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 676cedf6..d72e8f50 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -354,6 +354,7 @@ "slugLabel": "Slug", "slugLabelOptional": "Workspace Slug (Optional)", "slugHint": "Only lowercase letters, numbers, and hyphens. Example: team-alpha", + "slugRequired": "Workspace slug is required when editing", "editSlug": "Edit Slug", "slugTooLong": "Workspace slug cannot exceed 63 characters", "slugTooShort": "Workspace slug must be at least 3 characters", diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index d6268059..2d598c50 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -354,6 +354,7 @@ "slugLabel": "Slug", "slugLabelOptional": "工作空间 Slug(可选)", "slugHint": "仅支持小写字母、数字和连字符,例如:team-alpha", + "slugRequired": "编辑时必须填写工作空间 slug", "editSlug": "编辑 Slug", "slugTooLong": "工作空间 slug 不能超过 63 个字符", "slugTooShort": "工作空间 slug 至少需要 3 个字符", From 3045ba797d9b922367983c0960e93ed6795e91d3 Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 30 Mar 2026 11:41:21 +0800 Subject: [PATCH 12/15] =?UTF-8?q?refactor(fonts):=20address=20PR=20#190=20?= =?UTF-8?q?review=20=E2=80=94=20fix=20timestamps,=20extract=20shared=20hel?= =?UTF-8?q?pers,=20improve=20robustness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use CURRENT_TIMESTAMP for font created_at and published_at instead of Rust-side Utc::now(), consistent with the rest of the codebase - Extract test-mode workspace bootstrap from font_handlers.rs and upload.rs into workspace::ensure_test_mode_workspace(), eliminating ~100 lines of duplicated code - Extract read_font_row() helper to deduplicate the 13-column FontItem row mapping used in list_fonts and get_font - Log DB errors in update_font_error instead of silently discarding them - Save uploaded font with original file extension (e.g. original.ttf) for easier filesystem inspection --- backend/src/font_handlers.rs | 151 +++++++++-------------------------- backend/src/upload.rs | 82 ++----------------- backend/src/workspace.rs | 55 +++++++++++++ 3 files changed, 101 insertions(+), 187 deletions(-) diff --git a/backend/src/font_handlers.rs b/backend/src/font_handlers.rs index f6000745..5dd9353f 100644 --- a/backend/src/font_handlers.rs +++ b/backend/src/font_handlers.rs @@ -5,7 +5,6 @@ use axum::{ Json, }; use axum_login::AuthSession; -use chrono::Utc; use duckdb::OptionalExt; use pbf_font_tools::prost::Message; use pbf_font_tools::Glyphs; @@ -75,69 +74,16 @@ async fn get_workspace_id( Ok(workspace_id) } - None => { - if std::env::var("MAPFLOW_TEST_MODE").as_deref() == Ok("1") { - let conn = state.db.lock().await; - - let workspace_id: Option = conn - .query_row( - "SELECT id FROM workspaces WHERE is_personal = true AND deleted_at IS NULL LIMIT 1", - [], - |row| row.get(0), - ) - .ok() - .flatten(); - - if let Some(wid) = workspace_id { - drop(conn); - return Ok(wid); - } - - let existing_user_id: Option = conn - .query_row("SELECT id FROM users LIMIT 1", [], |row| row.get(0)) - .ok() - .flatten(); - - let user_id = match existing_user_id { - Some(uid) => uid, - None => { - let new_user_id = uuid::Uuid::new_v4().to_string(); - conn.execute( - "INSERT INTO users (id, username, password_hash, role, current_workspace_id, created_at) VALUES (?, ?, '', 'user', NULL, CURRENT_TIMESTAMP)", - duckdb::params![&new_user_id, format!("test_user_{}", &new_user_id[..8])], - ).ok(); - new_user_id - } - }; - - let workspace_id = uuid::Uuid::new_v4().to_string(); - let workspace_name = "Test Workspace".to_string(); - let workspace_slug = crate::workspace::workspace_slug_base_from_name_or_id( - &workspace_name, - &workspace_id, - ); - - conn.execute( - "INSERT INTO workspaces (id, name, slug, owner_id, is_personal, created_at) VALUES (?, ?, ?, ?, true, CURRENT_TIMESTAMP)", - duckdb::params![&workspace_id, &workspace_name, &workspace_slug, &user_id], - ).ok(); - - conn.execute( - "INSERT INTO workspace_members (workspace_id, user_id, joined_at) VALUES (?, ?, CURRENT_TIMESTAMP)", - duckdb::params![&workspace_id, &user_id], - ).ok(); - - drop(conn); - Ok(workspace_id) - } else { - Err(( + None => crate::workspace::ensure_test_mode_workspace(&state.db) + .await + .ok_or_else(|| { + ( StatusCode::UNAUTHORIZED, Json(ErrorResponse { error: "Not authenticated".to_string(), }), - )) - } - } + ) + }), } } @@ -229,7 +175,7 @@ pub async fn upload_font( .await .map_err(internal_error)?; - let original_path = fonts_dir.join("original"); + let original_path = fonts_dir.join(format!("original.{}", ext)); let mut file = BufWriter::new( fs::File::create(&original_path) .await @@ -255,10 +201,9 @@ pub async fn upload_font( drop(file); let glyphs_dir = fonts_dir.join("glyphs"); - let original_rel = format!("./uploads/fonts/{}/original", &font_id); + let original_rel = format!("./uploads/fonts/{}/original.{}", &font_id, ext); let glyphs_rel = format!("./uploads/fonts/{}/glyphs", &font_id); - let created_at = Utc::now().to_rfc3339(); let display_name = Path::new(&safe_name) .file_stem() .and_then(|n| n.to_str()) @@ -268,7 +213,7 @@ pub async fn upload_font( let conn = state.db.lock().await; conn.execute( "INSERT INTO fonts (id, workspace_id, name, fontstack, original_path, glyphs_path, status, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, CURRENT_TIMESTAMP)", duckdb::params![ &font_id, &workspace_id, @@ -277,7 +222,6 @@ pub async fn upload_font( &original_rel, &glyphs_rel, "processing", - &created_at, ], ) .map_err(internal_error)?; @@ -322,10 +266,12 @@ pub async fn upload_font( async fn update_font_error(state: &AppState, font_id: &str, error: &str) { let conn = state.db.lock().await; - let _ = conn.execute( + if let Err(e) = conn.execute( "UPDATE fonts SET status = 'failed', error = ? WHERE id = ?", duckdb::params![error, font_id], - ); + ) { + warn!(font_id = %font_id, db_error = %e, "Failed to update font error status"); + } } async fn update_font_ready( @@ -351,6 +297,28 @@ async fn update_font_ready( Ok(()) } +fn read_font_row(row: &duckdb::Row) -> Result { + let created_at: chrono::NaiveDateTime = row.get(13)?; + let published_at: Option = row.get(14)?; + Ok(FontItem { + id: row.get(0)?, + name: row.get(1)?, + fontstack: row.get(2)?, + family: row.get(3)?, + style: row.get(4)?, + glyph_count: row.get(5)?, + start_cp: row.get(6)?, + end_cp: row.get(7)?, + status: row.get(8)?, + error: row.get(9)?, + is_public: row.get(10)?, + slug: row.get(11)?, + workspace_slug: row.get(12)?, + created_at: created_at.and_utc().to_rfc3339(), + published_at: published_at.map(|t| t.and_utc().to_rfc3339()), + }) +} + pub async fn list_fonts( auth_session: AuthSession, State(state): State, @@ -369,27 +337,7 @@ pub async fn list_fonts( .map_err(internal_error)?; let fonts_iter = stmt - .query_map(duckdb::params![&workspace_id], |row| { - let created_at: chrono::NaiveDateTime = row.get(13)?; - let published_at: Option = row.get(14)?; - Ok(FontItem { - id: row.get(0)?, - name: row.get(1)?, - fontstack: row.get(2)?, - family: row.get(3)?, - style: row.get(4)?, - glyph_count: row.get(5)?, - start_cp: row.get(6)?, - end_cp: row.get(7)?, - status: row.get(8)?, - error: row.get(9)?, - is_public: row.get(10)?, - slug: row.get(11)?, - workspace_slug: row.get(12)?, - created_at: created_at.and_utc().to_rfc3339(), - published_at: published_at.map(|t| t.and_utc().to_rfc3339()), - }) - }) + .query_map(duckdb::params![&workspace_id], read_font_row) .map_err(internal_error)?; let mut fonts = Vec::new(); @@ -416,27 +364,7 @@ pub async fn get_font( JOIN workspaces w ON w.id = f.workspace_id WHERE f.id = ? AND f.workspace_id = ?", duckdb::params![&id, &workspace_id], - |row| { - let created_at: chrono::NaiveDateTime = row.get(13)?; - let published_at: Option = row.get(14)?; - Ok(FontItem { - id: row.get(0)?, - name: row.get(1)?, - fontstack: row.get(2)?, - family: row.get(3)?, - style: row.get(4)?, - glyph_count: row.get(5)?, - start_cp: row.get(6)?, - end_cp: row.get(7)?, - status: row.get(8)?, - error: row.get(9)?, - is_public: row.get(10)?, - slug: row.get(11)?, - workspace_slug: row.get(12)?, - created_at: created_at.and_utc().to_rfc3339(), - published_at: published_at.map(|t| t.and_utc().to_rfc3339()), - }) - }, + read_font_row, ) .optional() .map_err(internal_error)?; @@ -611,10 +539,9 @@ pub async fn publish_font( )); } - let published_at = Utc::now().naive_utc(); let result = conn.execute( - "UPDATE fonts SET is_public = TRUE, slug = ?, published_at = ? WHERE id = ? AND workspace_id = ? AND status = 'ready'", - duckdb::params![&slug, published_at, &id, &workspace_id], + "UPDATE fonts SET is_public = TRUE, slug = ?, published_at = CURRENT_TIMESTAMP WHERE id = ? AND workspace_id = ? AND status = 'ready'", + duckdb::params![&slug, &id, &workspace_id], ); match result { diff --git a/backend/src/upload.rs b/backend/src/upload.rs index d6521931..09a6dcfb 100644 --- a/backend/src/upload.rs +++ b/backend/src/upload.rs @@ -12,6 +12,7 @@ use tokio::{ fs, io::{AsyncWriteExt, BufWriter}, }; +use tracing::debug; use tracing::{info_span, Instrument}; use uuid::Uuid; @@ -22,7 +23,6 @@ use crate::{ models::{ErrorResponse, FileItem}, AppState, AuthBackend, }; -use tracing::debug; pub async fn upload_file( auth_session: AuthSession, @@ -76,84 +76,16 @@ pub async fn upload_file( workspace_id } - None => { - debug!("upload_file: no user in session, checking test mode"); - let test_mode = std::env::var("MAPFLOW_TEST_MODE").as_deref() == Ok("1"); - debug!("upload_file: test_mode = {}", test_mode); - if test_mode { - debug!("upload_file: test mode enabled, looking for workspace"); - let conn = state.db.lock().await; - - let workspace_id: Option = conn - .query_row( - "SELECT id FROM workspaces WHERE is_personal = true AND deleted_at IS NULL LIMIT 1", - [], - |row| row.get(0), - ) - .ok() - .flatten(); - - if let Some(wid) = workspace_id { - drop(conn); - debug!( - "upload_file: found existing workspace in test mode: {}", - wid - ); - wid - } else { - debug!("upload_file: no workspace found, creating one"); - - let existing_user_id: Option = conn - .query_row("SELECT id FROM users LIMIT 1", [], |row| row.get(0)) - .ok() - .flatten(); - - let user_id = match existing_user_id { - Some(uid) => uid, - None => { - let new_user_id = uuid::Uuid::new_v4().to_string(); - conn.execute( - "INSERT INTO users (id, username, password_hash, role, current_workspace_id, created_at) VALUES (?, ?, '', 'user', NULL, CURRENT_TIMESTAMP)", - duckdb::params![&new_user_id, format!("test_user_{}", &new_user_id[..8])], - ).ok(); - new_user_id - } - }; - - let new_workspace_id = uuid::Uuid::new_v4().to_string(); - let workspace_name = "Test Workspace".to_string(); - let workspace_slug = crate::workspace::workspace_slug_base_from_name_or_id( - &workspace_name, - &new_workspace_id, - ); - - conn.execute( - "INSERT INTO workspaces (id, name, slug, owner_id, is_personal, created_at) VALUES (?, ?, ?, ?, true, CURRENT_TIMESTAMP)", - duckdb::params![&new_workspace_id, &workspace_name, &workspace_slug, &user_id], - ).ok(); - - conn.execute( - "INSERT INTO workspace_members (workspace_id, user_id, joined_at) VALUES (?, ?, CURRENT_TIMESTAMP)", - duckdb::params![&new_workspace_id, &user_id], - ).ok(); - - drop(conn); - debug!( - "upload_file: created new workspace in test mode: {}", - new_workspace_id - ); - new_workspace_id - } - } else { - debug!("upload_file: not authenticated and not in test mode"); - return Err(( + None => crate::workspace::ensure_test_mode_workspace(&state.db) + .await + .ok_or_else(|| { + ( StatusCode::UNAUTHORIZED, Json(ErrorResponse { error: "Not authenticated".to_string(), }), - )); - } - } + ) + })?, }; let mut field = loop { diff --git a/backend/src/workspace.rs b/backend/src/workspace.rs index 0f537de9..9ecdde58 100644 --- a/backend/src/workspace.rs +++ b/backend/src/workspace.rs @@ -1,5 +1,8 @@ +use std::sync::Arc; + use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Workspace { @@ -191,6 +194,58 @@ pub fn workspace_slug_base_from_name_or_id(name: &str, workspace_id: &str) -> St "workspace-default".to_string() } +pub async fn ensure_test_mode_workspace(db: &Arc>) -> Option { + let conn = db.lock().await; + + let workspace_id: Option = conn + .query_row( + "SELECT id FROM workspaces WHERE is_personal = true AND deleted_at IS NULL LIMIT 1", + [], + |row| row.get(0), + ) + .ok() + .flatten(); + + if let Some(wid) = workspace_id { + drop(conn); + return Some(wid); + } + + let existing_user_id: Option = conn + .query_row("SELECT id FROM users LIMIT 1", [], |row| row.get(0)) + .ok() + .flatten(); + + let user_id = match existing_user_id { + Some(uid) => uid, + None => { + let new_user_id = uuid::Uuid::new_v4().to_string(); + let _ = conn.execute( + "INSERT INTO users (id, username, password_hash, role, current_workspace_id, created_at) VALUES (?, ?, '', 'user', NULL, CURRENT_TIMESTAMP)", + duckdb::params![&new_user_id, format!("test_user_{}", &new_user_id[..8])], + ); + new_user_id + } + }; + + let new_workspace_id = uuid::Uuid::new_v4().to_string(); + let workspace_name = "Test Workspace".to_string(); + let workspace_slug = workspace_slug_base_from_name_or_id(&workspace_name, &new_workspace_id); + + let _ = conn.execute( + "INSERT INTO workspaces (id, name, slug, owner_id, is_personal, created_at) VALUES (?, ?, ?, ?, true, CURRENT_TIMESTAMP)", + duckdb::params![&new_workspace_id, &workspace_name, &workspace_slug, &user_id], + ); + + let _ = conn.execute( + "INSERT INTO workspace_members (workspace_id, user_id, joined_at) VALUES (?, ?, CURRENT_TIMESTAMP)", + duckdb::params![&new_workspace_id, &user_id], + ); + + drop(conn); + Some(new_workspace_id) +} + #[cfg(test)] mod tests { use super::*; From ca37047f2a47ae8dd609e9527bd505f526cc8934 Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 30 Mar 2026 15:20:14 +0800 Subject: [PATCH 13/15] fix(fonts): add LIMIT 1 to public glyph query and unpublish fonts on workspace archive Defense-in-depth: LIMIT 1 prevents query_row().optional() from erroring if multiple rows somehow match the public glyph query. Workspace archive now unpublishes fonts (is_public=FALSE, slug=NULL, published_at=NULL) in the same transaction as file unpublish, closing a gap where fonts remained publicly accessible after archival. --- backend/src/font_handlers.rs | 3 ++- backend/src/workspace_handlers.rs | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/backend/src/font_handlers.rs b/backend/src/font_handlers.rs index 5dd9353f..1cc308a3 100644 --- a/backend/src/font_handlers.rs +++ b/backend/src/font_handlers.rs @@ -633,7 +633,8 @@ pub async fn get_public_glyph( AND f.fontstack = ? AND f.is_public = TRUE AND f.status = 'ready' - AND w.deleted_at IS NULL", + AND w.deleted_at IS NULL + LIMIT 1", duckdb::params![&workspace_slug, requested_fontstack], |row| row.get(0), ) diff --git a/backend/src/workspace_handlers.rs b/backend/src/workspace_handlers.rs index 7d723721..11a686c5 100644 --- a/backend/src/workspace_handlers.rs +++ b/backend/src/workspace_handlers.rs @@ -977,6 +977,15 @@ pub async fn delete_workspace( internal_err(err) })?; + conn.execute( + "UPDATE fonts SET is_public = FALSE, slug = NULL, published_at = NULL WHERE workspace_id = ?", + duckdb::params![&workspace_id], + ) + .map_err(|err| { + conn.execute_batch("ROLLBACK").ok(); + internal_err(err) + })?; + conn.execute( "UPDATE workspaces SET deleted_at = ?, name = ?, slug = NULL WHERE id = ?", duckdb::params![&now, &archived_name, &workspace_id], From 3d0066fb18e27f963e30619ff483a457f9c0cb39 Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 30 Mar 2026 16:17:31 +0800 Subject: [PATCH 14/15] refactor(fonts): return 201 on upload, extract shared workspace auth, dynamic path computation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 1: upload_font now returns 201 Created (matching upload_file semantics). Fix 2: Extract get_active_workspace_id() into workspace.rs — eliminates ~60 lines of duplicated workspace authorization logic between font_handlers and upload. Fix 3: Font stored paths are now computed dynamically relative to upload_dir instead of hardcoding './uploads/' prefix, preventing silent breakage if upload directory config changes. --- backend/src/font_handlers.rs | 101 +++++++++++--------------------- backend/src/upload.rs | 62 +------------------- backend/src/workspace.rs | 61 +++++++++++++++++++ backend/tests/font_api_tests.rs | 4 +- 4 files changed, 100 insertions(+), 128 deletions(-) diff --git a/backend/src/font_handlers.rs b/backend/src/font_handlers.rs index 1cc308a3..aa5c1500 100644 --- a/backend/src/font_handlers.rs +++ b/backend/src/font_handlers.rs @@ -22,6 +22,7 @@ use crate::{ font_processor::{process_font, FontMetadata}, http_errors::{bad_request, internal_error}, models::ErrorResponse, + workspace::get_active_workspace_id, AppState, AuthBackend, }; @@ -29,62 +30,27 @@ fn create_id() -> String { Uuid::new_v4().to_string() } -async fn get_workspace_id( - auth_session: &AuthSession, - state: &AppState, -) -> Result)> { - match &auth_session.user { - Some(user) => { - let workspace_id = user.current_workspace_id.clone().ok_or_else(|| { - ( - StatusCode::CONFLICT, - Json(ErrorResponse { - error: "No active workspace available, please switch workspace".to_string(), - }), - ) - })?; - - let conn = state.db.lock().await; - let active_workspace: Option = conn - .query_row( - r" - SELECT w.id - FROM workspaces w - JOIN workspace_members wm ON w.id = wm.workspace_id - WHERE w.id = ? AND wm.user_id = ? AND w.deleted_at IS NULL - LIMIT 1 - ", - duckdb::params![&workspace_id, &user.id], - |row| row.get(0), - ) - .optional() - .map_err(internal_error)?; - drop(conn); - - if active_workspace.is_none() { - return Err(( - StatusCode::CONFLICT, - Json(ErrorResponse { - error: - "Current workspace is archived or inaccessible, please switch workspace" - .to_string(), - }), - )); - } - - Ok(workspace_id) +fn relative_path_for(absolute: &Path, upload_dir: &Path) -> String { + if let Ok(relative) = absolute.strip_prefix(upload_dir) { + let dir_name = upload_dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("uploads"); + let mut s = format!( + "./{}/{}", + dir_name, + relative.to_string_lossy().replace('\\', "/") + ); + if !s.starts_with('.') { + s = format!("./{s}"); } - None => crate::workspace::ensure_test_mode_workspace(&state.db) - .await - .ok_or_else(|| { - ( - StatusCode::UNAUTHORIZED, - Json(ErrorResponse { - error: "Not authenticated".to_string(), - }), - ) - }), + return s; + } + let mut s = absolute.to_string_lossy().replace('\\', "/"); + if !s.starts_with('.') { + s = format!("./{s}"); } + s } #[derive(Debug, Serialize, Deserialize)] @@ -132,7 +98,7 @@ pub async fn upload_font( State(state): State, mut multipart: Multipart, ) -> Result)> { - let workspace_id = get_workspace_id(&auth_session, &state).await?; + let workspace_id = get_active_workspace_id(&auth_session, &state.db).await?; let mut field = loop { let next = multipart.next_field().await.map_err(|e| { @@ -201,8 +167,8 @@ pub async fn upload_font( drop(file); let glyphs_dir = fonts_dir.join("glyphs"); - let original_rel = format!("./uploads/fonts/{}/original.{}", &font_id, ext); - let glyphs_rel = format!("./uploads/fonts/{}/glyphs", &font_id); + let original_rel = relative_path_for(&original_path, &state.upload_dir); + let glyphs_rel = relative_path_for(&glyphs_dir, &state.upload_dir); let display_name = Path::new(&safe_name) .file_stem() @@ -258,10 +224,13 @@ pub async fn upload_font( } }); - Ok(Json(FontUploadResponse { - id: font_id, - status: "processing".to_string(), - })) + Ok(( + StatusCode::CREATED, + Json(FontUploadResponse { + id: font_id, + status: "processing".to_string(), + }), + )) } async fn update_font_error(state: &AppState, font_id: &str, error: &str) { @@ -323,7 +292,7 @@ pub async fn list_fonts( auth_session: AuthSession, State(state): State, ) -> Result)> { - let workspace_id = get_workspace_id(&auth_session, &state).await?; + let workspace_id = get_active_workspace_id(&auth_session, &state.db).await?; let conn = state.db.lock().await; let mut stmt = conn @@ -354,7 +323,7 @@ pub async fn get_font( State(state): State, AxumPath(id): AxumPath, ) -> Result)> { - let workspace_id = get_workspace_id(&auth_session, &state).await?; + let workspace_id = get_active_workspace_id(&auth_session, &state.db).await?; let conn = state.db.lock().await; let font: Option = conn @@ -387,7 +356,7 @@ pub async fn delete_font( State(state): State, AxumPath(id): AxumPath, ) -> Result)> { - let workspace_id = get_workspace_id(&auth_session, &state).await?; + let workspace_id = get_active_workspace_id(&auth_session, &state.db).await?; let conn = state.db.lock().await; @@ -488,7 +457,7 @@ pub async fn publish_font( AxumPath(id): AxumPath, Json(req): Json, ) -> Result)> { - let workspace_id = get_workspace_id(&auth_session, &state).await?; + let workspace_id = get_active_workspace_id(&auth_session, &state.db).await?; let slug = match req.slug { Some(s) => validate_slug(&s).map_err(|e| bad_request(&e))?, @@ -581,7 +550,7 @@ pub async fn unpublish_font( State(state): State, AxumPath(id): AxumPath, ) -> Result)> { - let workspace_id = get_workspace_id(&auth_session, &state).await?; + let workspace_id = get_active_workspace_id(&auth_session, &state.db).await?; info!(font_id = %id, "Unpublish font request"); diff --git a/backend/src/upload.rs b/backend/src/upload.rs index 09a6dcfb..1067507e 100644 --- a/backend/src/upload.rs +++ b/backend/src/upload.rs @@ -6,13 +6,11 @@ use axum::{ }; use axum_login::AuthSession; use chrono::Utc; -use duckdb::OptionalExt; use std::path::Path; use tokio::{ fs, io::{AsyncWriteExt, BufWriter}, }; -use tracing::debug; use tracing::{info_span, Instrument}; use uuid::Uuid; @@ -21,6 +19,7 @@ use crate::{ import::import_spatial_data, mbtiles, models::{ErrorResponse, FileItem}, + workspace::get_active_workspace_id, AppState, AuthBackend, }; @@ -29,64 +28,7 @@ pub async fn upload_file( State(state): State, mut multipart: Multipart, ) -> Result)> { - debug!("upload_file: starting upload, workspace_id check"); - let workspace_id = match auth_session.user { - Some(ref user) => { - debug!( - "upload_file: user found, current_workspace_id: {:?}", - user.current_workspace_id - ); - let workspace_id = user.current_workspace_id.clone().ok_or_else(|| { - debug!("upload_file: no current workspace set for user"); - ( - StatusCode::CONFLICT, - Json(ErrorResponse { - error: "No active workspace available, please switch workspace".to_string(), - }), - ) - })?; - - let conn = state.db.lock().await; - let active_workspace: Option = conn - .query_row( - r" - SELECT w.id - FROM workspaces w - JOIN workspace_members wm ON w.id = wm.workspace_id - WHERE w.id = ? AND wm.user_id = ? AND w.deleted_at IS NULL - LIMIT 1 - ", - duckdb::params![&workspace_id, &user.id], - |row| row.get(0), - ) - .optional() - .map_err(internal_error)?; - drop(conn); - - if active_workspace.is_none() { - return Err(( - StatusCode::CONFLICT, - Json(ErrorResponse { - error: - "Current workspace is archived or inaccessible, please switch workspace" - .to_string(), - }), - )); - } - - workspace_id - } - None => crate::workspace::ensure_test_mode_workspace(&state.db) - .await - .ok_or_else(|| { - ( - StatusCode::UNAUTHORIZED, - Json(ErrorResponse { - error: "Not authenticated".to_string(), - }), - ) - })?, - }; + let workspace_id = get_active_workspace_id(&auth_session, &state.db).await?; let mut field = loop { let next = multipart.next_field().await.map_err(|e| { diff --git a/backend/src/workspace.rs b/backend/src/workspace.rs index 9ecdde58..ff342247 100644 --- a/backend/src/workspace.rs +++ b/backend/src/workspace.rs @@ -1,9 +1,14 @@ use std::sync::Arc; use chrono::{DateTime, Utc}; +use duckdb::OptionalExt; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; +use crate::http_errors::internal_error; +use crate::models::ErrorResponse; +use crate::AuthBackend; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Workspace { pub id: String, @@ -246,6 +251,62 @@ pub async fn ensure_test_mode_workspace(db: &Arc>) -> Some(new_workspace_id) } +pub async fn get_active_workspace_id( + auth_session: &axum_login::AuthSession, + db: &Arc>, +) -> Result)> { + match &auth_session.user { + Some(user) => { + let workspace_id = user.current_workspace_id.clone().ok_or_else(|| { + ( + axum::http::StatusCode::CONFLICT, + axum::Json(ErrorResponse { + error: "No active workspace available, please switch workspace".to_string(), + }), + ) + })?; + + let conn = db.lock().await; + let active_workspace: Option = conn + .query_row( + r" + SELECT w.id + FROM workspaces w + JOIN workspace_members wm ON w.id = wm.workspace_id + WHERE w.id = ? AND wm.user_id = ? AND w.deleted_at IS NULL + LIMIT 1 + ", + duckdb::params![&workspace_id, &user.id], + |row| row.get(0), + ) + .optional() + .map_err(internal_error)?; + drop(conn); + + if active_workspace.is_none() { + return Err(( + axum::http::StatusCode::CONFLICT, + axum::Json(ErrorResponse { + error: + "Current workspace is archived or inaccessible, please switch workspace" + .to_string(), + }), + )); + } + + Ok(workspace_id) + } + None => ensure_test_mode_workspace(db).await.ok_or_else(|| { + ( + axum::http::StatusCode::UNAUTHORIZED, + axum::Json(ErrorResponse { + error: "Not authenticated".to_string(), + }), + ) + }), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/backend/tests/font_api_tests.rs b/backend/tests/font_api_tests.rs index 3954565e..589c1b0a 100644 --- a/backend/tests/font_api_tests.rs +++ b/backend/tests/font_api_tests.rs @@ -92,7 +92,7 @@ async fn upload_fixture_font(app: &axum::Router) -> String { let upload_response = app.clone().oneshot(upload_request).await.unwrap(); let upload_status = upload_response.status(); - if upload_status != axum::http::StatusCode::OK { + if upload_status != axum::http::StatusCode::CREATED { let upload_body = upload_response .into_body() .collect() @@ -100,7 +100,7 @@ async fn upload_fixture_font(app: &axum::Router) -> String { .unwrap() .to_bytes(); panic!( - "expected upload 200 but got {} with body {}", + "expected upload 201 but got {} with body {}", upload_status, String::from_utf8_lossy(&upload_body) ); From c763f41f0bc795d8080cf98b819012fd8832d80e Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 30 Mar 2026 16:47:10 +0800 Subject: [PATCH 15/15] fix(fonts): cleanup orphan files on DB insert failure, unify slug suffix start, use index names for error classification Fix 1: upload_font now cleans up uploaded file and directory if DB INSERT fails, preventing orphan files on disk. Fix 2: Unify slug collision suffix to start at 1 (was 2 in workspace_handlers.rs), consistent with db.rs. Fix 3: Replace fragile string-matching error classification (err.contains("slug"), err.contains("UNIQUE")) with explicit index name checks (idx_workspaces_slug, idx_fonts_workspace_slug). DuckDB error messages include constraint/index names, making this more robust than generic keyword matching. --- backend/src/font_handlers.rs | 36 ++++++++++++++++++------------- backend/src/handlers.rs | 2 +- backend/src/workspace_handlers.rs | 8 +++---- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/backend/src/font_handlers.rs b/backend/src/font_handlers.rs index aa5c1500..4ad842da 100644 --- a/backend/src/font_handlers.rs +++ b/backend/src/font_handlers.rs @@ -177,22 +177,28 @@ pub async fn upload_font( .to_string(); let conn = state.db.lock().await; - conn.execute( - "INSERT INTO fonts (id, workspace_id, name, fontstack, original_path, glyphs_path, status, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, CURRENT_TIMESTAMP)", - duckdb::params![ - &font_id, - &workspace_id, - &display_name, - &display_name, - &original_rel, - &glyphs_rel, - "processing", - ], - ) - .map_err(internal_error)?; + let insert_result: Result<(), duckdb::Error> = conn + .execute( + "INSERT INTO fonts (id, workspace_id, name, fontstack, original_path, glyphs_path, status, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, CURRENT_TIMESTAMP)", + duckdb::params![ + &font_id, + &workspace_id, + &display_name, + &display_name, + &original_rel, + &glyphs_rel, + "processing", + ], + ) + .map(|_| ()); drop(conn); + if let Err(e) = insert_result { + let _ = fs::remove_dir_all(&fonts_dir).await; + return Err(internal_error(e)); + } + let state_clone = state.clone(); let font_id_clone = font_id.clone(); let original_path_clone = original_path.clone(); @@ -535,7 +541,7 @@ pub async fn publish_font( } Err(e) => { let err_msg = e.to_string(); - if err_msg.contains("UNIQUE") || err_msg.contains("unique") { + if err_msg.contains("idx_fonts_workspace_slug") { drop(conn); Err(bad_request("Slug already in use")) } else { diff --git a/backend/src/handlers.rs b/backend/src/handlers.rs index 5a3bb438..1cfa0e3c 100644 --- a/backend/src/handlers.rs +++ b/backend/src/handlers.rs @@ -1058,7 +1058,7 @@ pub async fn publish_file( StatusCode::CONFLICT, Json(ErrorResponse { error: error_msg }), )); - } else if err_msg.contains("UNIQUE") + } else if err_msg.contains("published_files") || (err_msg.contains("slug") && err_msg.contains("unique")) { Err("Slug already in use".to_string()) diff --git a/backend/src/workspace_handlers.rs b/backend/src/workspace_handlers.rs index 11a686c5..1aaeaa2c 100644 --- a/backend/src/workspace_handlers.rs +++ b/backend/src/workspace_handlers.rs @@ -93,7 +93,7 @@ fn build_unique_workspace_slug( }; let mut candidate = base.clone(); - let mut suffix: u32 = 2; + let mut suffix: u32 = 1; loop { let in_use: i64 = conn @@ -903,7 +903,7 @@ pub async fn update_workspace( ) .map_err(|err| { let err_text = err.to_string(); - if err_text.contains("slug") { + if err_text.contains("idx_workspaces_slug") { workspace_slug_conflict_or_internal(err) } else { workspace_name_conflict_or_internal(err) @@ -1078,7 +1078,7 @@ pub async fn restore_workspace( ) .map_err(|err| { let err_text = err.to_string(); - if err_text.contains("slug") { + if err_text.contains("idx_workspaces_slug") { workspace_slug_conflict_or_internal(err) } else { workspace_name_conflict_or_internal(err) @@ -1121,7 +1121,7 @@ pub async fn restore_workspace( ) .map_err(|err| { let err_text = err.to_string(); - if err_text.contains("slug") { + if err_text.contains("idx_workspaces_slug") { workspace_slug_conflict_or_internal(err) } else { workspace_name_conflict_or_internal(err)