diff --git a/.env.example b/.env.example index b38199c..df9df68 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,10 @@ FRONTEND_ORIGIN= # Frontend Configuration # URL of the backend API accessible from the frontend container. PUBLIC_API_URL=http://backend:3000 + +# Presentation focus. Same skin either way (fonts, cybersigil/breakcore, +# paper grain, CyberFx). `atelier` = image-first gallery (justified plates, +# "plates" count). `blog` = writing-first (stacked rows, excerpt, reading +# time). Read server-side at render — no rebuild needed to switch. +# Anything other than `blog` falls back to atelier. +SITE_MODE=atelier diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..73145c0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci + - name: Type-check + run: npm run check + - name: Lint + run: npm run lint + - name: Test + run: npm test + - name: Build + run: npm run build + + backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: backend + - name: Format + run: cargo fmt --check + - name: Clippy + # Warnings are surfaced but not yet gating — ratchet to `-D warnings` + # once the existing ~10 style lints are cleared. + run: cargo clippy --all-targets + - name: Test + run: cargo test diff --git a/README.md b/README.md index c7f3968..fdd8174 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Narlblog +# Ela's Atelier -A single-author blog. Rust/Axum API backed by markdown files on disk; Astro/React frontend with a glass-effect Catppuccin theme. KaTeX math, GFM tables, server-side syntax highlighting, draft posts, RSS feed. +A single-curator art portfolio. Rust/Axum API backed by markdown files on disk; Astro/React frontend with a parchment "Salon Hang" aesthetic. Each entry is a markdown post that must contain at least one image; the first image becomes the cover plate in the catalogue. ``` backend/ Rust + Axum API (filesystem-backed) @@ -28,15 +28,15 @@ sudo chown -R 1000:1000 data/ ## Environment -| Variable | Required | Default | Notes | -| ----------------- | -------- | ----------------- | --------------------------------------------------------------------------- | -| `ADMIN_TOKEN` | yes | — | Long random string. Stored as an HttpOnly cookie after login. | -| `PORT` | no | `3000` | Backend port. | -| `DATA_DIR` | no | `/app/data` | Where posts/uploads/config live. | -| `COOKIE_SECURE` | no | `true` | Set `false` only for local HTTP development. | -| `FRONTEND_ORIGIN` | no | _empty_ | Set to your frontend's URL only if you expose the backend directly. | -| `RUST_LOG` | no | `info` | tracing-subscriber filter. | -| `PUBLIC_API_URL` | no | `http://backend:3000` | Backend URL the Astro proxy hits server-to-server. | +| Variable | Required | Default | Notes | +| ----------------- | -------- | --------------------- | --------------------------------------------------------------------------- | +| `ADMIN_TOKEN` | yes | — | Long random string. Stored as an HttpOnly cookie after login. | +| `PORT` | no | `3000` | Backend port. | +| `DATA_DIR` | no | `/app/data` | Where posts/uploads/config live. | +| `COOKIE_SECURE` | no | `true` | Set `false` only for local HTTP development. | +| `FRONTEND_ORIGIN` | no | _empty_ | Set to your frontend's URL only if you expose the backend directly. | +| `RUST_LOG` | no | `info` | tracing-subscriber filter. | +| `PUBLIC_API_URL` | no | `http://backend:3000` | Backend URL the Astro proxy hits server-to-server. | ## Local development @@ -58,27 +58,31 @@ npm run dev For a fully local stack, set `PUBLIC_API_URL=http://localhost:3000` in `frontend/.env`. -## Authoring posts +## Authoring a work -Posts are markdown files at `data/posts/.md` with YAML frontmatter: +Each work is a markdown file at `data/posts/.md` with YAML frontmatter. Every work **must** contain at least one markdown image — the first image becomes the cover plate on the gallery index. Image alt text becomes the figure caption on the work page. ```markdown --- date: 2026-05-09 -summary: Optional summary; falls back to auto-extracted excerpt. +summary: Optional short caption shown beneath the plate on the index. tags: - - rust - - astro + - oil + - 2026 draft: false --- -# My post +# Untitled (charcoal on paper) -Body in **markdown**, with $LaTeX$ via `$…$` / `$$…$$`, GFM tables, and fenced code blocks (```rust, ```ts, …). +![A view of the cliff at dawn](/uploads/cliff-dawn.jpg "Plate I — graphite, A3") + +Notes on the piece: materials, references, what worked, what didn't. + +![Detail of the foreground](/uploads/cliff-detail.jpg "Plate II — detail") ``` -Posts with `draft: true` are hidden from the public list and 404 when accessed by anyone without an admin session. Posts are sorted by `date` descending on the frontpage. - -The web editor at `/admin/editor` writes the same format and updates atomically (write to `.tmp`, rename over target). +- `draft: true` hides a work from the public catalogue and 404s for non-curators. +- Works are sorted by `date` descending on the index. +- The web editor at `/admin/editor` writes the same format and updates atomically. ## Uploads @@ -86,12 +90,18 @@ Allowlisted extensions: jpg, jpeg, png, webp, gif, avif, pdf, txt, md, mp3, wav, Max upload size: 50 MB. +## Theme + +The default theme is **Salon** — aged parchment, oxblood ink, Fraunces/EB Garamond/Caveat typography. A **Salon Noir** variant (black gallery wall) is available via the theme switcher in the header. + +Influences: Friedrich, Goya, Kahlo, Tillmans, Basquiat, Sherman, Matisse, Dix, Abramović. + ## Backups -The deployed `data/` directory is the entire blog. Back it up with whatever you trust — `rsync`, restic, borg, a sidecar container; nothing fancy is built in. +The deployed `data/` directory is the entire gallery. Back it up with whatever you trust — `rsync`, restic, borg, a sidecar container; nothing fancy is built in. ```sh -rsync -av data/ backup-host:/path/to/narlblog-data/ +rsync -av data/ backup-host:/path/to/gallery-data/ ``` ## Stack diff --git a/backend/Cargo.lock b/backend/Cargo.lock index fad2a19..cccb471 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +17,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -20,6 +41,18 @@ dependencies = [ "libc", ] +[[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -104,6 +137,7 @@ dependencies = [ "axum", "chrono", "dotenvy", + "imagesize", "infer", "serde", "serde_json", @@ -122,6 +156,27 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -181,12 +236,39 @@ dependencies = [ "windows-link", ] +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "deunicode" version = "1.6.2" @@ -230,6 +312,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -401,6 +493,12 @@ dependencies = [ "cc", ] +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + [[package]] name = "indexmap" version = "2.14.0" @@ -500,6 +598,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -759,6 +867,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -892,6 +1006,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ + "async-compression", "bitflags", "bytes", "futures-core", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 605f650..2098e72 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -8,12 +8,13 @@ axum = { version = "0.8.8", features = ["multipart", "macros"] } chrono = { version = "0.4.44", features = ["serde"] } dotenvy = "0.15.7" infer = "0.19.0" +imagesize = "0.14" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" serde_yaml = "0.9.34" slug = "0.1.6" subtle = "2.6.1" tokio = { version = "1.50.0", features = ["full"] } -tower-http = { version = "0.6.8", features = ["cors", "fs"] } +tower-http = { version = "0.6.8", features = ["cors", "fs", "compression-br", "compression-gzip"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } diff --git a/backend/rustfmt.toml b/backend/rustfmt.toml new file mode 100644 index 0000000..ed49ca7 --- /dev/null +++ b/backend/rustfmt.toml @@ -0,0 +1,2 @@ +edition = "2024" +max_width = 100 diff --git a/backend/src/error.rs b/backend/src/error.rs index f6aaf55..921d2a9 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -7,10 +7,12 @@ use tracing::error; use crate::models::ErrorResponse; +#[derive(Debug)] pub enum AppError { Unauthorized, NotFound(String), BadRequest(String), + TooManyRequests(String), /// (public_message, internal_details) — details are logged but not returned. Internal(String, Option), } @@ -21,13 +23,17 @@ impl IntoResponse for AppError { AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string()), AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg), + AppError::TooManyRequests(msg) => (StatusCode::TOO_MANY_REQUESTS, msg), AppError::Internal(msg, details) => { if let Some(d) = details { error!("Internal error: {} — {}", msg, d); } else { error!("Internal error: {}", msg); } - (StatusCode::INTERNAL_SERVER_ERROR, "Internal error".to_string()) + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal error".to_string(), + ) } }; diff --git a/backend/src/handlers/config.rs b/backend/src/handlers/config.rs index 379e47a..50ac9ce 100644 --- a/backend/src/handlers/config.rs +++ b/backend/src/handlers/config.rs @@ -1,5 +1,6 @@ use axum::{Json, extract::State, http::HeaderMap, response::IntoResponse}; -use std::{fs, sync::Arc}; +use std::sync::Arc; +use tokio::fs; use tracing::error; use crate::{ @@ -12,6 +13,7 @@ use crate::{ pub async fn get_config(State(state): State>) -> impl IntoResponse { let config_path = state.data_dir.join("config.json"); let config = fs::read_to_string(&config_path) + .await .ok() .and_then(|c| serde_json::from_str::(&c).ok()) .unwrap_or_default(); @@ -28,25 +30,48 @@ pub async fn update_config( let config_path = state.data_dir.join("config.json"); let mut config: SiteConfig = fs::read_to_string(&config_path) + .await .ok() .and_then(|c| serde_json::from_str(&c).ok()) .unwrap_or_default(); - if let Some(v) = patch.title { config.title = v; } - if let Some(v) = patch.subtitle { config.subtitle = v; } - if let Some(v) = patch.welcome_title { config.welcome_title = v; } - if let Some(v) = patch.welcome_subtitle { config.welcome_subtitle = v; } - if let Some(v) = patch.footer { config.footer = v; } - if let Some(v) = patch.favicon { config.favicon = v; } - if let Some(v) = patch.theme { config.theme = v; } - if let Some(v) = patch.custom_css { config.custom_css = v; } + if let Some(v) = patch.title { + config.title = v; + } + if let Some(v) = patch.subtitle { + config.subtitle = v; + } + if let Some(v) = patch.welcome_title { + config.welcome_title = v; + } + if let Some(v) = patch.welcome_subtitle { + config.welcome_subtitle = v; + } + if let Some(v) = patch.footer { + config.footer = v; + } + if let Some(v) = patch.favicon { + config.favicon = v; + } + if let Some(v) = patch.theme { + config.theme = v; + } + if let Some(v) = patch.custom_css { + config.custom_css = v; + } + if let Some(v) = patch.contact_intro { + config.contact_intro = v; + } + if let Some(v) = patch.contact_links { + config.contact_links = v; + } let config_str = serde_json::to_string_pretty(&config).map_err(|e| { error!("Serialization error: {}", e); AppError::Internal("Serialization error".to_string(), Some(e.to_string())) })?; - fs::write(&config_path, config_str).map_err(|e| { + fs::write(&config_path, config_str).await.map_err(|e| { error!("Write error for config: {}", e); AppError::Internal("Write error".to_string(), Some(e.to_string())) })?; diff --git a/backend/src/handlers/contact.rs b/backend/src/handlers/contact.rs new file mode 100644 index 0000000..cd80206 --- /dev/null +++ b/backend/src/handlers/contact.rs @@ -0,0 +1,247 @@ +use axum::{ + Json, + extract::{Path, State}, + http::HeaderMap, + response::IntoResponse, +}; +use chrono::Utc; +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + sync::Arc, +}; +use tokio::fs; +use tracing::{error, info, warn}; + +use crate::{ + AppState, + auth::check_auth, + error::AppError, + models::{ContactResponse, ContactSubmission, Message}, +}; + +const MIN_FILL_TIME_MS: i64 = 3_000; +const MAX_FORM_AGE_MS: i64 = 24 * 60 * 60 * 1000; +pub const RATE_LIMIT_WINDOW_MS: i64 = 60 * 60 * 1000; +const RATE_LIMIT_MAX: usize = 5; +const MAX_NAME: usize = 200; +const MAX_EMAIL: usize = 200; +const MAX_SUBJECT: usize = 300; +const MAX_BODY: usize = 10_000; + +fn client_ip(headers: &HeaderMap) -> String { + if let Some(xff) = headers.get("x-forwarded-for").and_then(|h| h.to_str().ok()) { + if let Some(first) = xff.split(',').next() { + let s = first.trim(); + if !s.is_empty() { + return s.to_string(); + } + } + } + if let Some(real_ip) = headers.get("x-real-ip").and_then(|h| h.to_str().ok()) { + let s = real_ip.trim(); + if !s.is_empty() { + return s.to_string(); + } + } + "unknown".to_string() +} + +fn hash_ip(ip: &str) -> String { + let mut h = DefaultHasher::new(); + ip.hash(&mut h); + format!("{:x}", h.finish()) +} + +pub async fn submit_contact( + State(state): State>, + headers: HeaderMap, + Json(sub): Json, +) -> Result, AppError> { + let now_ms = Utc::now().timestamp_millis(); + let ip = client_ip(&headers); + let ip_hash = hash_ip(&ip); + + { + let mut map = state.contact_rate_limit.lock().await; + let entry = map.entry(ip.clone()).or_default(); + entry.retain(|t| now_ms - *t < RATE_LIMIT_WINDOW_MS); + if entry.len() >= RATE_LIMIT_MAX { + warn!("Contact rate limit hit for ip hash {}", ip_hash); + return Err(AppError::TooManyRequests( + "Too many submissions. Try again later.".into(), + )); + } + entry.push(now_ms); + } + + if sub + .website + .as_deref() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false) + { + info!("Contact honeypot triggered from ip hash {}", ip_hash); + return Ok(Json(ContactResponse { ok: true })); + } + + let elapsed = now_ms - sub.started_at; + if elapsed < MIN_FILL_TIME_MS { + return Err(AppError::BadRequest( + "Submission too fast — please take a moment and try again.".into(), + )); + } + if elapsed < 0 || elapsed > MAX_FORM_AGE_MS { + return Err(AppError::BadRequest( + "Form expired — refresh the page and try again.".into(), + )); + } + + let body = sub.message.trim().to_string(); + if body.is_empty() { + return Err(AppError::BadRequest("Message cannot be empty.".into())); + } + if body.chars().count() > MAX_BODY { + return Err(AppError::BadRequest(format!( + "Message too long (max {} characters).", + MAX_BODY + ))); + } + let name = sub + .name + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let email = sub + .email + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let subject = sub + .subject + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + if name.as_ref().is_some_and(|s| s.chars().count() > MAX_NAME) { + return Err(AppError::BadRequest("Name is too long.".into())); + } + if email + .as_ref() + .is_some_and(|s| s.chars().count() > MAX_EMAIL) + { + return Err(AppError::BadRequest("Email is too long.".into())); + } + if subject + .as_ref() + .is_some_and(|s| s.chars().count() > MAX_SUBJECT) + { + return Err(AppError::BadRequest("Subject is too long.".into())); + } + if let Some(ref e) = email { + if !e.contains('@') || !e.contains('.') { + return Err(AppError::BadRequest("Email looks invalid.".into())); + } + } + + let received_at = now_ms; + let id = Utc::now() + .timestamp_nanos_opt() + .unwrap_or(received_at * 1_000_000) + .to_string(); + + let msg = Message { + id: id.clone(), + name, + email, + subject, + body, + received_at, + ip_hash: Some(ip_hash), + }; + + let messages_dir = state.data_dir.join("messages"); + fs::create_dir_all(&messages_dir).await.map_err(|e| { + error!("Failed to create messages dir: {}", e); + AppError::Internal("Storage error".into(), Some(e.to_string())) + })?; + let path = messages_dir.join(format!("{}.json", id)); + let json = serde_json::to_string_pretty(&msg) + .map_err(|e| AppError::Internal("Serialization error".into(), Some(e.to_string())))?; + fs::write(&path, json).await.map_err(|e| { + error!("Failed to write message {}: {}", id, e); + AppError::Internal("Storage error".into(), Some(e.to_string())) + })?; + + info!("Stored contact message {}", id); + Ok(Json(ContactResponse { ok: true })) +} + +pub async fn list_messages( + State(state): State>, + headers: HeaderMap, +) -> impl IntoResponse { + if let Err(e) = check_auth(&headers, &state.admin_token) { + return e.into_response(); + } + + let messages_dir = state.data_dir.join("messages"); + if !fs::try_exists(&messages_dir).await.unwrap_or(false) { + return Json(Vec::::new()).into_response(); + } + + let mut rd = match fs::read_dir(&messages_dir).await { + Ok(e) => e, + Err(e) => { + error!("Failed to read messages dir: {}", e); + return AppError::Internal("Read error".into(), Some(e.to_string())).into_response(); + } + }; + + let mut messages: Vec = Vec::new(); + loop { + match rd.next_entry().await { + Ok(Some(entry)) => { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let content = match fs::read_to_string(&path).await { + Ok(c) => c, + Err(e) => { + error!("Failed to read {:?}: {}", path, e); + continue; + } + }; + match serde_json::from_str::(&content) { + Ok(m) => messages.push(m), + Err(e) => error!("Malformed message {:?}: {}", path, e), + } + } + Ok(None) => break, + Err(e) => { + error!("Error iterating messages dir: {}", e); + break; + } + } + } + messages.sort_by(|a, b| b.received_at.cmp(&a.received_at)); + Json(messages).into_response() +} + +pub async fn delete_message( + State(state): State>, + Path(id): Path, + headers: HeaderMap, +) -> Result, AppError> { + check_auth(&headers, &state.admin_token)?; + + if id.contains('/') || id.contains('\\') || id.contains("..") || id.is_empty() { + return Err(AppError::BadRequest("Invalid id.".into())); + } + let path = state.data_dir.join("messages").join(format!("{}.json", id)); + if !fs::try_exists(&path).await.unwrap_or(false) { + return Err(AppError::NotFound("Message not found.".into())); + } + fs::remove_file(&path) + .await + .map_err(|e| AppError::Internal("Delete failed".into(), Some(e.to_string())))?; + Ok(Json(ContactResponse { ok: true })) +} diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index f28e4e9..2a7741d 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -1,4 +1,5 @@ pub mod auth; pub mod config; +pub mod contact; pub mod posts; pub mod upload; diff --git a/backend/src/handlers/posts.rs b/backend/src/handlers/posts.rs index 95b2539..46e872d 100644 --- a/backend/src/handlers/posts.rs +++ b/backend/src/handlers/posts.rs @@ -1,12 +1,19 @@ +//! HTTP handlers for posts. Orchestration only — parsing, image handling, +//! and the cache live in [`crate::post`]. + use axum::{ Json, extract::{Path, State}, http::{HeaderMap, StatusCode}, }; use chrono::Utc; -use std::{fs, sync::Arc}; +use std::sync::Arc; +use tokio::fs; use tracing::{error, info, warn}; +use crate::post::cache::{neighbors_from_cache, rebuild_posts_cache}; +use crate::post::images::{cover_from, dims_for_urls, extract_images}; +use crate::post::parse::{reading_time, serialize_post, validate_slug}; use crate::{ AppState, auth::is_authed, @@ -14,137 +21,23 @@ use crate::{ models::{CreatePostRequest, PostDetail, PostInfo, PostMeta}, }; -const WORDS_PER_MINUTE: u32 = 200; - -const MAX_SLUG_LEN: usize = 100; -const WINDOWS_RESERVED: &[&str] = &[ - "CON", "PRN", "AUX", "NUL", - "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", - "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", -]; - -fn validate_slug(s: &str) -> Result<(), AppError> { - if s.is_empty() { - return Err(AppError::BadRequest("Slug is empty".to_string())); - } - if s.len() > MAX_SLUG_LEN { - return Err(AppError::BadRequest(format!( - "Slug exceeds {} characters", - MAX_SLUG_LEN - ))); - } - if s.starts_with('.') { - return Err(AppError::BadRequest( - "Slug cannot start with '.'".to_string(), - )); - } - if s.ends_with('.') || s.ends_with(' ') { - return Err(AppError::BadRequest( - "Slug cannot end with '.' or space".to_string(), - )); - } - if s.contains("..") { - return Err(AppError::BadRequest( - "Slug cannot contain '..'".to_string(), - )); - } - for c in s.chars() { - if c.is_control() { - return Err(AppError::BadRequest( - "Slug contains control characters".to_string(), - )); - } - if matches!(c, '/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*') { - return Err(AppError::BadRequest(format!( - "Slug contains invalid character '{}'", - c - ))); - } - } - let stem = s.split('.').next().unwrap_or("").to_ascii_uppercase(); - if WINDOWS_RESERVED.iter().any(|r| *r == stem) { - return Err(AppError::BadRequest( - "Slug is a reserved name".to_string(), - )); - } - Ok(()) -} - -fn split_frontmatter(raw: &str) -> Option<(&str, &str)> { - let raw = raw.strip_prefix("---\n").or_else(|| raw.strip_prefix("---\r\n"))?; - let end_marker = raw.find("\n---\n").or_else(|| raw.find("\r\n---\r\n"))?; - let yaml = &raw[..end_marker]; - let body_start = end_marker - + raw[end_marker..] - .find("---\n") - .or_else(|| raw[end_marker..].find("---\r\n"))? - + "---\n".len(); - let body = raw[body_start..].trim_start_matches('\n').trim_start_matches('\r'); - Some((yaml, body)) -} - -fn parse_post(raw: &str) -> Result<(PostMeta, String), AppError> { - let (yaml, body) = split_frontmatter(raw).ok_or_else(|| { - AppError::Internal( - "Missing frontmatter".to_string(), - Some("post is missing the YAML --- block".to_string()), - ) - })?; - let meta: PostMeta = serde_yaml::from_str(yaml).map_err(|e| { - AppError::Internal( - "Invalid frontmatter".to_string(), - Some(format!("YAML parse error: {}", e)), - ) - })?; - Ok((meta, body.to_string())) -} - -fn serialize_post(meta: &PostMeta, body: &str) -> Result { - let yaml = serde_yaml::to_string(meta).map_err(|e| { - AppError::Internal( - "Serialization error".to_string(), - Some(e.to_string()), - ) - })?; - Ok(format!("---\n{}---\n{}", yaml, body)) -} - -fn reading_time(body: &str) -> u32 { - let words = body.split_whitespace().count() as u32; - (words + WORDS_PER_MINUTE - 1) / WORDS_PER_MINUTE.max(1) -} - -fn excerpt_from(meta: &PostMeta, body: &str) -> String { - if let Some(s) = meta.summary.as_ref() { - if !s.trim().is_empty() { - return s.trim().to_string(); - } - } - let plain = body - .replace(['#', '*', '_', '`'], "") - .replace('\n', " "); - let mut out: String = plain.chars().take(200).collect(); - if plain.chars().count() > 200 { - out.push_str("..."); - } - out.trim().to_string() -} - -async fn write_post_atomic( - state: &AppState, - slug: &str, - contents: &str, -) -> Result<(), AppError> { +async fn write_post_atomic(state: &AppState, slug: &str, contents: &str) -> Result<(), AppError> { let _guard = state.post_lock.lock().await; let final_path = state.data_dir.join("posts").join(format!("{}.md", slug)); - let tmp_path = state.data_dir.join("posts").join(format!(".{}.md.tmp", slug)); - fs::write(&tmp_path, contents).map_err(|e| { - AppError::Internal("Write error".to_string(), Some(e.to_string())) - })?; - fs::rename(&tmp_path, &final_path).map_err(|e| { - let _ = fs::remove_file(&tmp_path); - AppError::Internal("Rename error".to_string(), Some(e.to_string())) - })?; + let tmp_path = state + .data_dir + .join("posts") + .join(format!(".{}.md.tmp", slug)); + fs::write(&tmp_path, contents) + .await + .map_err(|e| AppError::Internal("Write error".to_string(), Some(e.to_string())))?; + if let Err(e) = fs::rename(&tmp_path, &final_path).await { + let _ = fs::remove_file(&tmp_path).await; + return Err(AppError::Internal( + "Rename error".to_string(), + Some(e.to_string()), + )); + } Ok(()) } @@ -174,14 +67,14 @@ pub async fn create_post( if let Some(ref old_slug) = payload.old_slug { if old_slug != &slug { let old_path = posts_dir.join(format!("{}.md", old_slug)); - if old_path.exists() { - if file_path.exists() { + if fs::try_exists(&old_path).await.unwrap_or(false) { + if fs::try_exists(&file_path).await.unwrap_or(false) { return Err(AppError::BadRequest( "A post with this new title already exists".to_string(), )); } let _guard = state.post_lock.lock().await; - fs::rename(&old_path, &file_path).map_err(|e| { + fs::rename(&old_path, &file_path).await.map_err(|e| { error!("Rename error from {} to {}: {}", old_slug, slug, e); AppError::Internal("Rename error".to_string(), Some(e.to_string())) })?; @@ -191,9 +84,20 @@ pub async fn create_post( } } + let images = extract_images(&payload.content); + if images.is_empty() { + return Err(AppError::BadRequest( + "A gallery entry must include at least one image (![](url) in the markdown body)." + .to_string(), + )); + } + let meta = PostMeta { date: payload.date.unwrap_or_else(|| Utc::now().date_naive()), - title: payload.title.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()), + title: payload + .title + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()), summary: payload.summary.filter(|s| !s.trim().is_empty()), tags: payload.tags, draft: payload.draft, @@ -202,6 +106,21 @@ pub async fn create_post( write_post_atomic(&state, &slug, &contents).await?; info!("Post saved: {}", slug); + let image_count = images.len() as u32; + let mut cover = cover_from(&images); + + rebuild_posts_cache(&state).await; + let (prev, next) = neighbors_from_cache(&state, &slug, true).await; + + let image_urls: Vec = images.iter().map(|(_, u)| u.clone()).collect(); + let dimensions = dims_for_urls(&state, &image_urls).await; + if let Some(c) = cover.as_mut() { + if let Some(d) = dimensions.get(&c.url) { + c.w = Some(d.w); + c.h = Some(d.h); + } + } + Ok(Json(PostDetail { slug, date: meta.date, @@ -211,6 +130,11 @@ pub async fn create_post( draft: meta.draft, reading_time: reading_time(&payload.content), content: payload.content, + cover_image: cover, + image_count, + prev, + next, + dimensions, })) } @@ -227,17 +151,19 @@ pub async fn delete_post( let _guard = state.post_lock.lock().await; let file_path = state.data_dir.join("posts").join(format!("{}.md", slug)); - if !file_path.exists() { + if !fs::try_exists(&file_path).await.unwrap_or(false) { warn!("Post not found for deletion: {}", slug); return Err(AppError::NotFound("Post not found".to_string())); } - fs::remove_file(file_path).map_err(|e| { + fs::remove_file(file_path).await.map_err(|e| { error!("Delete error for post {}: {}", slug, e); AppError::Internal("Delete error".to_string(), Some(e.to_string())) })?; + drop(_guard); info!("Post deleted: {}", slug); + rebuild_posts_cache(&state).await; Ok(StatusCode::NO_CONTENT) } @@ -246,45 +172,12 @@ pub async fn list_posts( headers: HeaderMap, ) -> Json> { let admin = is_authed(&headers, &state.admin_token); - let posts_dir = state.data_dir.join("posts"); - let mut posts: Vec = Vec::new(); - - if let Ok(entries) = fs::read_dir(posts_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("md") { - continue; - } - let Some(slug) = path.file_stem().and_then(|s| s.to_str()) else { - continue; - }; - if slug.starts_with('.') { - continue; - } - let Ok(raw) = fs::read_to_string(&path) else { - continue; - }; - let Ok((meta, body)) = parse_post(&raw) else { - warn!("Skipping post with bad frontmatter: {}", slug); - continue; - }; - if meta.draft && !admin { - continue; - } - posts.push(PostInfo { - slug: slug.to_string(), - date: meta.date, - title: meta.title.clone(), - summary: meta.summary.clone(), - tags: meta.tags.clone(), - draft: meta.draft, - reading_time: reading_time(&body), - excerpt: excerpt_from(&meta, &body), - }); - } - } - - posts.sort_by(|a, b| b.date.cmp(&a.date).then_with(|| a.slug.cmp(&b.slug))); + let cache = state.posts_cache.read().await; + let posts: Vec = cache + .iter() + .filter(|p| admin || !p.info.draft) + .map(|p| p.info.clone()) + .collect(); Json(posts) } @@ -295,24 +188,36 @@ pub async fn get_post( ) -> Result, AppError> { validate_slug(&slug)?; let admin = is_authed(&headers, &state.admin_token); - let file_path = state.data_dir.join("posts").join(format!("{}.md", slug)); - let raw = fs::read_to_string(&file_path) - .map_err(|_| AppError::NotFound("Post not found".to_string()))?; - let (meta, body) = parse_post(&raw)?; + let (info, body) = { + let cache = state.posts_cache.read().await; + let Some(p) = cache.iter().find(|p| p.info.slug == slug) else { + return Err(AppError::NotFound("Post not found".to_string())); + }; + if p.info.draft && !admin { + return Err(AppError::NotFound("Post not found".to_string())); + } + (p.info.clone(), p.body.clone()) + }; - if meta.draft && !admin { - return Err(AppError::NotFound("Post not found".to_string())); - } + let (prev, next) = neighbors_from_cache(&state, &slug, admin).await; + + let image_urls: Vec = extract_images(&body).into_iter().map(|(_, u)| u).collect(); + let dimensions = dims_for_urls(&state, &image_urls).await; Ok(Json(PostDetail { - slug, - date: meta.date, - title: meta.title, - summary: meta.summary, - tags: meta.tags, - draft: meta.draft, - reading_time: reading_time(&body), + slug: info.slug, + date: info.date, + title: info.title, + summary: info.summary, + tags: info.tags, + draft: info.draft, + reading_time: info.reading_time, content: body, + cover_image: info.cover_image, + image_count: info.image_count, + prev, + next, + dimensions, })) } diff --git a/backend/src/handlers/upload.rs b/backend/src/handlers/upload.rs index 2e99033..b8e39ce 100644 --- a/backend/src/handlers/upload.rs +++ b/backend/src/handlers/upload.rs @@ -4,7 +4,8 @@ use axum::{ http::{HeaderMap, StatusCode}, }; use serde::Deserialize; -use std::{fs, sync::Arc}; +use std::sync::Arc; +use tokio::fs; use tracing::{error, info, warn}; use crate::{ @@ -22,10 +23,8 @@ pub struct UploadQuery { /// Allowed upload extensions. SVG, HTML, JS, executables intentionally absent — /// /uploads/* is served as-is, so any active content there is XSS waiting to happen. const ALLOWED_EXTS: &[&str] = &[ - "jpg", "jpeg", "png", "webp", "gif", "avif", - "pdf", "txt", "md", - "mp3", "wav", "ogg", - "mp4", "webm", "mov", + "jpg", "jpeg", "png", "webp", "gif", "avif", "pdf", "txt", "md", "mp3", "wav", "ogg", "mp4", + "webm", "mov", ]; fn validate_filename(name: &str) -> Result<(), AppError> { @@ -76,18 +75,23 @@ pub async fn delete_upload( let uploads_dir = state.data_dir.join("uploads"); let file_path = uploads_dir.join(&filename); - let canonical_dir = uploads_dir.canonicalize().map_err(|e| { - AppError::Internal("Path resolution".to_string(), Some(e.to_string())) - })?; - if let Ok(canonical_file) = file_path.canonicalize() { + let canonical_dir = fs::canonicalize(&uploads_dir) + .await + .map_err(|e| AppError::Internal("Path resolution".to_string(), Some(e.to_string())))?; + if let Ok(canonical_file) = fs::canonicalize(&file_path).await { if !canonical_file.starts_with(&canonical_dir) { warn!("Refused delete outside uploads dir: {}", filename); return Err(AppError::BadRequest("Invalid filename".to_string())); } - fs::remove_file(canonical_file).map_err(|e| { + fs::remove_file(canonical_file).await.map_err(|e| { error!("Delete error for file {}: {}", filename, e); AppError::Internal("Delete error".to_string(), Some(e.to_string())) })?; + state + .image_dims_cache + .write() + .await + .remove(&format!("/uploads/{}", filename)); info!("Deleted file: {}", filename); Ok(StatusCode::NO_CONTENT) } else { @@ -104,15 +108,30 @@ pub async fn list_uploads( let uploads_dir = state.data_dir.join("uploads"); let mut files = Vec::new(); - if let Ok(entries) = fs::read_dir(uploads_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_file() { - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - files.push(FileInfo { - name: name.to_string(), - url: format!("/uploads/{}", name), - }); + if let Ok(mut rd) = fs::read_dir(&uploads_dir).await { + loop { + match rd.next_entry().await { + Ok(Some(entry)) => { + let path = entry.path(); + let is_file = entry + .file_type() + .await + .map(|t| t.is_file()) + .unwrap_or(false); + if !is_file { + continue; + } + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + files.push(FileInfo { + name: name.to_string(), + url: format!("/uploads/{}", name), + }); + } + } + Ok(None) => break, + Err(e) => { + warn!("Error iterating uploads dir: {}", e); + break; } } } @@ -178,7 +197,9 @@ pub async fn upload_file( let uploads_dir = state.data_dir.join("uploads"); let target_path = uploads_dir.join(&final_name); - let final_path = if target_path.exists() && !query.replace.unwrap_or(false) { + let final_path = if fs::try_exists(&target_path).await.unwrap_or(false) + && !query.replace.unwrap_or(false) + { let timestamp = chrono::Utc::now().timestamp(); uploads_dir.join(format!("{}_{}", timestamp, final_name)) } else { @@ -186,11 +207,11 @@ pub async fn upload_file( }; // Final containment check. - let canonical_dir = uploads_dir.canonicalize().map_err(|e| { - AppError::Internal("Path resolution".to_string(), Some(e.to_string())) - })?; + let canonical_dir = fs::canonicalize(&uploads_dir) + .await + .map_err(|e| AppError::Internal("Path resolution".to_string(), Some(e.to_string())))?; if let Some(parent) = final_path.parent() { - let canonical_parent = parent.canonicalize().map_err(|e| { + let canonical_parent = fs::canonicalize(parent).await.map_err(|e| { AppError::Internal("Path resolution".to_string(), Some(e.to_string())) })?; if canonical_parent != canonical_dir { @@ -204,15 +225,17 @@ pub async fn upload_file( .unwrap_or(&final_name) .to_string(); - fs::write(&final_path, &data).map_err(|e| { + fs::write(&final_path, &data).await.map_err(|e| { error!("Failed to write file to {:?}: {}", final_path, e); AppError::Internal("Write error".to_string(), Some(e.to_string())) })?; + let url = format!("/uploads/{}", final_name_str); + // Invalidate any stale dim cache entry (matters when replacing an existing file). + state.image_dims_cache.write().await.remove(&url); + info!("File uploaded successfully to {:?}", final_path); - return Ok(Json(UploadResponse { - url: format!("/uploads/{}", final_name_str), - })); + return Ok(Json(UploadResponse { url })); } warn!("Upload failed: no file found in multipart stream"); diff --git a/backend/src/main.rs b/backend/src/main.rs index 8db5634..bb4c92a 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -2,6 +2,7 @@ pub mod auth; pub mod error; pub mod handlers; pub mod models; +pub mod post; use axum::{ Router, @@ -9,19 +10,31 @@ use axum::{ http::{HeaderValue, header}, routing::{delete, get, post}, }; -use std::{env, fs, path::PathBuf, sync::Arc}; -use tokio::sync::Mutex; +use std::{collections::HashMap, env, path::PathBuf, sync::Arc, time::Duration}; +use tokio::sync::{Mutex, RwLock}; use tower_http::{ + compression::CompressionLayer, cors::{AllowOrigin, CorsLayer}, services::ServeDir, }; use tracing::{error, info, warn}; +use crate::handlers::contact::RATE_LIMIT_WINDOW_MS; +use crate::models::{ImageDim, PostInfo}; + +pub struct CachedPost { + pub info: PostInfo, + pub body: String, +} + pub struct AppState { pub admin_token: String, pub data_dir: PathBuf, pub cookie_secure: bool, pub post_lock: Mutex<()>, + pub posts_cache: RwLock>, + pub image_dims_cache: RwLock>, + pub contact_rate_limit: Mutex>>, } #[tokio::main] @@ -35,9 +48,7 @@ async fn main() { .filter(|t| !t.trim().is_empty()) .expect("ADMIN_TOKEN must be set to a non-empty value"); if admin_token.len() < 16 { - warn!( - "ADMIN_TOKEN is shorter than 16 characters. Use a long random string in production." - ); + warn!("ADMIN_TOKEN is shorter than 16 characters. Use a long random string in production."); } let data_dir_str = env::var("DATA_DIR").unwrap_or_else(|_| "../data".to_string()); let data_dir = PathBuf::from(data_dir_str); @@ -49,10 +60,10 @@ async fn main() { let posts_dir = data_dir.join("posts"); let uploads_dir = data_dir.join("uploads"); - if let Err(e) = fs::create_dir_all(&posts_dir) { + if let Err(e) = tokio::fs::create_dir_all(&posts_dir).await { error!("Failed to create posts directory: {}", e); } - if let Err(e) = fs::create_dir_all(&uploads_dir) { + if let Err(e) = tokio::fs::create_dir_all(&uploads_dir).await { error!("Failed to create uploads directory: {}", e); } @@ -61,16 +72,27 @@ async fn main() { data_dir, cookie_secure, post_lock: Mutex::new(()), + posts_cache: RwLock::new(Vec::new()), + image_dims_cache: RwLock::new(HashMap::new()), + contact_rate_limit: Mutex::new(HashMap::new()), }); + post::cache::rebuild_posts_cache(&state).await; + info!( + "Posts cache primed with {} entries", + state.posts_cache.read().await.len() + ); + + spawn_rate_limit_reaper(state.clone()); + // CORS — locked down by default. Set FRONTEND_ORIGIN to the public URL of // the frontend if you ever expose the backend directly to browsers. // Normal deployments hit the backend through the Astro proxy, which is // server-to-server and not subject to CORS. let cors = match env::var("FRONTEND_ORIGIN").ok().filter(|s| !s.is_empty()) { Some(origin) => { - let value = HeaderValue::from_str(&origin) - .expect("FRONTEND_ORIGIN must be a valid origin URL"); + let value = + HeaderValue::from_str(&origin).expect("FRONTEND_ORIGIN must be a valid origin URL"); CorsLayer::new() .allow_origin(AllowOrigin::exact(value)) .allow_methods([ @@ -85,6 +107,10 @@ async fn main() { None => CorsLayer::new(), }; + // JSON routes get a tight 1 MB cap; the upload route keeps 50 MB. + const JSON_BODY_LIMIT: usize = 1024 * 1024; + const UPLOAD_BODY_LIMIT: usize = 50 * 1024 * 1024; + let app = Router::new() .route("/api/auth/login", post(handlers::auth::login)) .route("/api/auth/logout", post(handlers::auth::logout)) @@ -106,10 +132,20 @@ async fn main() { "/api/uploads/{filename}", delete(handlers::upload::delete_upload), ) - .route("/api/upload", post(handlers::upload::upload_file)) + .route( + "/api/upload", + post(handlers::upload::upload_file).layer(DefaultBodyLimit::max(UPLOAD_BODY_LIMIT)), + ) + .route("/api/contact", post(handlers::contact::submit_contact)) + .route("/api/messages", get(handlers::contact::list_messages)) + .route( + "/api/messages/{id}", + delete(handlers::contact::delete_message), + ) .route("/healthz", get(|| async { "ok" })) .nest_service("/uploads", ServeDir::new(uploads_dir)) - .layer(DefaultBodyLimit::max(50 * 1024 * 1024)) + .layer(DefaultBodyLimit::max(JSON_BODY_LIMIT)) + .layer(CompressionLayer::new().br(true).gzip(true)) .layer(cors) .with_state(state); @@ -118,3 +154,21 @@ async fn main() { let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); axum::serve(listener, app).await.unwrap(); } + +/// Periodically prunes expired entries from the contact rate-limit map so it +/// can't grow unbounded across the lifetime of the process. +fn spawn_rate_limit_reaper(state: Arc) { + tokio::spawn(async move { + let mut ticker = tokio::time::interval(Duration::from_secs(300)); + ticker.tick().await; + loop { + ticker.tick().await; + let now_ms = chrono::Utc::now().timestamp_millis(); + let mut map = state.contact_rate_limit.lock().await; + map.retain(|_, times| { + times.retain(|t| now_ms - *t < RATE_LIMIT_WINDOW_MS); + !times.is_empty() + }); + } + }); +} diff --git a/backend/src/models.rs b/backend/src/models.rs index df201c0..29af6e9 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -1,5 +1,13 @@ use chrono::NaiveDate; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Serialize, Deserialize, Clone)] +pub struct ContactLink { + pub kind: String, + pub label: String, + pub value: String, +} #[derive(Serialize, Deserialize, Clone)] pub struct SiteConfig { @@ -11,20 +19,26 @@ pub struct SiteConfig { pub favicon: String, pub theme: String, pub custom_css: String, + #[serde(default)] + pub contact_intro: String, + #[serde(default)] + pub contact_links: Vec, } impl Default for SiteConfig { fn default() -> Self { Self { - title: "Narlblog".to_string(), - subtitle: "A clean, modern blog".to_string(), - welcome_title: "Welcome to my blog".to_string(), - welcome_subtitle: - "Thoughts on software, design, and building things with Rust and Astro.".to_string(), - footer: "Built with Rust & Astro".to_string(), + title: "Ela's Atelier".to_string(), + subtitle: "Works on paper, canvas, and elsewhere".to_string(), + welcome_title: "Works on view".to_string(), + welcome_subtitle: "An ongoing arrangement of pieces, sketches, and stray observations." + .to_string(), + footer: "Hand-arranged with care".to_string(), favicon: "/favicon.svg".to_string(), - theme: "mocha".to_string(), + theme: "salon".to_string(), custom_css: "".to_string(), + contact_intro: "".to_string(), + contact_links: Vec::new(), } } } @@ -47,6 +61,10 @@ pub struct SiteConfigPatch { pub theme: Option, #[serde(default)] pub custom_css: Option, + #[serde(default)] + pub contact_intro: Option, + #[serde(default)] + pub contact_links: Option>, } #[derive(Serialize, Deserialize, Clone, Default)] @@ -62,7 +80,23 @@ pub struct PostMeta { pub draft: bool, } -#[derive(Serialize)] +#[derive(Serialize, Clone, Copy)] +pub struct ImageDim { + pub w: u32, + pub h: u32, +} + +#[derive(Serialize, Clone)] +pub struct CoverImage { + pub url: String, + pub alt: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub w: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub h: Option, +} + +#[derive(Serialize, Clone)] pub struct PostInfo { pub slug: String, pub date: NaiveDate, @@ -74,6 +108,16 @@ pub struct PostInfo { pub draft: bool, pub reading_time: u32, pub excerpt: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cover_image: Option, + pub image_count: u32, +} + +#[derive(Serialize, Clone)] +pub struct PostNeighbor { + pub slug: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, } #[derive(Serialize)] @@ -88,6 +132,15 @@ pub struct PostDetail { pub draft: bool, pub reading_time: u32, pub content: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cover_image: Option, + pub image_count: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub prev: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next: Option, + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub dimensions: HashMap, } #[derive(Deserialize)] @@ -108,6 +161,40 @@ pub struct CreatePostRequest { pub content: String, } +#[derive(Deserialize)] +pub struct ContactSubmission { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub email: Option, + #[serde(default)] + pub subject: Option, + pub message: String, + #[serde(default)] + pub website: Option, + pub started_at: i64, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct Message { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject: Option, + pub body: String, + pub received_at: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ip_hash: Option, +} + +#[derive(Serialize)] +pub struct ContactResponse { + pub ok: bool, +} + #[derive(Serialize)] pub struct ErrorResponse { pub error: String, diff --git a/backend/src/post/cache.rs b/backend/src/post/cache.rs new file mode 100644 index 0000000..3626843 --- /dev/null +++ b/backend/src/post/cache.rs @@ -0,0 +1,113 @@ +//! The in-memory posts cache: rebuilt from disk at startup and after every +//! mutation, plus prev/next neighbour lookup over the visible set. + +use tokio::fs; +use tracing::warn; + +use crate::models::{PostInfo, PostMeta, PostNeighbor}; +use crate::post::images::{cover_from, dim_for_url, extract_images}; +use crate::post::parse::{excerpt_from, parse_post, reading_time}; +use crate::{AppState, CachedPost}; + +fn build_post_info(slug: &str, meta: &PostMeta, body: &str) -> PostInfo { + let images = extract_images(body); + PostInfo { + slug: slug.to_string(), + date: meta.date, + title: meta.title.clone(), + summary: meta.summary.clone(), + tags: meta.tags.clone(), + draft: meta.draft, + reading_time: reading_time(body), + excerpt: excerpt_from(meta, body), + cover_image: cover_from(&images), + image_count: images.len() as u32, + } +} + +/// Scans the posts directory and replaces the in-memory cache. +/// Called at startup and after any mutation (create/rename/delete). +pub(crate) async fn rebuild_posts_cache(state: &AppState) { + let posts_dir = state.data_dir.join("posts"); + let mut posts: Vec = Vec::new(); + + let mut rd = match fs::read_dir(&posts_dir).await { + Ok(rd) => rd, + Err(_) => { + *state.posts_cache.write().await = posts; + return; + } + }; + + loop { + match rd.next_entry().await { + Ok(Some(entry)) => { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + let Some(slug) = path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + if slug.starts_with('.') { + continue; + } + let Ok(raw) = fs::read_to_string(&path).await else { + continue; + }; + let Ok((meta, body)) = parse_post(&raw) else { + warn!("Skipping post with bad frontmatter: {}", slug); + continue; + }; + let mut info = build_post_info(slug, &meta, &body); + if let Some(cover) = info.cover_image.as_mut() { + if let Some(d) = dim_for_url(state, &cover.url).await { + cover.w = Some(d.w); + cover.h = Some(d.h); + } + } + posts.push(CachedPost { info, body }); + } + Ok(None) => break, + Err(e) => { + warn!("Error iterating posts dir: {}", e); + break; + } + } + } + + posts.sort_by(|a, b| { + b.info + .date + .cmp(&a.info.date) + .then_with(|| a.info.slug.cmp(&b.info.slug)) + }); + *state.posts_cache.write().await = posts; +} + +pub(crate) async fn neighbors_from_cache( + state: &AppState, + slug: &str, + admin: bool, +) -> (Option, Option) { + let cache = state.posts_cache.read().await; + let visible: Vec<&PostInfo> = cache + .iter() + .filter(|p| admin || !p.info.draft) + .map(|p| &p.info) + .collect(); + let Some(i) = visible.iter().position(|p| p.slug == slug) else { + return (None, None); + }; + let to_neighbor = |p: &PostInfo| PostNeighbor { + slug: p.slug.clone(), + title: p.title.clone(), + }; + let prev = if i > 0 { + Some(to_neighbor(visible[i - 1])) + } else { + None + }; + let next = visible.get(i + 1).map(|p| to_neighbor(p)); + (prev, next) +} diff --git a/backend/src/post/images.rs b/backend/src/post/images.rs new file mode 100644 index 0000000..c3474e4 --- /dev/null +++ b/backend/src/post/images.rs @@ -0,0 +1,166 @@ +//! Markdown image extraction, cover selection, and the on-disk +//! image-dimension probe (header-only read, cached on `AppState`). + +use std::collections::HashMap; + +use crate::AppState; +use crate::models::{CoverImage, ImageDim}; + +/// Scan markdown for `![alt](url)` images. Returns (alt, url) pairs in order. +/// Skips inside fenced code blocks. Tolerates titles like `![alt](url "title")`. +pub(crate) fn extract_images(body: &str) -> Vec<(String, String)> { + let mut out = Vec::new(); + let mut in_fence = false; + for line in body.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with("```") || trimmed.starts_with("~~~") { + in_fence = !in_fence; + continue; + } + if in_fence { + continue; + } + let bytes = line.as_bytes(); + let mut i = 0; + while i + 1 < bytes.len() { + if bytes[i] == b'!' && bytes[i + 1] == b'[' { + if let Some(rel_close) = line[i + 2..].find(']') { + let close = i + 2 + rel_close; + if close + 1 < line.len() && bytes[close + 1] == b'(' { + if let Some(rel_paren) = line[close + 2..].find(')') { + let paren_end = close + 2 + rel_paren; + let alt = line[i + 2..close].to_string(); + let url_field = line[close + 2..paren_end].trim(); + let url = url_field + .split_once(|c: char| c.is_whitespace()) + .map(|(u, _)| u) + .unwrap_or(url_field) + .trim_matches(|c| c == '<' || c == '>') + .to_string(); + if !url.is_empty() { + out.push((alt, url)); + } + i = paren_end + 1; + continue; + } + } + } + } + i += 1; + } + } + out +} + +pub(crate) fn cover_from(images: &[(String, String)]) -> Option { + images.first().map(|(alt, url)| CoverImage { + url: url.clone(), + alt: alt.clone(), + w: None, + h: None, + }) +} + +/// Probe an uploads-relative URL for image dimensions. Reads only header +/// bytes via `imagesize::size`, off the runtime via `spawn_blocking`. +async fn compute_dim_from_url(state: &AppState, url: &str) -> Option { + let name = url.strip_prefix("/uploads/")?; + if name.is_empty() || name.contains("..") || name.contains('\\') || name.starts_with('/') { + return None; + } + let path = state.data_dir.join("uploads").join(name); + tokio::task::spawn_blocking(move || imagesize::size(&path).ok()) + .await + .ok() + .flatten() + .map(|s| ImageDim { + w: s.width as u32, + h: s.height as u32, + }) +} + +/// Returns cached dim if present, else probes the file and caches the result. +pub(crate) async fn dim_for_url(state: &AppState, url: &str) -> Option { + { + let cache = state.image_dims_cache.read().await; + if let Some(d) = cache.get(url) { + return Some(*d); + } + } + let d = compute_dim_from_url(state, url).await?; + state + .image_dims_cache + .write() + .await + .insert(url.to_string(), d); + Some(d) +} + +/// Returns a map of `url -> ImageDim` for the given URLs, using the cache +/// and probing only the URLs that aren't cached yet. +pub(crate) async fn dims_for_urls(state: &AppState, urls: &[String]) -> HashMap { + let mut out: HashMap = HashMap::new(); + let mut missing: Vec = Vec::new(); + { + let cache = state.image_dims_cache.read().await; + for url in urls { + if out.contains_key(url) { + continue; + } + if let Some(d) = cache.get(url) { + out.insert(url.clone(), *d); + } else { + missing.push(url.clone()); + } + } + } + if missing.is_empty() { + return out; + } + let mut newly: Vec<(String, ImageDim)> = Vec::new(); + for url in &missing { + if let Some(d) = compute_dim_from_url(state, url).await { + newly.push((url.clone(), d)); + } + } + if !newly.is_empty() { + let mut cache = state.image_dims_cache.write().await; + for (url, d) in &newly { + cache.insert(url.clone(), *d); + out.insert(url.clone(), *d); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::{cover_from, extract_images}; + + #[test] + fn extract_images_skips_fences_and_strips_titles() { + let md = "intro\n\ + ![a](/u/one.png)\n\ + ```\n\ + ![skip](/u/hidden.png)\n\ + ```\n\ + ![c](/u/two.png \"a title\")"; + let imgs = extract_images(md); + assert_eq!( + imgs, + vec![ + ("a".to_string(), "/u/one.png".to_string()), + ("c".to_string(), "/u/two.png".to_string()), + ] + ); + } + + #[test] + fn cover_from_takes_first_or_none() { + assert!(cover_from(&[]).is_none()); + let imgs = vec![("alt".to_string(), "/u/first.png".to_string())]; + let cover = cover_from(&imgs).unwrap(); + assert_eq!(cover.url, "/u/first.png"); + assert_eq!(cover.alt, "alt"); + } +} diff --git a/backend/src/post/mod.rs b/backend/src/post/mod.rs new file mode 100644 index 0000000..cf2777f --- /dev/null +++ b/backend/src/post/mod.rs @@ -0,0 +1,13 @@ +//! Post domain logic, split out of the HTTP layer. +//! +//! - [`parse`] — slug validation, frontmatter split/parse/serialize, reading +//! time, excerpt. Pure, no I/O. +//! - [`images`] — markdown image extraction, cover selection, and the +//! filesystem image-dimension probe + cache. +//! - [`cache`] — the in-memory posts cache (rebuild + neighbour lookup). +//! +//! `handlers::posts` stays thin and only orchestrates these. + +pub mod cache; +pub mod images; +pub mod parse; diff --git a/backend/src/post/parse.rs b/backend/src/post/parse.rs new file mode 100644 index 0000000..b3ed349 --- /dev/null +++ b/backend/src/post/parse.rs @@ -0,0 +1,179 @@ +//! Pure post parsing: slug validation, YAML frontmatter, reading time, +//! excerpt. No filesystem or network access — trivially unit-testable. + +use crate::error::AppError; +use crate::models::PostMeta; + +const WORDS_PER_MINUTE: u32 = 200; +const MAX_SLUG_LEN: usize = 100; +const WINDOWS_RESERVED: &[&str] = &[ + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", + "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", +]; + +pub(crate) fn validate_slug(s: &str) -> Result<(), AppError> { + if s.is_empty() { + return Err(AppError::BadRequest("Slug is empty".to_string())); + } + if s.len() > MAX_SLUG_LEN { + return Err(AppError::BadRequest(format!( + "Slug exceeds {} characters", + MAX_SLUG_LEN + ))); + } + if s.starts_with('.') { + return Err(AppError::BadRequest( + "Slug cannot start with '.'".to_string(), + )); + } + if s.ends_with('.') || s.ends_with(' ') { + return Err(AppError::BadRequest( + "Slug cannot end with '.' or space".to_string(), + )); + } + if s.contains("..") { + return Err(AppError::BadRequest("Slug cannot contain '..'".to_string())); + } + for c in s.chars() { + if c.is_control() { + return Err(AppError::BadRequest( + "Slug contains control characters".to_string(), + )); + } + if matches!(c, '/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*') { + return Err(AppError::BadRequest(format!( + "Slug contains invalid character '{}'", + c + ))); + } + } + let stem = s.split('.').next().unwrap_or("").to_ascii_uppercase(); + if WINDOWS_RESERVED.iter().any(|r| *r == stem) { + return Err(AppError::BadRequest("Slug is a reserved name".to_string())); + } + Ok(()) +} + +pub(crate) fn split_frontmatter(raw: &str) -> Option<(&str, &str)> { + let raw = raw + .strip_prefix("---\n") + .or_else(|| raw.strip_prefix("---\r\n"))?; + let end_marker = raw.find("\n---\n").or_else(|| raw.find("\r\n---\r\n"))?; + let yaml = &raw[..end_marker]; + let body_start = end_marker + + raw[end_marker..] + .find("---\n") + .or_else(|| raw[end_marker..].find("---\r\n"))? + + "---\n".len(); + let body = raw[body_start..] + .trim_start_matches('\n') + .trim_start_matches('\r'); + Some((yaml, body)) +} + +pub(crate) fn parse_post(raw: &str) -> Result<(PostMeta, String), AppError> { + let (yaml, body) = split_frontmatter(raw).ok_or_else(|| { + AppError::Internal( + "Missing frontmatter".to_string(), + Some("post is missing the YAML --- block".to_string()), + ) + })?; + let meta: PostMeta = serde_yaml::from_str(yaml).map_err(|e| { + AppError::Internal( + "Invalid frontmatter".to_string(), + Some(format!("YAML parse error: {}", e)), + ) + })?; + Ok((meta, body.to_string())) +} + +pub(crate) fn serialize_post(meta: &PostMeta, body: &str) -> Result { + let yaml = serde_yaml::to_string(meta) + .map_err(|e| AppError::Internal("Serialization error".to_string(), Some(e.to_string())))?; + Ok(format!("---\n{}---\n{}", yaml, body)) +} + +pub(crate) fn reading_time(body: &str) -> u32 { + let words = body.split_whitespace().count() as u32; + (words + WORDS_PER_MINUTE - 1) / WORDS_PER_MINUTE.max(1) +} + +pub(crate) fn excerpt_from(meta: &PostMeta, body: &str) -> String { + if let Some(s) = meta.summary.as_ref() { + if !s.trim().is_empty() { + return s.trim().to_string(); + } + } + let plain = body.replace(['#', '*', '_', '`'], "").replace('\n', " "); + let mut out: String = plain.chars().take(200).collect(); + if plain.chars().count() > 200 { + out.push_str("..."); + } + out.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::{parse_post, reading_time, split_frontmatter, validate_slug}; + use crate::error::AppError; + + #[test] + fn validate_slug_accepts_normal_slugs() { + assert!(validate_slug("hello-world").is_ok()); + assert!(validate_slug("a_b.c-123").is_ok()); + } + + #[test] + fn validate_slug_rejects_traversal_and_bad_chars() { + for bad in [ + "", + "../etc", + "with/slash", + "back\\slash", + "ends.", + "trailing ", + ".hidden", + ] { + assert!( + matches!(validate_slug(bad), Err(AppError::BadRequest(_))), + "expected {bad:?} to be rejected" + ); + } + let too_long = "x".repeat(101); + assert!(validate_slug(&too_long).is_err()); + assert!(matches!(validate_slug("CON"), Err(AppError::BadRequest(_)))); + } + + #[test] + fn split_frontmatter_handles_lf_and_crlf() { + let (yaml, body) = split_frontmatter("---\ndate: 2026-05-16\n---\nHello").unwrap(); + assert_eq!(yaml, "date: 2026-05-16"); + assert_eq!(body, "Hello"); + + let (y2, b2) = split_frontmatter("---\r\ndate: 2026-05-16\r\n---\r\nHi").unwrap(); + assert!(y2.contains("date: 2026-05-16")); + assert_eq!(b2, "Hi"); + + assert!(split_frontmatter("no frontmatter here").is_none()); + } + + #[test] + fn parse_post_reads_meta_and_body() { + let raw = "---\ndate: 2026-05-16\ntitle: Hello\ndraft: true\n---\nBody text"; + let (meta, body) = parse_post(raw).unwrap(); + assert_eq!(meta.title.as_deref(), Some("Hello")); + assert!(meta.draft); + assert_eq!(meta.date.to_string(), "2026-05-16"); + assert_eq!(body, "Body text"); + + assert!(parse_post("no frontmatter").is_err()); + } + + #[test] + fn reading_time_rounds_up_by_wpm() { + assert_eq!(reading_time(""), 0); + assert_eq!(reading_time("one"), 1); + assert_eq!(reading_time(&"word ".repeat(200)), 1); + assert_eq!(reading_time(&"word ".repeat(201)), 2); + } +} diff --git a/data/posts/another-post.md b/data/posts/another-post.md index 7ef0f07..7cc40b9 100644 --- a/data/posts/another-post.md +++ b/data/posts/another-post.md @@ -1,26 +1,14 @@ --- date: 2026-05-09 -summary: Markdown smoke test — bold, italic, links, blockquotes, fenced code. +summary: A second placeholder — layout smoke test. tags: - - meta + - intro draft: false --- -# My Second Blog Post +# Second placeholder -Adding some more content to test the layout and the glassy look! +A second placeholder so the salon-hang layout has room to breathe with more than one plate. Remove or replace from `/admin`. -### Markdown Testing +![Placeholder plate](/uploads/placeholder.jpg "replace me") -- **Bold text** -- *Italic text* -- [A link to GitHub](https://github.com) - -> "The only way to do great work is to love what you do." - Steve Jobs - -```rust -fn main() { - println!("Hello from Rust!"); -} -``` - -Enjoy reading! +> "The painter constructs, the photographer discloses." — Susan Sontag diff --git a/data/posts/hello-world.md b/data/posts/hello-world.md index 0b04412..f6b830e 100644 --- a/data/posts/hello-world.md +++ b/data/posts/hello-world.md @@ -1,21 +1,14 @@ --- date: 2026-05-09 -summary: First post — modern stack, glassy aesthetic, Catppuccin theme. +summary: Opening note for the gallery — what's on the walls, why these pieces. tags: - - meta - intro draft: false --- -# Welcome to Narlblog +# Welcome to the gallery -This is my very first blog post! Built with a modern, glassy aesthetic and the beautiful Catppuccin color palette. +This room collects work made on paper, canvas, and elsewhere — finished pieces alongside the studies that didn't make it. -## Technical Stack +![Placeholder plate](/uploads/placeholder.jpg "replace with a real plate from /admin/assets") -The blog is powered by: -- **Rust (Axum)**: Fast and reliable backend. -- **Astro**: Modern frontend framework for performance. -- **Tailwind CSS**: Glassy UI with Catppuccin theme. -- **Docker**: Simple containerized deployment. - -Feel free to explore and add your own posts by creating `.md` files in the `data/posts` directory! +Replace this entry with your first real work, or remove it from the catalogue via the admin dashboard. diff --git a/docker-compose.yml b/docker-compose.yml index 50a1a38..6f4708b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,8 @@ +name: elas-atelier + services: backend: + container_name: elas-atelier-backend build: context: ./backend dockerfile: Dockerfile @@ -17,7 +20,7 @@ services: - RUST_LOG=${RUST_LOG:-info} restart: unless-stopped networks: - - internal_net + - atelier_net healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:3000/healthz"] interval: 15s @@ -31,19 +34,21 @@ services: max-file: "3" frontend: + container_name: elas-atelier-frontend build: context: ./frontend dockerfile: Dockerfile ports: - - "4321:4321" + - "4322:4321" environment: - - PUBLIC_API_URL=http://backend:3000 + - PUBLIC_API_URL=${PUBLIC_API_URL:-} + - SITE_MODE=${SITE_MODE:-atelier} depends_on: backend: condition: service_healthy restart: unless-stopped networks: - - internal_net + - atelier_net logging: driver: json-file options: @@ -51,5 +56,6 @@ services: max-file: "3" networks: - internal_net: + atelier_net: + name: elas-atelier-net driver: bridge diff --git a/frontend/astro.config.mjs b/frontend/astro.config.mjs index c96e206..7e0436a 100644 --- a/frontend/astro.config.mjs +++ b/frontend/astro.config.mjs @@ -21,7 +21,10 @@ export default defineConfig({ service: { entrypoint: 'astro/assets/services/noop' } }, vite: { - plugins: [tailwindcss()] + plugins: [tailwindcss()], + build: { + chunkSizeWarningLimit: 600, + }, }, adapter: node({ diff --git a/frontend/biome.json b/frontend/biome.json new file mode 100644 index 0000000..46b5c35 --- /dev/null +++ b/frontend/biome.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["src/**/*.{ts,tsx,js,jsx,json}", "*.{ts,js,json}", "!**/dist", "!**/.astro"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "off", + "noControlCharactersInRegex": "warn", + "noArrayIndexKey": "warn" + }, + "correctness": { + "useExhaustiveDependencies": "warn" + }, + "security": { + "noDangerouslySetInnerHtml": "warn" + }, + "a11y": { + "noLabelWithoutControl": "warn", + "noSvgWithoutTitle": "warn", + "useKeyWithClickEvents": "warn", + "useButtonType": "warn", + "noStaticElementInteractions": "warn", + "useSemanticElements": "warn" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "always" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 397fac3..94d4623 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,8 +17,12 @@ "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.40.0", + "@fontsource-variable/eb-garamond": "^5.2.7", + "@fontsource-variable/fraunces": "^5.2.9", "@fontsource-variable/inter": "^5.2.5", "@fontsource-variable/jetbrains-mono": "^5.2.5", + "@fontsource/space-mono": "^5.2.9", + "@fontsource/vt323": "^5.2.7", "@replit/codemirror-vim": "^6.3.0", "@tailwindcss/vite": "^4.2.2", "astro": "^6.0.8", @@ -37,11 +41,13 @@ }, "devDependencies": { "@astrojs/check": "^0.9.9", + "@biomejs/biome": "^2.0.0", "@types/katex": "^0.16.8", "@types/node": "^25.5.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^3.0.0" }, "engines": { "node": ">=22.12.0" @@ -604,6 +610,181 @@ "node": ">=6.9.0" } }, + "node_modules/@biomejs/biome": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.15.tgz", + "integrity": "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.15", + "@biomejs/cli-darwin-x64": "2.4.15", + "@biomejs/cli-linux-arm64": "2.4.15", + "@biomejs/cli-linux-arm64-musl": "2.4.15", + "@biomejs/cli-linux-x64": "2.4.15", + "@biomejs/cli-linux-x64-musl": "2.4.15", + "@biomejs/cli-win32-arm64": "2.4.15", + "@biomejs/cli-win32-x64": "2.4.15" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.15.tgz", + "integrity": "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.15.tgz", + "integrity": "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.15.tgz", + "integrity": "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.15.tgz", + "integrity": "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.15.tgz", + "integrity": "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.15.tgz", + "integrity": "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.15.tgz", + "integrity": "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.15.tgz", + "integrity": "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -1684,6 +1865,24 @@ } } }, + "node_modules/@fontsource-variable/eb-garamond": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource-variable/eb-garamond/-/eb-garamond-5.2.7.tgz", + "integrity": "sha512-TbR+Pun4k5g85lgdxRNSbWka5vJgi9pzdFzFswgf7/7NRhKssvoumft1+ZGz3n91YYet96JcnjWrgPGGj6JTzg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/fraunces": { + "version": "5.2.9", + "resolved": "https://registry.npmjs.org/@fontsource-variable/fraunces/-/fraunces-5.2.9.tgz", + "integrity": "sha512-Y6IjunlN9Ni723np+GIgAaKzCDBrPRrqNi01TZxHs5wtHYROWFM9W6yiT+/gGwSjWIRD18oX17kD/BRWekc/Lw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource-variable/inter": { "version": "5.2.8", "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz", @@ -1702,6 +1901,24 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource/space-mono": { + "version": "5.2.9", + "resolved": "https://registry.npmjs.org/@fontsource/space-mono/-/space-mono-5.2.9.tgz", + "integrity": "sha512-b61faFOHEISQ/pD25G+cfGY9o/WW6lRv6hBQQfpWvEJ4y1V+S4gmth95EVyBE2VL3qDYHeVQ8nBzrplzdXTDDg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/vt323": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource/vt323/-/vt323-5.2.7.tgz", + "integrity": "sha512-8JTMM23vMhQxin9Cn/ijty8cNwXW4INrln0VAJ2227Rz0CVfkzM3qr3l/CqudZJ6BXCnbCGUTdf2ym3cTNex8A==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -3265,6 +3482,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -3274,6 +3502,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3387,6 +3622,131 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/kit": { "version": "2.4.28", "resolved": "https://registry.npmjs.org/@volar/kit/-/kit-2.4.28.tgz", @@ -3602,6 +3962,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/astro": { "version": "6.0.8", "resolved": "https://registry.npmjs.org/astro/-/astro-6.0.8.tgz", @@ -3759,6 +4129,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001781", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", @@ -3789,6 +4169,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -3819,6 +4216,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -4120,6 +4527,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/defu": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", @@ -4443,6 +4860,16 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -5396,6 +5823,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "11.2.7", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", @@ -6540,6 +6974,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/piccolore": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", @@ -7132,6 +7583,13 @@ "node": ">=20" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -7169,6 +7627,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -7178,6 +7643,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -7220,6 +7692,26 @@ "node": ">=8" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/style-mod": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", @@ -7282,6 +7774,13 @@ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyclip": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.12.tgz", @@ -7316,6 +7815,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.30", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", @@ -7874,6 +8403,36 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/vitefu": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", @@ -7893,6 +8452,86 @@ } } }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/volar-service-css": { "version": "0.0.70", "resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.70.tgz", @@ -8217,6 +8856,23 @@ "node": ">=4" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1cbdc57..16a68c3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,15 @@ "dev": "astro dev", "build": "astro build", "preview": "astro preview", - "astro": "astro" + "astro": "astro", + "check": "astro check", + "lint": "biome lint .", + "lint:fix": "biome lint --write .", + "format": "biome format --write .", + "format:check": "biome format .", + "test": "vitest run", + "test:watch": "vitest", + "verify": "astro check && biome lint . && vitest run" }, "dependencies": { "@astrojs/node": "^10.0.3", @@ -21,8 +29,12 @@ "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.40.0", + "@fontsource-variable/eb-garamond": "^5.2.7", + "@fontsource-variable/fraunces": "^5.2.9", "@fontsource-variable/inter": "^5.2.5", "@fontsource-variable/jetbrains-mono": "^5.2.5", + "@fontsource/space-mono": "^5.2.9", + "@fontsource/vt323": "^5.2.7", "@replit/codemirror-vim": "^6.3.0", "@tailwindcss/vite": "^4.2.2", "astro": "^6.0.8", @@ -41,10 +53,12 @@ }, "devDependencies": { "@astrojs/check": "^0.9.9", + "@biomejs/biome": "^2.0.0", "@types/katex": "^0.16.8", "@types/node": "^25.5.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^3.0.0" } } diff --git a/frontend/src/components/CyberFx.astro b/frontend/src/components/CyberFx.astro new file mode 100644 index 0000000..3759fc1 --- /dev/null +++ b/frontend/src/components/CyberFx.astro @@ -0,0 +1,125 @@ +--- +/* + * CyberFx — ambient + interactive layer for the `.cybersigil` theme. + * + * Renders an aria-hidden overlay root on every page. All visuals are CSS, + * scoped to `.cybersigil .cs-fx*` in global.css, so this is an inert, + * display:none no-op under every other theme. The bundled script only wires + * the scroll-entry databend on images and self-disables off-theme or under + * prefers-reduced-motion. + */ +--- + + + + diff --git a/frontend/src/components/react/AssetsButton.tsx b/frontend/src/components/react/AssetsButton.tsx index 7b4e5b0..22b0165 100644 --- a/frontend/src/components/react/AssetsButton.tsx +++ b/frontend/src/components/react/AssetsButton.tsx @@ -4,11 +4,13 @@ import AssetManager from './admin/AssetManager'; interface Props { className?: string; label?: string; + iconSize?: number; } export default function AssetsButton({ - className = 'inline-flex items-center gap-2 bg-surface0 hover:bg-surface1 text-subtext1 hover:text-text px-3 py-2 rounded-lg border border-surface1 transition-colors text-sm', + className = 'btn btn--ghost', label = 'Assets', + iconSize = 14, }: Props) { const [open, setOpen] = useState(false); @@ -31,8 +33,8 @@ export default function AssetsButton({ + + ); + } + + return ( +
+ + + {status === 'error' && ( +
+ {errorMsg} +
+ )} + +
+
+ + setName(e.target.value)} + className="field-input" + autoComplete="name" + maxLength={200} + /> +
+
+ + setEmail(e.target.value)} + className="field-input" + autoComplete="email" + maxLength={200} + /> +
+
+ +
+ + setSubject(e.target.value)} + className="field-input" + maxLength={300} + /> +
+ +
+ +