chore: implement audit action items and translate hardcoded German text
This commit is contained in:
@@ -38,7 +38,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||||||
if (!success) {
|
if (!success) {
|
||||||
const retryAfter = Math.ceil((resetAt - Date.now()) / 1000);
|
const retryAfter = Math.ceil((resetAt - Date.now()) / 1000);
|
||||||
const userAgent = request.headers.get('user-agent') || 'unknown';
|
const userAgent = request.headers.get('user-agent') || 'unknown';
|
||||||
console.warn(`[Image API] ⚠️ Rate limit exceeded for IP: ${ip} (UA: ${userAgent}). Retry after ${retryAfter}s`);
|
console.warn(
|
||||||
|
`[Image API] ⚠️ Rate limit exceeded for IP: ${ip} (UA: ${userAgent}). Retry after ${retryAfter}s`,
|
||||||
|
);
|
||||||
return NextResponse.json({ error: 'Too many requests' }, { status: 429 });
|
return NextResponse.json({ error: 'Too many requests' }, { status: 429 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import { SubpageNav } from '@/components/SubpageNav';
|
|||||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||||
import { ScrollToTop } from '@/components/ScrollToTop';
|
import { ScrollToTop } from '@/components/ScrollToTop';
|
||||||
import { Footer } from '@/components/Footer';
|
import { Footer } from '@/components/Footer';
|
||||||
|
import { SetupScreen } from '@/components/SetupScreen';
|
||||||
import { getConfig, getGoogleFontsUrl, AppConfig } from '@/lib/config';
|
import { getConfig, getGoogleFontsUrl, AppConfig } from '@/lib/config';
|
||||||
// DevToolbarLoader ist eine Client Component (ssr: false nur dort erlaubt)
|
// DevToolbarLoader is a Client Component (ssr: false is only allowed there)
|
||||||
import { DevToolbarLoader } from '@/components/DevToolbarLoader';
|
import { DevToolbarLoader } from '@/components/DevToolbarLoader';
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
@@ -66,75 +67,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body>
|
<body>
|
||||||
<div
|
<SetupScreen />
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
minHeight: '100dvh',
|
|
||||||
padding: '2rem',
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
color: '#111111',
|
|
||||||
fontFamily: 'system-ui, sans-serif',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ maxWidth: '600px', width: '100%' }}>
|
|
||||||
<h1
|
|
||||||
style={{
|
|
||||||
fontSize: 'clamp(2rem, 5vw, 3rem)',
|
|
||||||
fontWeight: 500,
|
|
||||||
marginBottom: '0.5rem',
|
|
||||||
letterSpacing: '-0.02em',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Setup Required
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: '1.125rem',
|
|
||||||
opacity: 0.7,
|
|
||||||
marginBottom: '2rem',
|
|
||||||
lineHeight: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Immich Folio is running, but it looks like your configuration files or environment
|
|
||||||
variables are missing.
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.03)',
|
|
||||||
padding: '1.5rem',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontSize: '0.95rem',
|
|
||||||
lineHeight: 1.6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p style={{ marginBottom: '1rem' }}>
|
|
||||||
To get started, follow these steps in your repository or mounted{' '}
|
|
||||||
<code>content/</code> volume:
|
|
||||||
</p>
|
|
||||||
<ol style={{ margin: 0, paddingLeft: '1.25rem' }}>
|
|
||||||
<li style={{ marginBottom: '0.5rem' }}>
|
|
||||||
Copy <code>.env.example</code> to <code>.env.local</code> and fill in your
|
|
||||||
Immich API URL and Key
|
|
||||||
</li>
|
|
||||||
<li style={{ marginBottom: '0.5rem' }}>
|
|
||||||
Copy <code>settings.yaml.example</code> to <code>settings.yaml</code>
|
|
||||||
</li>
|
|
||||||
<li style={{ marginBottom: '0.5rem' }}>
|
|
||||||
Copy <code>gallery.yaml.example</code> to <code>gallery.yaml</code>
|
|
||||||
</li>
|
|
||||||
<li style={{ marginBottom: '0.5rem' }}>
|
|
||||||
Copy <code>about.md.example</code> to <code>about.md</code> (optional)
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
<p style={{ marginTop: '1.5rem', marginBottom: 0 }}>
|
|
||||||
Open <code>gallery.yaml</code> and add the album IDs you want to display, then{' '}
|
|
||||||
<strong>restart your server</strong>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
@@ -142,7 +75,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="de"
|
lang={config.lang || 'en'}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
style={themeVars as React.CSSProperties}
|
style={themeVars as React.CSSProperties}
|
||||||
data-preset={theme.preset}
|
data-preset={theme.preset}
|
||||||
|
|||||||
28
app/page.tsx
28
app/page.tsx
@@ -192,7 +192,12 @@ export default async function HomePage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="hero__mosaic-overlay">
|
<div className="hero__mosaic-overlay">
|
||||||
<HeroTextContent title={config.siteTitle} subtitle={config.siteSubtitle} subpages={subpages} albums={albums} />
|
<HeroTextContent
|
||||||
|
title={config.siteTitle}
|
||||||
|
subtitle={config.siteSubtitle}
|
||||||
|
subpages={subpages}
|
||||||
|
albums={albums}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -203,7 +208,12 @@ export default async function HomePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="hero hero--minimal">
|
<div className="hero hero--minimal">
|
||||||
<div className="hero__content">
|
<div className="hero__content">
|
||||||
<HeroTextContent title={config.siteTitle} subtitle={config.siteSubtitle} subpages={subpages} albums={albums} />
|
<HeroTextContent
|
||||||
|
title={config.siteTitle}
|
||||||
|
subtitle={config.siteSubtitle}
|
||||||
|
subpages={subpages}
|
||||||
|
albums={albums}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -215,7 +225,12 @@ export default async function HomePage() {
|
|||||||
<div className="hero hero--fullbleed">
|
<div className="hero hero--fullbleed">
|
||||||
<HeroCarousel images={heroData} />
|
<HeroCarousel images={heroData} />
|
||||||
<div className="hero__fullbleed-overlay">
|
<div className="hero__fullbleed-overlay">
|
||||||
<HeroTextContent title={config.siteTitle} subtitle={config.siteSubtitle} subpages={subpages} albums={albums} />
|
<HeroTextContent
|
||||||
|
title={config.siteTitle}
|
||||||
|
subtitle={config.siteSubtitle}
|
||||||
|
subpages={subpages}
|
||||||
|
albums={albums}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -227,7 +242,12 @@ export default async function HomePage() {
|
|||||||
{/* ── Left Panel ──────────────────────────────── */}
|
{/* ── Left Panel ──────────────────────────────── */}
|
||||||
<div className="hero__left">
|
<div className="hero__left">
|
||||||
<div className="hero__content">
|
<div className="hero__content">
|
||||||
<HeroTextContent title={config.siteTitle} subtitle={config.siteSubtitle} subpages={subpages} albums={albums} />
|
<HeroTextContent
|
||||||
|
title={config.siteTitle}
|
||||||
|
subtitle={config.siteSubtitle}
|
||||||
|
subpages={subpages}
|
||||||
|
albums={albums}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
// ssr: false ist nur in Client Components erlaubt
|
// ssr: false is only allowed in Client Components
|
||||||
const DevToolbar = dynamic(
|
const DevToolbar = dynamic(
|
||||||
() => import('@/components/DevToolbar').then(m => ({ default: m.DevToolbar })),
|
() => import('@/components/DevToolbar').then((m) => ({ default: m.DevToolbar })),
|
||||||
{ ssr: false }
|
{ ssr: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
export function DevToolbarLoader() {
|
export function DevToolbarLoader() {
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ export function MapView() {
|
|||||||
const res = await fetch('/api/map');
|
const res = await fetch('/api/map');
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let detail = '';
|
let detail = '';
|
||||||
try { detail = await res.text(); } catch { /* ignore */ }
|
try {
|
||||||
|
detail = await res.text();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
console.error('[Map] API error', res.status, detail);
|
console.error('[Map] API error', res.status, detail);
|
||||||
setError(`Failed to load map data (${res.status})`);
|
setError(`Failed to load map data (${res.status})`);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -91,7 +95,10 @@ export function MapView() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const albumLinks = loc.albums
|
const albumLinks = loc.albums
|
||||||
.map((a) => `<a href="${escapeHtml(a.url)}" class="map-popup__album-link">${escapeHtml(a.name)} →</a>`)
|
.map(
|
||||||
|
(a) =>
|
||||||
|
`<a href="${escapeHtml(a.url)}" class="map-popup__album-link">${escapeHtml(a.name)} →</a>`,
|
||||||
|
)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
const popupHtml = `
|
const popupHtml = `
|
||||||
|
|||||||
75
components/SetupScreen.tsx
Normal file
75
components/SetupScreen.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export function SetupScreen() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: '100dvh',
|
||||||
|
padding: '2rem',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
color: '#111111',
|
||||||
|
fontFamily: 'system-ui, sans-serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: '600px', width: '100%' }}>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: 'clamp(2rem, 5vw, 3rem)',
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Setup Required
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: '1.125rem',
|
||||||
|
opacity: 0.7,
|
||||||
|
marginBottom: '2rem',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Immich Folio is running, but it looks like your configuration files or environment
|
||||||
|
variables are missing.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.03)',
|
||||||
|
padding: '1.5rem',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ marginBottom: '1rem' }}>
|
||||||
|
To get started, follow these steps in your repository or mounted <code>content/</code>{' '}
|
||||||
|
volume:
|
||||||
|
</p>
|
||||||
|
<ol style={{ margin: 0, paddingLeft: '1.25rem' }}>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>
|
||||||
|
Copy <code>.env.example</code> to <code>.env.local</code> and fill in your Immich API
|
||||||
|
URL and Key
|
||||||
|
</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>
|
||||||
|
Copy <code>settings.yaml.example</code> to <code>settings.yaml</code>
|
||||||
|
</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>
|
||||||
|
Copy <code>gallery.yaml.example</code> to <code>gallery.yaml</code>
|
||||||
|
</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>
|
||||||
|
Copy <code>about.md.example</code> to <code>about.md</code> (optional)
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<p style={{ marginTop: '1.5rem', marginBottom: 0 }}>
|
||||||
|
Open <code>gallery.yaml</code> and add the album IDs you want to display, then{' '}
|
||||||
|
<strong>restart your server</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -102,16 +102,16 @@ albums:
|
|||||||
subpages:
|
subpages:
|
||||||
- name: Japan
|
- name: Japan
|
||||||
title: Japan
|
title: Japan
|
||||||
subtitle: 'Reisen durch ein Land voller Kontraste.'
|
subtitle: 'Travel through a land full of contrasts.'
|
||||||
sections:
|
sections:
|
||||||
- title: Tokyo
|
- title: Tokyo
|
||||||
description: 'Megacity, Neonlichter, Stille im Lärm.'
|
description: 'Megacity, neon lights, silence in the noise.'
|
||||||
albums:
|
albums:
|
||||||
- '33333333-3333-3333-3333-333333333333'
|
- '33333333-3333-3333-3333-333333333333'
|
||||||
- '44444444-4444-4444-4444-444444444444': Shinjuku bei Nacht
|
- '44444444-4444-4444-4444-444444444444': Shinjuku at night
|
||||||
|
|
||||||
- title: Kyoto
|
- title: Kyoto
|
||||||
description: 'Tempel und Bambuswälder.'
|
description: 'Temples and bamboo forests.'
|
||||||
albums:
|
albums:
|
||||||
- '55555555-5555-5555-5555-555555555555': Fushimi Inari
|
- '55555555-5555-5555-5555-555555555555': Fushimi Inari
|
||||||
- '66666666-6666-6666-6666-666666666666'
|
- '66666666-6666-6666-6666-666666666666'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Portfolio Backlog & Ideas
|
# Portfolio Backlog & Ideas
|
||||||
|
|
||||||
Eine konsolidierte Liste an Features und Ideen für zukünftige Versionen, bereinigt um alles, was bereits implementiert wurde.
|
A consolidated list of features and ideas for future versions, cleaned up from everything that has already been implemented.
|
||||||
|
|
||||||
## 🏆 Quick Wins (High Impact / Low Effort)
|
## 🏆 Quick Wins (High Impact / Low Effort)
|
||||||
|
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ describe('buildSubpageGrid', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles all fields together', () => {
|
it('handles all fields together', () => {
|
||||||
expect(
|
expect(buildSubpageGrid({ columns: 3, gap: 16, aspectRatio: '1', layout: 'masonry' })).toEqual({
|
||||||
buildSubpageGrid({ columns: 3, gap: 16, aspectRatio: '1', layout: 'masonry' }),
|
grid: { columns: 3, gap: 16, aspectRatio: '1', layout: 'masonry' },
|
||||||
).toEqual({ grid: { columns: 3, gap: 16, aspectRatio: '1', layout: 'masonry' } });
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
AppConfig,
|
AppConfig,
|
||||||
SubpageConfig,
|
SubpageConfig,
|
||||||
SubpageSectionConfig,
|
SubpageSectionConfig,
|
||||||
|
SubpageObjectValue,
|
||||||
GalleryYaml,
|
GalleryYaml,
|
||||||
SettingsYaml,
|
SettingsYaml,
|
||||||
GridConfig,
|
GridConfig,
|
||||||
@@ -17,9 +18,12 @@ export * from './theme';
|
|||||||
let _config: AppConfig | null = null;
|
let _config: AppConfig | null = null;
|
||||||
|
|
||||||
/** Converts raw YAML grid overrides into a typed partial GridConfig. */
|
/** Converts raw YAML grid overrides into a typed partial GridConfig. */
|
||||||
export function buildSubpageGrid(
|
export function buildSubpageGrid(raw?: {
|
||||||
raw?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string },
|
columns?: number;
|
||||||
): { grid: Partial<GridConfig> } | Record<string, never> {
|
gap?: number;
|
||||||
|
aspectRatio?: string;
|
||||||
|
layout?: string;
|
||||||
|
}): { grid: Partial<GridConfig> } | Record<string, never> {
|
||||||
if (!raw) return {};
|
if (!raw) return {};
|
||||||
return {
|
return {
|
||||||
grid: {
|
grid: {
|
||||||
@@ -43,13 +47,19 @@ export function getConfig(): AppConfig {
|
|||||||
|
|
||||||
const apiUrl = env.IMMICH_API_URL;
|
const apiUrl = env.IMMICH_API_URL;
|
||||||
const apiKey = env.IMMICH_API_KEY;
|
const apiKey = env.IMMICH_API_KEY;
|
||||||
const authSecret = env.AUTH_SECRET || apiKey;
|
let { AUTH_SECRET } = env;
|
||||||
|
if (!AUTH_SECRET) {
|
||||||
if (!env.AUTH_SECRET && process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
throw new Error(
|
||||||
|
'SECURITY ERROR: AUTH_SECRET is not set in production. Please set a long random string as AUTH_SECRET in your .env.',
|
||||||
|
);
|
||||||
|
}
|
||||||
console.warn(
|
console.warn(
|
||||||
'\n⚠️ SECURITY WARNING: AUTH_SECRET is not set. Falling back to IMMICH_API_KEY.\n Please set a long random string as AUTH_SECRET in your .env for better security.\n',
|
'\n⚠️ SECURITY WARNING: AUTH_SECRET is not set. Falling back to IMMICH_API_KEY.\n Please set a long random string as AUTH_SECRET in your .env for better security.\n',
|
||||||
);
|
);
|
||||||
|
AUTH_SECRET = apiKey;
|
||||||
}
|
}
|
||||||
|
const authSecret = AUTH_SECRET;
|
||||||
|
|
||||||
const gallery = loadYaml<GalleryYaml>('gallery.yaml');
|
const gallery = loadYaml<GalleryYaml>('gallery.yaml');
|
||||||
const settings = loadYaml<SettingsYaml>('settings.yaml') || {};
|
const settings = loadYaml<SettingsYaml>('settings.yaml') || {};
|
||||||
@@ -64,6 +74,7 @@ export function getConfig(): AppConfig {
|
|||||||
subpages: [],
|
subpages: [],
|
||||||
siteTitle: env.SITE_TITLE || 'Immich Folio',
|
siteTitle: env.SITE_TITLE || 'Immich Folio',
|
||||||
siteSubtitle: env.SITE_SUBTITLE || 'Setup Required',
|
siteSubtitle: env.SITE_SUBTITLE || 'Setup Required',
|
||||||
|
lang: 'en',
|
||||||
seo: {
|
seo: {
|
||||||
title: 'Setup Required',
|
title: 'Setup Required',
|
||||||
description: 'Please configure Immich Folio',
|
description: 'Please configure Immich Folio',
|
||||||
@@ -169,13 +180,6 @@ export function getConfig(): AppConfig {
|
|||||||
albumIds: value.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
|
albumIds: value.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
interface SubpageObjectValue {
|
|
||||||
title?: string;
|
|
||||||
subtitle?: string;
|
|
||||||
albums?: Array<string | Record<string, string>>;
|
|
||||||
password?: string;
|
|
||||||
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
|
|
||||||
}
|
|
||||||
const sp = value as SubpageObjectValue;
|
const sp = value as SubpageObjectValue;
|
||||||
const albumEntries = sp.albums || [];
|
const albumEntries = sp.albums || [];
|
||||||
return {
|
return {
|
||||||
@@ -201,6 +205,7 @@ export function getConfig(): AppConfig {
|
|||||||
subpages,
|
subpages,
|
||||||
siteTitle: settings.title ?? env.SITE_TITLE,
|
siteTitle: settings.title ?? env.SITE_TITLE,
|
||||||
siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE,
|
siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE,
|
||||||
|
lang: settings.lang ?? 'en',
|
||||||
seo: {
|
seo: {
|
||||||
title: settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery',
|
title: settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -16,6 +16,19 @@ export interface SubpageConfig {
|
|||||||
grid?: Partial<GridConfig>;
|
grid?: Partial<GridConfig>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubpageObjectValue {
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
password?: string;
|
||||||
|
grid?: Partial<GridConfig>;
|
||||||
|
albums?: Array<string | Record<string, string>>;
|
||||||
|
sections?: Array<{
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
albums: Array<string | Record<string, string>>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FooterConfig {
|
export interface FooterConfig {
|
||||||
name?: string;
|
name?: string;
|
||||||
instagram?: string;
|
instagram?: string;
|
||||||
@@ -62,6 +75,7 @@ export interface AppConfig {
|
|||||||
subpages: SubpageConfig[];
|
subpages: SubpageConfig[];
|
||||||
siteTitle: string;
|
siteTitle: string;
|
||||||
siteSubtitle: string;
|
siteSubtitle: string;
|
||||||
|
lang: string;
|
||||||
seo: {
|
seo: {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -110,6 +124,7 @@ export interface GalleryYaml {
|
|||||||
export interface SettingsYaml {
|
export interface SettingsYaml {
|
||||||
title?: string;
|
title?: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
|
lang?: string;
|
||||||
seo?: {
|
seo?: {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|||||||
@@ -66,13 +66,13 @@ export interface SubpageSummary {
|
|||||||
coverAssetId: string | null;
|
coverAssetId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Module-level flag so the album summary is only logged once even if the
|
// Note: Album summary logging state is kept in the client instance.
|
||||||
// ImmichClient singleton is replaced in hot-reload scenarios.
|
|
||||||
let _hasLoggedAlbums = false;
|
|
||||||
|
|
||||||
// ── API Client ─────────────────────────────────────────────────
|
// ── API Client ─────────────────────────────────────────────────
|
||||||
|
|
||||||
class ImmichClient {
|
class ImmichClient {
|
||||||
|
private hasLoggedAlbums = false;
|
||||||
|
|
||||||
private get config() {
|
private get config() {
|
||||||
return getConfig();
|
return getConfig();
|
||||||
}
|
}
|
||||||
@@ -168,8 +168,8 @@ class ImmichClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Log album summary on first load so admins can see what's published
|
// Log album summary on first load so admins can see what's published
|
||||||
if (!_hasLoggedAlbums) {
|
if (!this.hasLoggedAlbums) {
|
||||||
_hasLoggedAlbums = true;
|
this.hasLoggedAlbums = true;
|
||||||
console.log('\n[Lightbox] Published albums:');
|
console.log('\n[Lightbox] Published albums:');
|
||||||
console.log('─'.repeat(80));
|
console.log('─'.repeat(80));
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ function getKey(): Buffer {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Encode an asset ID into an opaque URL-safe token.
|
* Encode an asset ID into an opaque URL-safe token.
|
||||||
|
* Note: The AES-CBC deterministic IV causes the identical input asset ID
|
||||||
|
* to encrypt to the exact same cipher token. This provides URL obfuscation
|
||||||
|
* and caching consistency, but it does NOT provide k-anonymous cryptographic
|
||||||
|
* security guarantees against recognizing identical items if intercepted.
|
||||||
*/
|
*/
|
||||||
export function encodeAssetId(assetId: string): string {
|
export function encodeAssetId(assetId: string): string {
|
||||||
const key = getKey();
|
const key = getKey();
|
||||||
|
|||||||
52
middleware.ts
Normal file
52
middleware.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
|
||||||
|
|
||||||
|
// Define CSP directives
|
||||||
|
const cspDirectives = [
|
||||||
|
"default-src 'self'",
|
||||||
|
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' 'unsafe-inline'`, // 'unsafe-inline' is ignored by modern browsers if nonce/strict-dynamic is present, but kept for fallback
|
||||||
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://unpkg.com",
|
||||||
|
"font-src 'self' https://fonts.gstatic.com",
|
||||||
|
"img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://*.tile.openstreetmap.org https://unpkg.com",
|
||||||
|
"connect-src 'self' https://fonts.googleapis.com https://fonts.gstatic.com",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
].join('; ');
|
||||||
|
|
||||||
|
const requestHeaders = new Headers(request.headers);
|
||||||
|
requestHeaders.set('x-nonce', nonce);
|
||||||
|
requestHeaders.set('Content-Security-Policy', cspDirectives);
|
||||||
|
|
||||||
|
const response = NextResponse.next({
|
||||||
|
request: {
|
||||||
|
headers: requestHeaders,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
response.headers.set('Content-Security-Policy', cspDirectives);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
/*
|
||||||
|
* Match all request paths except for the ones starting with:
|
||||||
|
* - api (API routes)
|
||||||
|
* - _next/static (static files)
|
||||||
|
* - _next/image (image optimization files)
|
||||||
|
* - favicon.ico, sitemap.xml, robots.txt (metadata files)
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
source: '/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
|
||||||
|
missing: [
|
||||||
|
{ type: 'header', key: 'next-router-prefetch' },
|
||||||
|
{ type: 'header', key: 'purpose', value: 'prefetch' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,18 +1,6 @@
|
|||||||
import type { NextConfig } from 'next';
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
// ── Content Security Policy ────────────────────────────────────
|
// Security headers configuration
|
||||||
// Locks down the public-facing site to only trusted sources.
|
|
||||||
const cspDirectives = [
|
|
||||||
"default-src 'self'",
|
|
||||||
"script-src 'self' 'unsafe-inline'",
|
|
||||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://unpkg.com",
|
|
||||||
"font-src 'self' https://fonts.gstatic.com",
|
|
||||||
"img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://*.tile.openstreetmap.org https://unpkg.com", // Added blob: and osm
|
|
||||||
"connect-src 'self' https://fonts.googleapis.com https://fonts.gstatic.com", // Added fonts for connectivity checks
|
|
||||||
"frame-ancestors 'none'",
|
|
||||||
"base-uri 'self'",
|
|
||||||
"form-action 'self'",
|
|
||||||
].join('; ');
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
@@ -25,7 +13,6 @@ const nextConfig: NextConfig = {
|
|||||||
{
|
{
|
||||||
source: '/(.*)',
|
source: '/(.*)',
|
||||||
headers: [
|
headers: [
|
||||||
{ key: 'Content-Security-Policy', value: cspDirectives },
|
|
||||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||||
{ key: 'X-Frame-Options', value: 'DENY' },
|
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/leaflet": "^1.9.21",
|
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
@@ -26,6 +25,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
Reference in New Issue
Block a user