diff --git a/src/cli/serve.rs b/src/cli/serve.rs index 9bed405..b89d567 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -8,6 +8,7 @@ use tower_http::cors::CorsLayer; use crate::cache::{IsrCache, IsrCacheConfig}; use crate::config::ListenAddr; use crate::github; +use crate::icon_cache::IconCache; use crate::middleware::RequestIdLayer; use crate::state::AppState; use crate::tarpit::{TarpitConfig, TarpitState}; @@ -135,6 +136,10 @@ pub async fn run( "ISR cache initialized" ); + // Initialize icon cache + let icon_cache = Arc::new(IconCache::new()); + tracing::debug!("Icon cache initialized"); + let state = Arc::new(AppState { client, health_checker, @@ -142,6 +147,7 @@ pub async fn run( pool: pool.clone(), session_manager: session_manager.clone(), isr_cache, + icon_cache, }); // Regenerate common OGP images on startup diff --git a/src/handlers/assets.rs b/src/handlers/assets.rs index b524bf1..5b8f85d 100644 --- a/src/handlers/assets.rs +++ b/src/handlers/assets.rs @@ -1,5 +1,4 @@ use axum::{ - Json, extract::{Request, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, @@ -65,45 +64,3 @@ pub async fn handle_pgp_route( proxy::isr_handler(State(state), req).await } } - -/// Proxy icon requests to SvelteKit -pub async fn proxy_icons_handler( - State(state): State>, - jar: axum_extra::extract::CookieJar, - axum::extract::Path(path): axum::extract::Path, - req: Request, -) -> impl IntoResponse { - let full_path = format!("/api/icons/{}", path); - let query = req.uri().query().unwrap_or(""); - - let path_with_query = if query.is_empty() { - full_path.clone() - } else { - format!("{full_path}?{query}") - }; - - // Build trusted headers with session info - let mut forward_headers = HeaderMap::new(); - - if let Some(cookie) = jar.get("admin_session") - && let Ok(session_id) = ulid::Ulid::from_string(cookie.value()) - && let Some(session) = state.session_manager.validate_session(session_id) - && let Ok(username_value) = axum::http::HeaderValue::from_str(&session.username) - { - forward_headers.insert("x-session-user", username_value); - } - - match proxy::proxy_to_bun(&path_with_query, state, forward_headers).await { - Ok((status, headers, body)) => (status, headers, body).into_response(), - Err(err) => { - tracing::error!(error = %err, path = %full_path, "Failed to proxy icon request"); - ( - StatusCode::BAD_GATEWAY, - Json(serde_json::json!({ - "error": "Failed to fetch icon data" - })), - ) - .into_response() - } - } -} diff --git a/src/handlers/icons.rs b/src/handlers/icons.rs new file mode 100644 index 0000000..a4b6c42 --- /dev/null +++ b/src/handlers/icons.rs @@ -0,0 +1,162 @@ +//! Icon serving handler with caching +//! +//! Serves SVG icons with aggressive HTTP caching. On cache miss, proxies to +//! Bun's icon API and caches the result. + +use axum::{ + extract::{Path, Query, State}, + http::{HeaderMap, HeaderValue, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use std::{collections::HashMap, sync::Arc}; + +use crate::{proxy, state::AppState}; + +/// Response from Bun's icon API +#[derive(serde::Deserialize)] +struct IconApiResponse { + svg: String, +} + +/// Handle icon requests - serves cached SVG or proxies to Bun +/// +/// Route: GET /api/icons/{*path} +/// - For `{collection}/{name}.svg` paths: serve cached SVG with aggressive caching +/// - For all other paths: proxy to Bun's icon API (search, collections, JSON responses) +pub async fn serve_icon_handler( + State(state): State>, + Path(path): Path, + Query(query): Query>, +) -> Response { + // Check if this is a cacheable SVG request: {collection}/{name}.svg + if let Some((collection, name)) = parse_svg_path(&path) { + return serve_cached_svg(state, collection, name).await; + } + + // Not an SVG request - proxy to Bun (with query string for search) + proxy_to_bun_icons(&path, &query, state).await +} + +/// Parse path to extract collection and name if it matches {collection}/{name}.svg +fn parse_svg_path(path: &str) -> Option<(&str, &str)> { + let parts: Vec<&str> = path.split('/').collect(); + if parts.len() == 2 { + let collection = parts[0]; + let filename = parts[1]; + if let Some(name) = filename.strip_suffix(".svg") + && !collection.is_empty() + && !name.is_empty() + { + return Some((collection, name)); + } + } + None +} + +/// Serve an SVG icon with caching +async fn serve_cached_svg(state: Arc, collection: &str, name: &str) -> Response { + let cache_key = format!("{collection}:{name}"); + + // Check cache first + if let Some(svg) = state.icon_cache.get(&cache_key).await { + tracing::trace!( + collection = %collection, + name = %name, + "Icon cache hit" + ); + return svg_response(&svg); + } + + // Cache miss - fetch from Bun's icon API + let bun_path = format!("/api/icons/{collection}/{name}"); + let forward_headers = HeaderMap::new(); + + match proxy::proxy_to_bun(&bun_path, state.clone(), forward_headers).await { + Ok((status, _headers, body)) if status.is_success() => { + // Parse JSON response from Bun + match serde_json::from_slice::(&body) { + Ok(data) => { + // Cache the SVG + state.icon_cache.insert(cache_key, data.svg.clone()).await; + + tracing::debug!( + collection = %collection, + name = %name, + "Icon cached" + ); + + svg_response(&data.svg) + } + Err(e) => { + tracing::error!( + error = %e, + collection = %collection, + name = %name, + "Failed to parse icon response" + ); + (StatusCode::INTERNAL_SERVER_ERROR, "Invalid icon data").into_response() + } + } + } + Ok((status, _, _)) => { + tracing::debug!( + status = %status, + collection = %collection, + name = %name, + "Icon not found" + ); + (status, "Icon not found").into_response() + } + Err(e) => { + tracing::error!( + error = %e, + collection = %collection, + name = %name, + "Failed to proxy icon request" + ); + (StatusCode::BAD_GATEWAY, "Icon service unavailable").into_response() + } + } +} + +/// Proxy non-SVG icon requests to Bun +async fn proxy_to_bun_icons( + path: &str, + query: &HashMap, + state: Arc, +) -> Response { + let query_string = if query.is_empty() { + String::new() + } else { + let qs: Vec = query + .iter() + .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v))) + .collect(); + format!("?{}", qs.join("&")) + }; + let bun_path = format!("/api/icons/{path}{query_string}"); + let forward_headers = HeaderMap::new(); + + match proxy::proxy_to_bun(&bun_path, state, forward_headers).await { + Ok((status, headers, body)) => (status, headers, body).into_response(), + Err(e) => { + tracing::error!(error = %e, path = %path, "Failed to proxy icon request"); + (StatusCode::BAD_GATEWAY, "Icon service unavailable").into_response() + } + } +} + +/// Build SVG response with aggressive cache headers +fn svg_response(svg: &str) -> Response { + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("image/svg+xml"), + ); + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000, immutable"), + ); + + (StatusCode::OK, headers, svg.to_string()).into_response() +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index e7711f9..91ecfab 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,6 +1,7 @@ pub mod assets; pub mod auth; pub mod health; +pub mod icons; pub mod media; pub mod projects; pub mod settings; @@ -10,6 +11,7 @@ pub mod tags; pub use assets::*; pub use auth::*; pub use health::*; +pub use icons::*; pub use media::*; pub use projects::*; pub use settings::*; diff --git a/src/icon_cache.rs b/src/icon_cache.rs new file mode 100644 index 0000000..68afe45 --- /dev/null +++ b/src/icon_cache.rs @@ -0,0 +1,43 @@ +//! Icon cache for serving SVG icons with aggressive HTTP caching +//! +//! Icons are immutable for a given identifier (e.g., "lucide:home" always returns +//! the same SVG), so we can cache them with long TTLs and immutable HTTP headers. + +use moka::future::Cache; +use std::{sync::Arc, time::Duration}; + +/// Cache for rendered SVG icons +pub struct IconCache { + cache: Cache>, +} + +impl IconCache { + /// Create a new icon cache + /// + /// Config: 10,000 max entries, 24-hour TTL + pub fn new() -> Self { + let cache = Cache::builder() + .max_capacity(10_000) + .time_to_live(Duration::from_secs(86400)) // 24 hours + .name("icon_cache") + .build(); + + Self { cache } + } + + /// Get a cached SVG if it exists + pub async fn get(&self, identifier: &str) -> Option> { + self.cache.get(identifier).await + } + + /// Insert an SVG into the cache + pub async fn insert(&self, identifier: String, svg: String) { + self.cache.insert(identifier, Arc::new(svg)).await; + } +} + +impl Default for IconCache { + fn default() -> Self { + Self::new() + } +} diff --git a/src/main.rs b/src/main.rs index 9b86e37..42ac859 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod github; mod handlers; mod health; mod http; +mod icon_cache; mod media_processing; mod middleware; mod og; diff --git a/src/routes.rs b/src/routes.rs index 2b3204c..af8d4fd 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -84,8 +84,9 @@ pub fn api_routes() -> Router> { "/settings", get(handlers::get_settings_handler).put(handlers::update_settings_handler), ) - // Icon API - proxy to SvelteKit (authentication handled by SvelteKit) - .route("/icons/{*path}", get(handlers::proxy_icons_handler)) + // Icon API - handles both cached SVG serving and JSON proxy + // SVG requests (e.g., /icons/lucide/star.svg) are cached; others proxy to Bun + .route("/icons/{*path}", get(handlers::serve_icon_handler)) .fallback(api_404_and_method_handler) } diff --git a/src/state.rs b/src/state.rs index 744e375..b77b4c4 100644 --- a/src/state.rs +++ b/src/state.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use crate::{ auth::SessionManager, cache::IsrCache, health::HealthChecker, http::HttpClient, - tarpit::TarpitState, + icon_cache::IconCache, tarpit::TarpitState, }; /// Application state shared across all handlers @@ -14,6 +14,7 @@ pub struct AppState { pub pool: sqlx::PgPool, pub session_manager: Arc, pub isr_cache: Arc, + pub icon_cache: Arc, } /// Errors that can occur during proxying to Bun diff --git a/web/src/lib/components/Icon.svelte b/web/src/lib/components/Icon.svelte new file mode 100644 index 0000000..8f41688 --- /dev/null +++ b/web/src/lib/components/Icon.svelte @@ -0,0 +1,82 @@ + + +{#if loading} + + +{:else if error} + + +{:else if svg} + + +{/if} diff --git a/web/src/lib/components/IconSprite.svelte b/web/src/lib/components/IconSprite.svelte deleted file mode 100644 index 549f15d..0000000 --- a/web/src/lib/components/IconSprite.svelte +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - diff --git a/web/src/lib/components/TagChip.svelte b/web/src/lib/components/TagChip.svelte index afbfd47..34318b4 100644 --- a/web/src/lib/components/TagChip.svelte +++ b/web/src/lib/components/TagChip.svelte @@ -1,6 +1,6 @@ - - -
trackSocialClick(link.value)} class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-500 cursor-pointer" > - - - + {link.label} - - - + {link.label} trackSocialClick(`mailto:${link.value}`)} class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-500 cursor-pointer" > - - - + {link.label} { const projects = await apiFetch("/api/projects", { fetch }); - // Collect all tag icons across all projects - const allTags = projects.flatMap((project) => project.tags); - const icons = await collectTagIcons(allTags); - return { projects, - icons, }; }; diff --git a/web/src/routes/admin/projects/+page.svelte b/web/src/routes/admin/projects/+page.svelte index 4405e96..9f8a707 100644 --- a/web/src/routes/admin/projects/+page.svelte +++ b/web/src/routes/admin/projects/+page.svelte @@ -2,7 +2,6 @@ import Button from "$lib/components/admin/Button.svelte"; import Table from "$lib/components/admin/Table.svelte"; import TagChip from "$lib/components/TagChip.svelte"; - import IconSprite from "$lib/components/IconSprite.svelte"; import { goto } from "$app/navigation"; import type { PageData } from "./$types"; import type { ProjectStatus } from "$lib/admin-types"; @@ -63,8 +62,6 @@ Projects | Admin - -
diff --git a/web/src/routes/admin/projects/[id]/+page.server.ts b/web/src/routes/admin/projects/[id]/+page.server.ts index 3807287..d51fb1e 100644 --- a/web/src/routes/admin/projects/[id]/+page.server.ts +++ b/web/src/routes/admin/projects/[id]/+page.server.ts @@ -1,11 +1,6 @@ import type { PageServerLoad } from "./$types"; import { apiFetch } from "$lib/api.server"; -import { collectTagIcons } from "$lib/server/tag-icons"; -import type { - AdminProject, - AdminTagWithCount, - AdminTag, -} from "$lib/admin-types"; +import type { AdminProject, AdminTagWithCount } from "$lib/admin-types"; export const load: PageServerLoad = async ({ params, fetch }) => { const { id } = params; @@ -16,16 +11,8 @@ export const load: PageServerLoad = async ({ params, fetch }) => { apiFetch("/api/tags", { fetch }), ]); - // Collect icons for sprite (from available tags + project tags) - const allTags: AdminTag[] = [...availableTags]; - if (project) { - allTags.push(...project.tags); - } - const icons = await collectTagIcons(allTags); - return { project, availableTags, - icons, }; }; diff --git a/web/src/routes/admin/projects/[id]/+page.svelte b/web/src/routes/admin/projects/[id]/+page.svelte index e572ba3..d27c715 100644 --- a/web/src/routes/admin/projects/[id]/+page.svelte +++ b/web/src/routes/admin/projects/[id]/+page.svelte @@ -3,7 +3,6 @@ import { resolve } from "$app/paths"; import ProjectForm from "$lib/components/admin/ProjectForm.svelte"; import Modal from "$lib/components/admin/Modal.svelte"; - import IconSprite from "$lib/components/IconSprite.svelte"; import { updateAdminProject, deleteAdminProject } from "$lib/api"; import type { UpdateProjectData, CreateProjectData } from "$lib/admin-types"; import type { PageData } from "./$types"; @@ -61,8 +60,6 @@ Edit Project | Admin - -
diff --git a/web/src/routes/admin/projects/new/+page.server.ts b/web/src/routes/admin/projects/new/+page.server.ts index 18430f4..ce0454c 100644 --- a/web/src/routes/admin/projects/new/+page.server.ts +++ b/web/src/routes/admin/projects/new/+page.server.ts @@ -1,6 +1,5 @@ import type { PageServerLoad } from "./$types"; import { apiFetch } from "$lib/api.server"; -import { collectTagIcons } from "$lib/server/tag-icons"; import type { AdminTagWithCount } from "$lib/admin-types"; export const load: PageServerLoad = async ({ fetch }) => { @@ -8,11 +7,7 @@ export const load: PageServerLoad = async ({ fetch }) => { fetch, }); - // Collect icons for sprite - const icons = await collectTagIcons(availableTags); - return { availableTags, - icons, }; }; diff --git a/web/src/routes/admin/projects/new/+page.svelte b/web/src/routes/admin/projects/new/+page.svelte index 3df8af7..fa62d2b 100644 --- a/web/src/routes/admin/projects/new/+page.svelte +++ b/web/src/routes/admin/projects/new/+page.svelte @@ -2,7 +2,6 @@ import { goto } from "$app/navigation"; import { resolve } from "$app/paths"; import ProjectForm from "$lib/components/admin/ProjectForm.svelte"; - import IconSprite from "$lib/components/IconSprite.svelte"; import { createAdminProject } from "$lib/api"; import type { CreateProjectData } from "$lib/admin-types"; import type { PageData } from "./$types"; @@ -19,8 +18,6 @@ New Project | Admin - -
diff --git a/web/src/routes/admin/tags/+page.server.ts b/web/src/routes/admin/tags/+page.server.ts index 62b6f45..b9d93e5 100644 --- a/web/src/routes/admin/tags/+page.server.ts +++ b/web/src/routes/admin/tags/+page.server.ts @@ -1,6 +1,5 @@ import type { PageServerLoad } from "./$types"; import { apiFetch } from "$lib/api.server"; -import { collectTagIcons } from "$lib/server/tag-icons"; import type { AdminTagWithCount } from "$lib/admin-types"; export const load: PageServerLoad = async ({ fetch }) => { @@ -9,11 +8,7 @@ export const load: PageServerLoad = async ({ fetch }) => { // Sort by project count descending (popularity) const sortedTags = [...tags].sort((a, b) => b.projectCount - a.projectCount); - // Collect icons for sprite - const icons = await collectTagIcons(sortedTags); - return { tags: sortedTags, - icons, }; }; diff --git a/web/src/routes/admin/tags/+page.svelte b/web/src/routes/admin/tags/+page.svelte index 100e277..47b405a 100644 --- a/web/src/routes/admin/tags/+page.svelte +++ b/web/src/routes/admin/tags/+page.svelte @@ -5,7 +5,6 @@ import ColorPicker from "$lib/components/admin/ColorPicker.svelte"; import IconPicker from "$lib/components/admin/IconPicker.svelte"; import TagChip from "$lib/components/TagChip.svelte"; - import IconSprite from "$lib/components/IconSprite.svelte"; import { createAdminTag, deleteAdminTag } from "$lib/api"; import type { CreateTagData, AdminTagWithCount } from "$lib/admin-types"; import type { PageData } from "./$types"; @@ -146,8 +145,6 @@ Tags | Admin - -
diff --git a/web/src/routes/admin/tags/[slug]/+page.server.ts b/web/src/routes/admin/tags/[slug]/+page.server.ts index 768dd81..4ba8e41 100644 --- a/web/src/routes/admin/tags/[slug]/+page.server.ts +++ b/web/src/routes/admin/tags/[slug]/+page.server.ts @@ -1,6 +1,5 @@ import type { PageServerLoad } from "./$types"; import { apiFetch } from "$lib/api.server"; -import { renderIconsBatch } from "$lib/server/icons"; import { error } from "@sveltejs/kit"; import type { AdminTag, AdminProject } from "$lib/admin-types"; @@ -37,30 +36,9 @@ export const load: PageServerLoad = async ({ params, fetch }) => { // Non-fatal - just show empty related tags } - // Collect all unique icons - const iconIds = new Set(); - if (tagData.tag.icon) { - iconIds.add(tagData.tag.icon); - } - for (const tag of relatedTags) { - if (tag.icon) { - iconIds.add(tag.icon); - } - } - - // Batch render all icons - const iconsMap = await renderIconsBatch([...iconIds]); - - // Convert Map to plain object for serialization - const icons: Record = {}; - for (const [id, svg] of iconsMap) { - icons[id] = svg; - } - return { tag: tagData.tag, projects: tagData.projects, relatedTags, - icons, }; }; diff --git a/web/src/routes/admin/tags/[slug]/+page.svelte b/web/src/routes/admin/tags/[slug]/+page.svelte index 45435a0..311329e 100644 --- a/web/src/routes/admin/tags/[slug]/+page.svelte +++ b/web/src/routes/admin/tags/[slug]/+page.svelte @@ -5,7 +5,7 @@ import ColorPicker from "$lib/components/admin/ColorPicker.svelte"; import IconPicker from "$lib/components/admin/IconPicker.svelte"; import TagChip from "$lib/components/TagChip.svelte"; - import IconSprite from "$lib/components/IconSprite.svelte"; + import Icon from "$lib/components/Icon.svelte"; import { updateAdminTag, deleteAdminTag } from "$lib/api"; import { goto, invalidateAll } from "$app/navigation"; import type { PageData } from "./$types"; @@ -28,49 +28,6 @@ let color = $state(data.tag.color); let saving = $state(false); - // Preview icon SVG - starts with server-rendered, updates on icon change - // svelte-ignore state_referenced_locally - let previewIconSvg = $state( - data.tag.icon ? (data.icons[data.tag.icon] ?? "") : "", - ); - let iconLoadTimeout: ReturnType | null = null; - - // Watch for icon changes and fetch new preview - $effect(() => { - const currentIcon = icon; - - // Clear pending timeout - if (iconLoadTimeout) { - clearTimeout(iconLoadTimeout); - } - - if (!currentIcon) { - previewIconSvg = ""; - return; - } - - // Check if icon is already in sprite - if (data.icons[currentIcon]) { - previewIconSvg = data.icons[currentIcon]; - return; - } - - // Debounce icon fetching for new icons - iconLoadTimeout = setTimeout(async () => { - try { - const response = await fetch( - `/api/icons/${currentIcon.replace(":", "/")}`, - ); - if (response.ok) { - const iconData = await response.json(); - previewIconSvg = iconData.svg ?? ""; - } - } catch { - // Keep existing preview on error - } - }, 200); - }); - // Delete state let deleteModalOpen = $state(false); let deleteConfirmReady = $state(false); @@ -145,8 +102,6 @@ Edit {data.tag.name} | Tags | Admin - - - +
Preview @@ -202,11 +157,8 @@ class={tagBaseClasses} style="border-left-color: #{color || '06b6d4'}" > - {#if previewIconSvg} - - - {@html previewIconSvg} - + {#if icon} + {/if} {name || "Tag Name"} diff --git a/web/src/routes/api/icons/[collection]/[name]/+server.ts b/web/src/routes/api/icons/[collection]/[name]/+server.ts index ecb8a8a..1eaf36b 100644 --- a/web/src/routes/api/icons/[collection]/[name]/+server.ts +++ b/web/src/routes/api/icons/[collection]/[name]/+server.ts @@ -1,12 +1,8 @@ import { json, error } from "@sveltejs/kit"; import type { RequestHandler } from "./$types"; -import { requireAuth } from "$lib/server/auth"; import { getIconForApi } from "$lib/server/icons"; export const GET: RequestHandler = async (event) => { - // Require authentication - requireAuth(event); - const { collection, name } = event.params; const identifier = `${collection}:${name}`;