perf: implement proper LRU Cache and ETag optimizations
This commit is contained in:
8
.planning/ui-reviews/.gitignore
vendored
Normal file
8
.planning/ui-reviews/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Screenshot files — never commit binary assets
|
||||||
|
*.png
|
||||||
|
*.webp
|
||||||
|
*.jpg
|
||||||
|
*.jpeg
|
||||||
|
*.gif
|
||||||
|
*.bmp
|
||||||
|
*.tiff
|
||||||
66
.planning/ui-reviews/UI-REVIEW.md
Normal file
66
.planning/ui-reviews/UI-REVIEW.md
Normal file
@@ -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 && <p>{error}</p>`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`
|
||||||
@@ -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);
|
const result = await immich.streamAsset(assetId, size);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
console.error(`[Image API] ❌ Asset not found in Immich: ${assetId} (Size: ${size})`);
|
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,
|
: result.contentType,
|
||||||
// Images are immutable once uploaded to Immich — cache aggressively
|
// Images are immutable once uploaded to Immich — cache aggressively
|
||||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||||
|
'ETag': etag,
|
||||||
'X-RateLimit-Remaining': String(remaining),
|
'X-RateLimit-Remaining': String(remaining),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -35,12 +35,12 @@ const PRESET_FONTS: Record<string, { heading: string; body: string; caption: str
|
|||||||
};
|
};
|
||||||
|
|
||||||
const PRESET_ACCENTS: Record<string, string> = {
|
const PRESET_ACCENTS: Record<string, string> = {
|
||||||
studio: '#e60012',
|
studio: 'var(--accent-studio, #e60012)',
|
||||||
minimal: '#000000',
|
minimal: 'var(--accent-minimal, #000000)',
|
||||||
editorial: '#8B2500',
|
editorial: 'var(--accent-editorial, #8B2500)',
|
||||||
classic: '#c49a3c',
|
classic: 'var(--accent-classic, #c49a3c)',
|
||||||
noir: '#ff6b35',
|
noir: 'var(--accent-noir, #ff6b35)',
|
||||||
monograph: '#333333',
|
monograph: 'var(--accent-monograph, #333333)',
|
||||||
};
|
};
|
||||||
|
|
||||||
function getAttr(attr: string): string {
|
function getAttr(attr: string): string {
|
||||||
@@ -91,9 +91,12 @@ export function DevToolbar() {
|
|||||||
setAttr('data-preset', p);
|
setAttr('data-preset', p);
|
||||||
setPreset(p);
|
setPreset(p);
|
||||||
// Update accent + fonts
|
// Update accent + fonts
|
||||||
const accent = PRESET_ACCENTS[p] || '#e60012';
|
const accent = PRESET_ACCENTS[p] || 'var(--accent-studio, #e60012)';
|
||||||
setVar('--accent', accent);
|
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];
|
const fonts = PRESET_FONTS[p];
|
||||||
if (fonts) {
|
if (fonts) {
|
||||||
setVar('--font-serif', `'${fonts.heading}', Georgia, 'Times New Roman', serif`);
|
setVar('--font-serif', `'${fonts.heading}', Georgia, 'Times New Roman', serif`);
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export function MapView() {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="map-container__loading">
|
<div className="map-container__loading" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -162,9 +162,39 @@ export function MapView() {
|
|||||||
{loading && (
|
{loading && (
|
||||||
<div
|
<div
|
||||||
className="map-container__loading"
|
className="map-container__loading"
|
||||||
style={{ position: 'absolute', inset: 0, zIndex: 1000 }}
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
color: '#fff'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
style={{ animation: 'spin 1s linear infinite', marginBottom: '8px' }}
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" strokeOpacity="0.25"></circle>
|
||||||
|
<path d="M12 2a10 10 0 0 1 10 10"></path>
|
||||||
|
</svg>
|
||||||
<p>Loading map…</p>
|
<p>Loading map…</p>
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div ref={containerRef} className="map-container" />
|
<div ref={containerRef} className="map-container" />
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ export default function PasswordGate({ slug, title }: PasswordGateProps) {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} else {
|
} else {
|
||||||
setError('Wrong password');
|
setError('Incorrect password. Please try again.');
|
||||||
setPassword('');
|
setPassword('');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError('Something went wrong');
|
setError('Unable to verify password. Please try again later.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
11
lib/cache.ts
11
lib/cache.ts
@@ -21,12 +21,19 @@ class MemoryCache {
|
|||||||
return null;
|
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;
|
return entry.data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
set<T>(key: string, data: T, ttlMs: number): void {
|
set<T>(key: string, data: T, ttlMs: number): void {
|
||||||
// Evict oldest entries if at capacity
|
// If key exists, delete it first to ensure we push it to the back
|
||||||
if (this.store.size >= this.maxEntries) {
|
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;
|
const firstKey = this.store.keys().next().value;
|
||||||
if (firstKey) this.store.delete(firstKey);
|
if (firstKey) this.store.delete(firstKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ export function middleware(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
response.headers.set('Content-Security-Policy', cspDirectives);
|
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;
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
33
scripts/pentest-ratelimit.sh
Executable file
33
scripts/pentest-ratelimit.sh
Executable file
@@ -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! ✨"
|
||||||
Reference in New Issue
Block a user