From bf72a88d6c980b382fb988e1ee9ca795dfbb5fd7 Mon Sep 17 00:00:00 2001 From: ralksta Date: Fri, 27 Mar 2026 11:11:23 +0100 Subject: [PATCH] perf: implement proper LRU Cache and ETag optimizations --- .planning/ui-reviews/.gitignore | 8 ++++ .planning/ui-reviews/UI-REVIEW.md | 66 +++++++++++++++++++++++++++++++ app/api/image/[id]/route.ts | 15 +++++++ components/DevToolbar.tsx | 19 +++++---- components/MapView.tsx | 34 +++++++++++++++- components/PasswordGate.tsx | 4 +- lib/cache.ts | 11 +++++- middleware.ts | 6 +++ scripts/pentest-ratelimit.sh | 33 ++++++++++++++++ 9 files changed, 182 insertions(+), 14 deletions(-) create mode 100644 .planning/ui-reviews/.gitignore create mode 100644 .planning/ui-reviews/UI-REVIEW.md create mode 100755 scripts/pentest-ratelimit.sh diff --git a/.planning/ui-reviews/.gitignore b/.planning/ui-reviews/.gitignore new file mode 100644 index 0000000..5393559 --- /dev/null +++ b/.planning/ui-reviews/.gitignore @@ -0,0 +1,8 @@ +# Screenshot files — never commit binary assets +*.png +*.webp +*.jpg +*.jpeg +*.gif +*.bmp +*.tiff diff --git a/.planning/ui-reviews/UI-REVIEW.md b/.planning/ui-reviews/UI-REVIEW.md new file mode 100644 index 0000000..2127305 --- /dev/null +++ b/.planning/ui-reviews/UI-REVIEW.md @@ -0,0 +1,66 @@ +# Phase Standalone — UI Review + +**Audited:** 2024 +**Baseline:** Abstract 6-Pillar Standards +**Screenshots:** Not captured (code-only audit) + +--- + +## Pillar Scores + +| Pillar | Score | Key Finding | +|--------|-------|-------------| +| 1. Copywriting | 3/4 | Error states use generic 'Something went wrong'. | +| 2. Visuals | 3/4 | Standard component structures, but icon accessibility could be improved. | +| 3. Color | 2/4 | Heavy use of hardcoded colors in `DevToolbar.tsx`. | +| 4. Typography | 3/4 | Uses custom CSS styles rather than utility classes; mostly consistent. | +| 5. Spacing | 3/4 | Uses custom CSS styles; no major arbitrary inline spacing found. | +| 6. Experience Design | 3/4 | Good coverage of loading/error states in MapView and PasswordGate. | + +**Overall: 17/24** + +--- + +## Top 3 Priority Fixes + +1. **Refactor Colors in DevToolbar** — Hardcoded hex values break theme consistency and dark mode — Extract colors to CSS variables in the theme layer. +2. **Improve Error Copy in PasswordGate** — Generic "Something went wrong" frustrates users — Replace with specific, actionable messaging like "Incorrect password, please try again." +3. **Enhance MapView Loading State** — Simple boolean loading state causes layout shifts — Implement a skeleton loader or a dedicated map placeholder to improve perceived performance. + +--- + +## Detailed Findings + +### Pillar 1: Copywriting (3/4) +- **PasswordGate.tsx**: Uses standard CTA ('Enter', 'Verifying...') but error state falls back to generic "Something went wrong" at line 40. + +### Pillar 2: Visuals (3/4) +- Components use semantic HTML for the most part. SVG icons in `ThemeToggle`, `Lightbox`, and `Footer` lack robust `aria-label` alternatives for screen readers in some instances. + +### Pillar 3: Color (2/4) +- **DevToolbar.tsx**: Contains over 20 distinct hardcoded hex values (clues: `#e60012`, `#e4e4e7`, `#166534`, `#ff6b35`). This violates the single-source-of-truth theme approach. +- **Lightbox / SetupScreen**: Minimal hardcoded values used for fallback (`#000`, `#ffffff`). + +### Pillar 4: Typography (3/4) +- Exclusively uses CSS module classes rather than utility variables. +- Some inline font sizes exist (e.g., `fontSize: 10` in DevToolbar). + +### Pillar 5: Spacing (3/4) +- Relies cleanly on CSS modules. +- Only a few inline spacing overrides (e.g., `marginTop: 6` in DevToolbar). + +### Pillar 6: Experience Design (3/4) +- **PasswordGate.tsx**: Correctly disables the submit button and indicates processing state (`loading ? 'Verifying...' : 'Enter'`). +- **MapView.tsx**: Handles API mapping errors gracefully (`error &&

{error}

`). + +--- + +## Files Audited +- `app/api/auth/route.ts` +- `app/api/health/route.ts` +- `components/PasswordGate.tsx` +- `components/MapView.tsx` +- `components/Lightbox.tsx` +- `components/DevToolbar.tsx` +- `components/ThemeToggle.tsx` +- `components/Footer.tsx` diff --git a/app/api/image/[id]/route.ts b/app/api/image/[id]/route.ts index 79115a0..5df32ba 100644 --- a/app/api/image/[id]/route.ts +++ b/app/api/image/[id]/route.ts @@ -61,6 +61,20 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } } + // ── Browser Cache Optimization ───────────────────── + // Use the opaque token to generate a safe ETag without leaking Immich UUIDs + const etag = `W/"${token}-${size}"`; + if (request.headers.get('if-none-match') === etag) { + return new NextResponse(null, { + status: 304, + headers: { + 'ETag': etag, + 'Cache-Control': 'public, max-age=31536000, immutable', + 'X-RateLimit-Remaining': String(remaining), + }, + }); + } + const result = await immich.streamAsset(assetId, size); if (!result) { console.error(`[Image API] ❌ Asset not found in Immich: ${assetId} (Size: ${size})`); @@ -73,6 +87,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ : result.contentType, // Images are immutable once uploaded to Immich — cache aggressively 'Cache-Control': 'public, max-age=31536000, immutable', + 'ETag': etag, 'X-RateLimit-Remaining': String(remaining), }; diff --git a/components/DevToolbar.tsx b/components/DevToolbar.tsx index 66405fb..75d5c88 100644 --- a/components/DevToolbar.tsx +++ b/components/DevToolbar.tsx @@ -35,12 +35,12 @@ const PRESET_FONTS: Record = { - studio: '#e60012', - minimal: '#000000', - editorial: '#8B2500', - classic: '#c49a3c', - noir: '#ff6b35', - monograph: '#333333', + studio: 'var(--accent-studio, #e60012)', + minimal: 'var(--accent-minimal, #000000)', + editorial: 'var(--accent-editorial, #8B2500)', + classic: 'var(--accent-classic, #c49a3c)', + noir: 'var(--accent-noir, #ff6b35)', + monograph: 'var(--accent-monograph, #333333)', }; function getAttr(attr: string): string { @@ -91,9 +91,12 @@ export function DevToolbar() { setAttr('data-preset', p); setPreset(p); // Update accent + fonts - const accent = PRESET_ACCENTS[p] || '#e60012'; + const accent = PRESET_ACCENTS[p] || 'var(--accent-studio, #e60012)'; setVar('--accent', accent); - setVar('--accent-dim', `${accent}1f`); + // Note: since accent might be a CSS var, appending hex opacity won't work in all browsers if it's a var, + // but we provide it for backward compatibility where possible. Or use color-mix if supported. + // For now we set it to same var or use a generic dim if needed. + setVar('--accent-dim', `color-mix(in srgb, ${accent} 12%, transparent)`); const fonts = PRESET_FONTS[p]; if (fonts) { setVar('--font-serif', `'${fonts.heading}', Georgia, 'Times New Roman', serif`); diff --git a/components/MapView.tsx b/components/MapView.tsx index 03e75a4..570f940 100644 --- a/components/MapView.tsx +++ b/components/MapView.tsx @@ -151,7 +151,7 @@ export function MapView() { if (error) { return ( -
+

{error}

); @@ -162,9 +162,39 @@ export function MapView() { {loading && (
+ + + +

Loading map…

+
)}
diff --git a/components/PasswordGate.tsx b/components/PasswordGate.tsx index 2066616..8c1ef9a 100644 --- a/components/PasswordGate.tsx +++ b/components/PasswordGate.tsx @@ -33,11 +33,11 @@ export default function PasswordGate({ slug, title }: PasswordGateProps) { if (res.ok) { window.location.reload(); } else { - setError('Wrong password'); + setError('Incorrect password. Please try again.'); setPassword(''); } } catch { - setError('Something went wrong'); + setError('Unable to verify password. Please try again later.'); } finally { setLoading(false); } diff --git a/lib/cache.ts b/lib/cache.ts index 325ed44..9b8c3a1 100644 --- a/lib/cache.ts +++ b/lib/cache.ts @@ -21,12 +21,19 @@ class MemoryCache { return null; } + // LRU: Move accessed item to the back of the Map (newest) + this.store.delete(key); + this.store.set(key, entry); + return entry.data as T; } set(key: string, data: T, ttlMs: number): void { - // Evict oldest entries if at capacity - if (this.store.size >= this.maxEntries) { + // If key exists, delete it first to ensure we push it to the back + if (this.store.has(key)) { + this.store.delete(key); + } else if (this.store.size >= this.maxEntries) { + // Evict oldest entry (first item) if at capacity const firstKey = this.store.keys().next().value; if (firstKey) this.store.delete(firstKey); } diff --git a/middleware.ts b/middleware.ts index 106517e..1ac3523 100644 --- a/middleware.ts +++ b/middleware.ts @@ -28,6 +28,12 @@ export function middleware(request: NextRequest) { }); response.headers.set('Content-Security-Policy', cspDirectives); + response.headers.set('X-DNS-Prefetch-Control', 'on'); + response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); + response.headers.set('X-Frame-Options', 'SAMEORIGIN'); + response.headers.set('X-Content-Type-Options', 'nosniff'); + response.headers.set('Referrer-Policy', 'origin-when-cross-origin'); + response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); return response; } diff --git a/scripts/pentest-ratelimit.sh b/scripts/pentest-ratelimit.sh new file mode 100755 index 0000000..71aa285 --- /dev/null +++ b/scripts/pentest-ratelimit.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# Simple rate limit testing script for Immich-Folio +# It fires 15 rapid POST requests to the /api/auth endpoint. + +ENDPOINT="http://localhost:3000/api/auth" +PAYLOAD='{"slug": "test", "password": "wrong"}' + +echo "======================================" +echo "🛡️ Starting Rate-Limit Pentest..." +echo "Target: $ENDPOINT" +echo "Expected behavior: First 10 requests should be 40x (e.g. 401/400), subsequent requests should be 429." +echo "======================================" + +for i in {1..15} +do + echo -n "Request #$i: " + + # Fetch the HTTP status code + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" -d "$PAYLOAD" $ENDPOINT) + + if [ "$HTTP_STATUS" == "429" ]; then + echo "🚨 HTTP 429 (Too Many Requests) -> RATE LIMIT TRIGGERED!" + else + echo "🟢 HTTP $HTTP_STATUS -> Allowed" + fi + + # Sleep a tiny bit to avoid completely saturating local resources, but fast enough to hit the 10 RPM limit + sleep 0.2 +done + +echo "======================================" +echo "Pentest completed! ✨"