Initialize Nele & Till Wedding Gallery

This commit is contained in:
Jannik committed 2026-09-20 17:27:14 +02:00
commit a1c808cc6b
59 files changed
+13518

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
node_modules
.next
.git
.gitignore
.dockerignore
.env
.env.*
!.env.example
Dockerfile
docker-compose*.yml
README.md
coverage
test-results
playwright-report
tests
*.md
BIN
View File
Binary file not shown.
+37
View File
@@ -0,0 +1,37 @@
# ============================================
# Nele & Till Wedding Gallery - Configuration
# ============================================
# Database
DATABASE_URL=postgresql://wedding:your_db_password_here@postgres:5432/wedding
POSTGRES_USER=wedding
POSTGRES_PASSWORD=your_db_password_here
POSTGRES_DB=wedding
# Redis
REDIS_URL=redis://redis:6379
# Admin Authentication
ADMIN_PASSWORD=your_secure_admin_password_here
# Application Secret (used for session signing)
APP_SECRET=your_random_secret_at_least_32_chars_here
# Media Configuration
MEDIA_ROOT=/media
CACHE_DIR=/cache
ZIP_OUTPUT_PATH=/cache/download/hochzeit.zip
# Public URL (without trailing slash)
PUBLIC_BASE_URL=https://hochzeit.orfel.de
# Application
NODE_ENV=production
PORT=3000
# Scanner
SCAN_INTERVAL_SECONDS=60
FILE_STABILITY_DELAY_MS=3000
# ZIP Generation
ZIP_DEBOUNCE_MINUTES=5
+48
View File
@@ -0,0 +1,48 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Build
.next/
out/
build/
dist/
# Environment (NEVER commit .env directly - use .env.age)
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# AGE private key
age-key.txt
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS
.DS_Store
Thumbs.db
desktop.ini
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Testing
coverage/
test-results/
playwright-report/
# Cache & generated
.cache/
tmp/
*.tmp
*.log
+72
View File
@@ -0,0 +1,72 @@
# ============================================
# Nele & Till Wedding Gallery
# Multi-stage Docker build
# ============================================
# --- Stage 1: Dependencies ---
FROM node:20.18-alpine AS deps
RUN apk add --no-cache libc6-compat python3 make g++
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# --- Stage 2: Build ---
FROM node:20.18-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Generate Prisma client
RUN npx prisma generate
# Build Next.js
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# --- Stage 3: Production ---
FROM node:20.18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Install runtime dependencies (ffmpeg for video, vips for sharp)
RUN apk add --no-cache \
ffmpeg \
vips-dev \
wget \
&& addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# Copy built application (standalone output)
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
# Copy additional node_modules needed at runtime (not bundled by standalone)
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder /app/node_modules/bullmq ./node_modules/bullmq
COPY --from=builder /app/node_modules/ioredis ./node_modules/ioredis
COPY --from=builder /app/node_modules/archiver ./node_modules/archiver
COPY --from=builder /app/node_modules/bcrypt ./node_modules/bcrypt
COPY --from=builder /app/node_modules/sharp ./node_modules/sharp
# Create directories for media and cache
RUN mkdir -p /media /cache/thumbnails /cache/previews /cache/download \
&& chown -R nextjs:nodejs /cache
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
CMD ["node", "server.js"]
+75
View File
@@ -0,0 +1,75 @@
services:
app:
image: git.orfel.de/jannik/nele-till-hochzeit:latest
pull_policy: always
container_name: wedding-app
restart: unless-stopped
ports:
- "127.0.0.1:3847:3000"
env_file:
- .env
volumes:
- /mnt/Jannik-Cloud-Volume-01/hochzeit/media:/media:ro
- /mnt/Jannik-Cloud-Volume-01/hochzeit/cache:/cache
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
deploy:
resources:
limits:
cpus: "2.0"
memory: 1024M
networks:
- jannik-cloud-net
- wedding-internal
postgres:
image: postgres:16.4-alpine
container_name: wedding-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- /mnt/Jannik-Cloud-Volume-01/hochzeit/pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
networks:
- wedding-internal
redis:
image: redis:7.4-alpine
container_name: wedding-redis
restart: unless-stopped
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
volumes:
- /mnt/Jannik-Cloud-Volume-01/hochzeit/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
cpus: "0.5"
memory: 192M
networks:
- wedding-internal
networks:
jannik-cloud-net:
external: true
wedding-internal:
driver: bridge
+73
View File
@@ -0,0 +1,73 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: wedding-app
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
env_file:
- .env
volumes:
- ./test-media:/media:ro
- wedding-cache:/cache
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- wedding-net
postgres:
image: postgres:16.4-alpine
container_name: wedding-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- wedding-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
networks:
- wedding-net
redis:
image: redis:7.4-alpine
container_name: wedding-redis
restart: unless-stopped
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
volumes:
- wedding-redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
cpus: "0.5"
memory: 192M
networks:
- wedding-net
volumes:
wedding-pgdata:
wedding-redis:
wedding-cache:
networks:
wedding-net:
driver: bridge
+15
View File
@@ -0,0 +1,15 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
experimental: {
instrumentationHook: true,
serverComponentsExternalPackages: ['sharp', 'bullmq', 'archiver', 'bcrypt', 'ioredis'],
},
images: {
unoptimized: true, // We handle our own image optimization
},
poweredByHeader: false,
reactStrictMode: true,
};
module.exports = nextConfig;
+8802
View File
File diff suppressed because it is too large. Load diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "nele-till-hochzeit",
"version": "1.0.0",
"private": true,
"description": "Wedding media gallery for Nele & Till",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"db:migrate": "npx prisma migrate dev",
"db:push": "npx prisma db push",
"db:generate": "npx prisma generate",
"db:studio": "npx prisma studio",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"next": "14.2.15",
"react": "18.3.1",
"react-dom": "18.3.1",
"@prisma/client": "5.20.0",
"bullmq": "5.13.0",
"ioredis": "5.4.1",
"sharp": "0.33.5",
"archiver": "7.0.1",
"bcrypt": "5.1.1"
},
"devDependencies": {
"typescript": "5.5.4",
"@types/node": "20.16.11",
"@types/react": "18.3.11",
"@types/react-dom": "18.3.1",
"@types/archiver": "6.0.3",
"@types/bcrypt": "5.0.2",
"prisma": "5.20.0",
"vitest": "2.1.2",
"eslint": "8.57.1",
"eslint-config-next": "14.2.15",
"@playwright/test": "1.48.0"
}
}
+117
View File
@@ -0,0 +1,117 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum MediaType {
IMAGE
VIDEO
AUDIO
OTHER
}
enum PlacementMode {
RANKING // Position determined by likes ranking
FIXED_POSITION // Position manually set by admin
FIXED_SIZE_RANKING // Position from ranking, but size is fixed
FIXED_POSITION_ADJUSTABLE // Position and size set by admin
}
model MediaItem {
id Int @id @default(autoincrement())
relativePath String @unique // Path relative to MEDIA_ROOT, e.g. "Fotobox/001.jpg"
filename String // Just the filename, e.g. "001.jpg"
directory String // Parent directory, e.g. "Fotobox"
mediaType MediaType @default(IMAGE)
mimeType String @default("application/octet-stream")
fileSize BigInt @default(0)
width Int? // Pixels, for images and videos
height Int? // Pixels, for images and videos
duration Float? // Seconds, for video and audio
// Publication
published Boolean @default(false)
// Layout / Placement
featured Boolean @default(false) // Prominent/title image
placementMode PlacementMode @default(RANKING)
gridColumn Int? // For fixed position (grid column start)
gridRow Int? // For fixed position (grid row start)
gridWidth Int @default(1) // Width in grid units
gridHeight Int @default(1) // Height in grid units
sortOrder Int @default(0) // Manual sort order / ranking tiebreak
// Processing status
thumbnailReady Boolean @default(false)
previewReady Boolean @default(false)
// File tracking
fileHash String? // For detecting file changes
lastModified DateTime? // File's mtime from filesystem
lastScanned DateTime @default(now())
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
likes Like[]
@@index([published])
@@index([directory])
@@index([mediaType])
@@index([published, sortOrder])
@@index([published, placementMode])
}
model Like {
id Int @id @default(autoincrement())
mediaItemId Int
ipHash String // SHA-256 hash of IP address
fingerprint String? // Optional browser fingerprint hash
createdAt DateTime @default(now())
mediaItem MediaItem @relation(fields: [mediaItemId], references: [id], onDelete: Cascade)
@@unique([mediaItemId, ipHash]) // One like per IP per media item
@@index([ipHash])
@@index([mediaItemId])
@@index([createdAt])
}
model AppSettings {
id Int @id @default(1)
votingOpen Boolean @default(false)
zipVersion Int @default(0)
zipReady Boolean @default(false)
zipBuildAt DateTime?
zipFilePath String? // Path to current valid ZIP
lastScanAt DateTime?
updatedAt DateTime @updatedAt
}
model AdminSession {
id String @id @default(uuid())
ipAddress String
userAgent String @default("")
createdAt DateTime @default(now())
expiresAt DateTime
@@index([expiresAt])
}
model RateLimitEntry {
id Int @id @default(autoincrement())
ipHash String
action String // e.g. "login", "like"
windowStart DateTime
count Int @default(1)
@@unique([ipHash, action, windowStart])
@@index([ipHash, action, windowStart])
@@index([windowStart]) // For cleanup of old entries
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="15" fill="#7C9A6E"/>
<text x="16" y="21" text-anchor="middle" font-family="Georgia,serif" font-size="14" fill="white" font-weight="bold">N&amp;T</text>
</svg>

After

Width:  |  Height:  |  Size: 251 B

+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env pwsh
# Decrypts .env.age to .env using the local age private key
$ErrorActionPreference = 'Stop'
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ProjectRoot = Split-Path -Parent $ScriptDir
$EnvFile = Join-Path $ProjectRoot '.env'
$EnvAgeFile = Join-Path $ProjectRoot '.env.age'
# Try common key locations
$KeyLocations = @(
(Join-Path $env:USERPROFILE '.age' 'keys.txt'),
(Join-Path $ProjectRoot 'keys' 'age-key.txt'),
'/opt/Jannik-Cloud/keys/age-key.txt'
)
$KeyFile = $null
foreach ($loc in $KeyLocations) {
if (Test-Path $loc) {
$KeyFile = $loc
break
}
}
if (-not $KeyFile) {
Write-Error "No age private key found. Searched: $($KeyLocations -join ', ')"
exit 1
}
if (-not (Test-Path $EnvAgeFile)) {
Write-Error ".env.age file not found at $EnvAgeFile"
exit 1
}
Write-Host "Decrypting .env.age -> .env using key $KeyFile ..." -ForegroundColor Green
age --decrypt -i $KeyFile -o $EnvFile $EnvAgeFile
if ($LASTEXITCODE -eq 0) {
Write-Host "Done. .env restored successfully." -ForegroundColor Green
} else {
Write-Error "Decryption failed!"
exit 1
}
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Decrypts .env.age to .env using the local age private key
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
ENV_FILE="${PROJECT_ROOT}/.env"
ENV_AGE_FILE="${PROJECT_ROOT}/.env.age"
# Try common key locations
KEY_FILE=""
for loc in "${HOME}/.age/keys.txt" "${PROJECT_ROOT}/keys/age-key.txt" "/opt/Jannik-Cloud/keys/age-key.txt"; do
if [[ -f "$loc" ]]; then
KEY_FILE="$loc"
break
fi
done
if [[ -z "$KEY_FILE" ]]; then
echo "ERROR: No age private key found." >&2
exit 1
fi
if [[ ! -f "$ENV_AGE_FILE" ]]; then
echo "ERROR: .env.age not found at $ENV_AGE_FILE" >&2
exit 1
fi
echo "Decrypting .env.age -> .env using key $KEY_FILE ..."
age --decrypt -i "$KEY_FILE" -o "$ENV_FILE" "$ENV_AGE_FILE"
chmod 600 "$ENV_FILE"
echo "Done. .env restored successfully."
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env pwsh
# Encrypts .env to .env.age using the Jannik-Cloud age public key
$ErrorActionPreference = 'Stop'
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ProjectRoot = Split-Path -Parent $ScriptDir
$EnvFile = Join-Path $ProjectRoot '.env'
$EnvAgeFile = Join-Path $ProjectRoot '.env.age'
$PublicKey = 'age1sa3q5qxl5dylld7v839m9lkv5h7l457wq4h40pt7kfmcfhcqkfjqspdjpn'
if (-not (Test-Path $EnvFile)) {
Write-Error ".env file not found at $EnvFile"
exit 1
}
Write-Host "Encrypting .env -> .env.age ..." -ForegroundColor Green
age --encrypt -r $PublicKey -o $EnvAgeFile $EnvFile
if ($LASTEXITCODE -eq 0) {
Write-Host "Done. .env.age created successfully." -ForegroundColor Green
Write-Host "You can now safely commit .env.age to git." -ForegroundColor Yellow
} else {
Write-Error "Encryption failed!"
exit 1
}
+123
View File
@@ -0,0 +1,123 @@
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/db';
import { validateMediaPath } from '@/lib/security/path';
import Header from '@/components/Header';
import MediaWall from '@/components/MediaWall';
import type { Metadata } from 'next';
const MEDIA_ROOT = process.env.MEDIA_ROOT || '/media';
const PUBLIC_BASE_URL = process.env.PUBLIC_BASE_URL || 'https://hochzeit.orfel.de';
async function getInitialMedia() {
try {
const items = await prisma.mediaItem.findMany({
where: { published: true },
take: 51,
orderBy: [
{ featured: 'desc' },
{ sortOrder: 'asc' },
{ createdAt: 'desc' },
],
include: { _count: { select: { likes: true } } },
});
let nextCursor: number | null = null;
if (items.length > 50) {
const extra = items.pop();
nextCursor = extra!.id;
}
const settings = await prisma.appSettings.findUnique({ where: { id: 1 } });
return {
items: JSON.parse(JSON.stringify(items, (_key, value) =>
typeof value === 'bigint' ? Number(value) : value
)),
nextCursor,
votingOpen: settings?.votingOpen ?? false,
};
} catch (error) {
console.error('[Page] Error fetching initial media:', error);
return { items: [], nextCursor: null, votingOpen: false };
}
}
export async function generateMetadata({
params,
}: {
params: { mediaPath: string[] };
}): Promise<Metadata> {
const mediaPath = params.mediaPath.join('/');
// Validate path
const validated = validateMediaPath(mediaPath, MEDIA_ROOT);
if (!validated) {
return { title: 'Nele & Till - Hochzeit' };
}
const item = await prisma.mediaItem.findFirst({
where: { relativePath: mediaPath },
});
// Don't leak metadata for unpublished items
if (!item?.published) {
return { title: 'Nele & Till - Hochzeit' };
}
return {
title: `${item.filename} - Nele & Till`,
openGraph: {
title: 'Nele & Till - Hochzeit',
images: [`${PUBLIC_BASE_URL}/api/media/${mediaPath}?type=thumb`],
url: `${PUBLIC_BASE_URL}/${mediaPath}`,
type: 'website',
},
};
}
export default async function MediaDirectRoute({
params,
}: {
params: { mediaPath: string[] };
}) {
const mediaPath = params.mediaPath.join('/');
// Security: validate path
const validated = validateMediaPath(mediaPath, MEDIA_ROOT);
if (!validated) {
notFound();
}
// Check publication status
const item = await prisma.mediaItem.findFirst({
where: { relativePath: mediaPath },
});
if (!item) {
notFound();
}
// CRITICAL: Unpublished media must return 403, not render a page
if (!item.published) {
const { NextResponse } = await import('next/server');
// In App Router, we need to use Response for non-page responses
return new Response('Forbidden', { status: 403 });
}
const data = await getInitialMedia();
const itemIndex = data.items.findIndex(
(i: { relativePath: string }) => i.relativePath === mediaPath
);
return (
<main>
<Header />
<MediaWall
initialItems={data.items}
initialCursor={data.nextCursor}
initialViewerIndex={itemIndex !== -1 ? itemIndex : 0}
initialVotingOpen={data.votingOpen}
/>
</main>
);
}
+11
View File
@@ -0,0 +1,11 @@
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="admin-layout">
{children}
</div>
);
}
+216
View File
@@ -0,0 +1,216 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
type MediaItem = {
id: number;
relativePath: string;
filename: string;
published: boolean;
featured: boolean;
mediaType: string;
width: number | null;
height: number | null;
gridWidth: number;
gridHeight: number;
placementMode: string;
sortOrder: number;
};
export default function AdminLayoutEditor() {
const [authChecked, setAuthChecked] = useState(false);
const [items, setItems] = useState<MediaItem[]>([]);
const [selectedItem, setSelectedItem] = useState<MediaItem | null>(null);
const [loading, setLoading] = useState(true);
const router = useRouter();
useEffect(() => {
checkAuth();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const checkAuth = async () => {
const res = await fetch('/api/auth/check');
if (!res.ok) {
router.push('/admin/login');
} else {
setAuthChecked(true);
fetchData();
}
};
const fetchData = async () => {
setLoading(true);
try {
const res = await fetch('/api/media?admin=true&limit=1000');
if (res.ok) {
const data = await res.json();
setItems(data.items);
}
} catch (err) {
console.error('Error fetching admin data:', err);
} finally {
setLoading(false);
}
};
const handleSaveLayout = async () => {
if (!selectedItem) return;
try {
const res = await fetch(`/api/admin/media/${selectedItem.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
placementMode: selectedItem.placementMode,
gridWidth: selectedItem.gridWidth,
gridHeight: selectedItem.gridHeight,
featured: selectedItem.featured,
sortOrder: selectedItem.sortOrder
})
});
if (res.ok) {
alert('Gespeichert!');
fetchData(); // Refresh to reflect order changes
} else {
alert('Fehler beim Speichern');
}
} catch (err) {
console.error('Error saving layout:', err);
alert('Fehler beim Speichern');
}
};
if (!authChecked) return <div style={{ padding: '20px' }}>Prüfe Berechtigung...</div>;
return (
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
{/* Main Grid View */}
<div style={{ flex: 1, overflowY: 'auto', padding: '20px', background: 'var(--color-bg)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '20px', alignItems: 'center' }}>
<h2 style={{ fontFamily: 'var(--font-display)' }}>Layout Editor</h2>
<div style={{ display: 'flex', gap: '10px' }}>
<Link href="/admin" className="btn-secondary" style={{ textDecoration: 'none' }}>Zurück</Link>
<a href="/" target="_blank" rel="noreferrer" className="btn-primary" style={{ textDecoration: 'none' }}>Vorschau</a>
</div>
</div>
{loading ? (
<div>Lade Medien...</div>
) : (
<div className="media-wall" style={{ padding: 0 }}>
{items.map(item => {
const aspectRatio = item.width && item.height ? `${item.width} / ${item.height}` : '1 / 1';
return (
<div
key={item.id}
className={`media-tile ${!item.published ? 'unpublished' : ''}`}
style={{
gridColumn: `span ${item.gridWidth || 1}`,
gridRow: `span ${item.gridHeight || 1}`,
aspectRatio,
border: selectedItem?.id === item.id ? '3px solid var(--color-primary-dark)' : 'none',
opacity: item.published ? 1 : 0.4,
cursor: 'pointer',
position: 'relative'
}}
onClick={() => setSelectedItem(item)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.mediaType === 'VIDEO' ? `/api/media/${item.relativePath}?type=poster` : `/api/media/${item.relativePath}?type=thumb`}
alt={item.filename}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
{selectedItem?.id === item.id && (
<div style={{ position: 'absolute', top: 5, right: 5, background: 'var(--color-primary-dark)', color: 'white', borderRadius: '50%', width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
✓
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* Sidebar Properties Panel */}
<div style={{ width: '300px', background: 'var(--color-surface)', borderLeft: '1px solid var(--color-border)', padding: '20px', overflowY: 'auto' }}>
<h3 style={{ marginBottom: '20px' }}>Eigenschaften</h3>
{selectedItem ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
<div style={{ fontSize: '0.8rem', color: 'var(--color-text-light)', wordBreak: 'break-all' }}>
{selectedItem.filename}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
<label style={{ fontSize: '0.9rem', fontWeight: 'bold' }}>Platzierungsmodus</label>
<select
style={{ padding: '8px', borderRadius: '4px', border: '1px solid var(--color-border)' }}
value={selectedItem.placementMode}
onChange={(e) => setSelectedItem({...selectedItem, placementMode: e.target.value})}
>
<option value="RANKING">Ranking</option>
<option value="FIXED_POSITION">Feste Position</option>
<option value="FIXED_SIZE_RANKING">Feste Größe</option>
<option value="FIXED_POSITION_ADJUSTABLE">Fest & Anpassbar</option>
</select>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
<label style={{ fontSize: '0.9rem', fontWeight: 'bold' }}>Breite (Spalten)</label>
<input
style={{ padding: '8px', borderRadius: '4px', border: '1px solid var(--color-border)' }}
type="number"
min="1" max="6"
value={selectedItem.gridWidth}
onChange={(e) => setSelectedItem({...selectedItem, gridWidth: parseInt(e.target.value) || 1})}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
<label style={{ fontSize: '0.9rem', fontWeight: 'bold' }}>Höhe (Zeilen)</label>
<input
style={{ padding: '8px', borderRadius: '4px', border: '1px solid var(--color-border)' }}
type="number"
min="1" max="6"
value={selectedItem.gridHeight}
onChange={(e) => setSelectedItem({...selectedItem, gridHeight: parseInt(e.target.value) || 1})}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
<label style={{ fontSize: '0.9rem', fontWeight: 'bold' }}>Sortierung</label>
<input
style={{ padding: '8px', borderRadius: '4px', border: '1px solid var(--color-border)' }}
type="number"
value={selectedItem.sortOrder}
onChange={(e) => setSelectedItem({...selectedItem, sortOrder: parseInt(e.target.value) || 0})}
/>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', marginTop: '10px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={selectedItem.featured}
onChange={(e) => setSelectedItem({...selectedItem, featured: e.target.checked})}
style={{ width: '16px', height: '16px' }}
/>
Hervorgehoben
</label>
<button className="btn-primary" onClick={handleSaveLayout} style={{ marginTop: '20px', padding: '10px' }}>
Speichern
</button>
</div>
) : (
<p style={{ color: 'var(--color-text-light)', fontSize: '0.9rem', textAlign: 'center', marginTop: '40px' }}>
Klicke auf ein Bild, um dessen Layout zu bearbeiten.
</p>
)}
</div>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function AdminLoginPage() {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (res.ok) {
router.push('/admin');
} else {
setError('Falsches Passwort');
}
} catch (err) {
setError('Ein Fehler ist aufgetreten');
} finally {
setLoading(false);
}
};
return (
<div className="login-container">
<h1 className="header-title" style={{ marginBottom: '40px' }}>Nele & Till - Admin</h1>
<form className="login-form" onSubmit={handleSubmit}>
{error && <div className="error-message">{error}</div>}
<div className="input-group">
<label htmlFor="password">Passwort</label>
<input
type="password"
id="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
autoFocus
/>
</div>
<button type="submit" className="btn-primary" style={{ justifyContent: 'center' }} disabled={loading}>
{loading ? 'Laden...' : 'Anmelden'}
</button>
</form>
</div>
);
}
+208
View File
@@ -0,0 +1,208 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
type MediaItem = {
id: number;
relativePath: string;
filename: string;
published: boolean;
featured: boolean;
mediaType: string;
};
export default function AdminDashboard() {
const [authChecked, setAuthChecked] = useState(false);
const [items, setItems] = useState<MediaItem[]>([]);
const [stats, setStats] = useState({ total: 0, published: 0, unpublished: 0 });
const [votingOpen, setVotingOpen] = useState(false);
const [loading, setLoading] = useState(true);
const router = useRouter();
useEffect(() => {
checkAuth();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const checkAuth = async () => {
const res = await fetch('/api/auth/check');
if (!res.ok) {
router.push('/admin/login');
} else {
setAuthChecked(true);
fetchData();
}
};
const fetchData = async () => {
setLoading(true);
try {
// Fetch all media (admin=true bypasses publication check)
const res = await fetch('/api/media?admin=true&limit=1000');
if (res.ok) {
const data = await res.json();
setItems(data.items);
setStats({
total: data.items.length,
published: data.items.filter((i: MediaItem) => i.published).length,
unpublished: data.items.filter((i: MediaItem) => !i.published).length,
});
if (data.votingOpen !== undefined) {
setVotingOpen(data.votingOpen);
}
}
} catch (err) {
console.error('Error fetching admin data:', err);
} finally {
setLoading(false);
}
};
const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/admin/login');
};
const handleScan = async () => {
const res = await fetch('/api/admin/scan', { method: 'POST' });
if (res.ok) {
alert('Scan gestartet. Die Liste wird aktualisiert...');
// Wait a bit for scan to do something, then refresh
setTimeout(fetchData, 3000);
}
};
const togglePublished = async (id: number, current: boolean) => {
// Optimistic UI
setItems((prev) =>
prev.map((i) => (i.id === id ? { ...i, published: !current } : i))
);
setStats((prev) => ({
...prev,
published: prev.published + (current ? -1 : 1),
unpublished: prev.unpublished + (current ? 1 : -1),
}));
try {
await fetch(`/api/admin/media/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ published: !current }),
});
} catch {
// Revert on error
fetchData();
}
};
const toggleVoting = async () => {
const newStatus = !votingOpen;
setVotingOpen(newStatus);
try {
await fetch('/api/admin/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ votingOpen: newStatus }),
});
} catch {
// Revert on error
setVotingOpen(!newStatus);
}
};
if (!authChecked) return <div style={{ padding: '20px' }}>Prüfe Berechtigung...</div>;
return (
<div className="admin-content" style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '10px' }}>
<h2 style={{ fontSize: '1.5rem', fontFamily: 'var(--font-display)' }}>Admin Dashboard</h2>
<div style={{ display: 'flex', gap: '10px' }}>
<button className="btn-secondary" onClick={handleScan}>Neu Scannen</button>
<Link href="/admin/layout" className="btn-secondary" style={{ textDecoration: 'none' }}>Layout Editor</Link>
<button className="btn-primary" onClick={handleLogout}>Logout</button>
</div>
</div>
<div style={{ display: 'flex', gap: '20px', marginBottom: '30px', flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 300px', background: 'var(--color-surface)', padding: '20px', borderRadius: 'var(--radius)', border: '1px solid var(--color-border)' }}>
<h3 style={{ marginBottom: '10px', fontSize: '1.1rem' }}>Statistiken</h3>
<p>Gesamt: <strong>{stats.total}</strong></p>
<p>Veröffentlicht: <strong style={{ color: 'var(--color-primary-dark)' }}>{stats.published}</strong></p>
<p>Unveröffentlicht: <strong>{stats.unpublished}</strong></p>
</div>
<div style={{ flex: '1 1 300px', background: 'var(--color-surface)', padding: '20px', borderRadius: 'var(--radius)', border: '1px solid var(--color-border)' }}>
<h3 style={{ marginBottom: '10px', fontSize: '1.1rem' }}>Einstellungen</h3>
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={votingOpen}
onChange={toggleVoting}
style={{ width: '18px', height: '18px' }}
/>
Voting aktiviert (Likes)
</label>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' }}>
<h3 style={{ fontSize: '1.2rem' }}>Alle Medien</h3>
<button className="btn-secondary" onClick={fetchData} disabled={loading}>
{loading ? 'Lädt...' : 'Aktualisieren'}
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: '15px' }}>
{items.map(item => (
<div
key={item.id}
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--radius)',
overflow: 'hidden',
border: '1px solid var(--color-border)',
opacity: item.published ? 1 : 0.6,
transition: 'opacity 0.2s'
}}
>
<div style={{ position: 'relative', aspectRatio: '1/1', background: '#eee' }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`/api/media/${item.relativePath}?type=thumb`}
alt={item.filename}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
loading="lazy"
/>
{item.mediaType === 'VIDEO' && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.3)' }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="white" stroke="none"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
</div>
)}
</div>
<div style={{ padding: '10px' }}>
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-light)', marginBottom: '8px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={item.relativePath}>
{item.filename}
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '0.85rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={item.published}
onChange={() => togglePublished(item.id, item.published)}
/>
Veröffentlicht
</label>
</div>
</div>
))}
{items.length === 0 && !loading && (
<div style={{ gridColumn: '1 / -1', padding: '40px', textAlign: 'center', color: 'var(--color-text-light)' }}>
Keine Medien gefunden. Bitte einen Scan durchführen.
</div>
)}
</div>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { requireAuth } from '@/lib/auth/session';
export async function GET(request: NextRequest) {
const authError = await requireAuth(request);
if (authError) return authError;
try {
const items = await prisma.mediaItem.findMany({
orderBy: [
{ featured: 'desc' },
{ placementMode: 'asc' },
{ sortOrder: 'asc' },
{ createdAt: 'desc' }
]
});
return NextResponse.json({ items });
} catch (error) {
console.error('Error fetching layout items:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
const authError = await requireAuth(request);
if (authError) return authError;
try {
const { items } = await request.json();
if (!Array.isArray(items)) {
return NextResponse.json({ error: 'Expected items array' }, { status: 400 });
}
await prisma.$transaction(
items.map((item: any) =>
prisma.mediaItem.update({
where: { id: item.id },
data: {
...(item.gridColumn !== undefined && { gridColumn: item.gridColumn }),
...(item.gridRow !== undefined && { gridRow: item.gridRow }),
...(item.gridWidth !== undefined && { gridWidth: item.gridWidth }),
...(item.gridHeight !== undefined && { gridHeight: item.gridHeight }),
...(item.placementMode !== undefined && { placementMode: item.placementMode }),
...(item.sortOrder !== undefined && { sortOrder: item.sortOrder }),
...(item.featured !== undefined && { featured: item.featured }),
}
})
)
);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error updating layout:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+59
View File
@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { requireAuth } from '@/lib/auth/session';
import { scheduleZipRebuild } from '@/lib/jobs/queue';
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const authError = await requireAuth(request);
if (authError) return authError;
const id = parseInt(params.id, 10);
if (isNaN(id)) {
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
}
try {
const body = await request.json();
const {
published,
featured,
placementMode,
gridColumn,
gridRow,
gridWidth,
gridHeight,
sortOrder
} = body;
const currentItem = await prisma.mediaItem.findUnique({ where: { id } });
if (!currentItem) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const updated = await prisma.mediaItem.update({
where: { id },
data: {
...(published !== undefined && { published }),
...(featured !== undefined && { featured }),
...(placementMode !== undefined && { placementMode }),
...(gridColumn !== undefined && { gridColumn }),
...(gridRow !== undefined && { gridRow }),
...(gridWidth !== undefined && { gridWidth }),
...(gridHeight !== undefined && { gridHeight }),
...(sortOrder !== undefined && { sortOrder }),
}
});
if (published !== undefined && published !== currentItem.published) {
await scheduleZipRebuild();
}
return NextResponse.json(updated);
} catch (error) {
console.error('Error updating media item:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { requireAuth } from '@/lib/auth/session';
import { scheduleZipRebuild } from '@/lib/jobs/queue';
export async function PATCH(request: NextRequest) {
const authError = await requireAuth(request);
if (authError) return authError;
try {
const { ids, action } = await request.json();
if (!Array.isArray(ids) || !ids.every(id => typeof id === 'number')) {
return NextResponse.json({ error: 'Invalid ids array' }, { status: 400 });
}
if (action !== 'publish' && action !== 'unpublish') {
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
}
const published = action === 'publish';
const result = await prisma.mediaItem.updateMany({
where: {
id: { in: ids }
},
data: { published }
});
if (result.count > 0) {
await scheduleZipRebuild();
}
return NextResponse.json({ updated: result.count });
} catch (error) {
console.error('Error in bulk update:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+16
View File
@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth/session';
import { enqueueScan } from '@/lib/jobs/queue';
export async function POST(request: NextRequest) {
const authError = await requireAuth(request);
if (authError) return authError;
try {
await enqueueScan();
return NextResponse.json({ success: true, message: 'Scan enqueued' });
} catch (error) {
console.error('Error enqueuing scan:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+48
View File
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { requireAuth } from '@/lib/auth/session';
async function getSettings() {
let settings = await prisma.appSettings.findFirst();
if (!settings) {
settings = await prisma.appSettings.create({
data: { votingOpen: true }
});
}
return settings;
}
export async function GET(request: NextRequest) {
const authError = await requireAuth(request);
if (authError) return authError;
try {
const settings = await getSettings();
return NextResponse.json(settings);
} catch (error) {
console.error('Error getting settings:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PATCH(request: NextRequest) {
const authError = await requireAuth(request);
if (authError) return authError;
try {
const { votingOpen } = await request.json();
const settings = await getSettings();
const updated = await prisma.appSettings.update({
where: { id: settings.id },
data: {
...(votingOpen !== undefined && { votingOpen })
}
});
return NextResponse.json(updated);
} catch (error) {
console.error('Error updating settings:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+15
View File
@@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { validateSession } from '@/lib/auth/session';
export async function GET(request: NextRequest) {
try {
const isValid = await validateSession(request);
if (isValid) {
return NextResponse.json({ authenticated: true });
}
return NextResponse.json({ authenticated: false }, { status: 401 });
} catch (error) {
console.error('Auth check error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+79
View File
@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import {
verifyPassword,
SESSION_COOKIE_NAME,
SESSION_MAX_AGE,
getClientIP,
hashIP,
} from '@/lib/security/auth';
import { checkRateLimit, RATE_LIMITS } from '@/lib/security/rateLimit';
import crypto from 'crypto';
export async function POST(request: NextRequest) {
try {
const ip = getClientIP(request.headers);
const ipHash = hashIP(ip);
// Rate limit login attempts
const rateLimitResult = await checkRateLimit(ip, RATE_LIMITS.login);
if (!rateLimitResult.allowed) {
console.warn(`[Auth] Login rate limit exceeded for IP hash: ${ipHash}`);
return NextResponse.json(
{ error: 'Too many login attempts. Try again later.' },
{ status: 429 }
);
}
let body: { password?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
if (!body.password || typeof body.password !== 'string') {
return NextResponse.json({ error: 'Password required' }, { status: 400 });
}
const isValid = await verifyPassword(body.password);
if (!isValid) {
console.warn(`[Auth] Failed login attempt from IP hash: ${ipHash}`);
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
}
// Create session
const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + SESSION_MAX_AGE * 1000);
await prisma.adminSession.create({
data: {
id: sessionId,
ipAddress: ipHash,
userAgent: request.headers.get('user-agent')?.slice(0, 200) || '',
expiresAt,
},
});
console.log(`[Auth] Successful login from IP hash: ${ipHash}`);
// Set session cookie
const isProduction = process.env.NODE_ENV === 'production';
const response = NextResponse.json({ success: true });
response.cookies.set(SESSION_COOKIE_NAME, sessionId, {
path: '/',
httpOnly: true,
secure: isProduction,
sameSite: isProduction ? 'strict' : 'lax',
maxAge: SESSION_MAX_AGE,
});
return response;
} catch (error) {
console.error('[Auth] Login error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { SESSION_COOKIE_NAME } from '@/lib/security/auth';
export async function POST(request: NextRequest) {
try {
const sessionCookie = request.cookies.get(SESSION_COOKIE_NAME);
if (sessionCookie?.value) {
await prisma.adminSession
.delete({ where: { id: sessionCookie.value } })
.catch(() => {}); // Ignore if session already gone
}
const response = NextResponse.json({ success: true });
response.cookies.set(SESSION_COOKIE_NAME, '', {
path: '/',
httpOnly: true,
maxAge: 0,
});
console.log('[Auth] Admin logged out');
return response;
} catch (error) {
console.error('[Auth] Logout error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+41
View File
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { isZipReady, getZipPath } from '@/lib/processing/zip';
import fs from 'fs';
import { Readable } from 'stream';
function streamToResponse(nodeStream: fs.ReadStream, headers: Headers) {
const webStream = new ReadableStream({
start(controller) {
nodeStream.on('data', (chunk) => controller.enqueue(chunk));
nodeStream.on('end', () => controller.close());
nodeStream.on('error', (err) => controller.error(err));
}
});
return new NextResponse(webStream, { headers });
}
export async function GET(request: NextRequest) {
try {
const ready = await isZipReady();
if (!ready) {
return NextResponse.json({ error: 'Download not ready yet' }, { status: 503 });
}
const zipPath = getZipPath();
if (!fs.existsSync(zipPath)) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
const stat = fs.statSync(zipPath);
const headers = new Headers();
headers.set('Content-Type', 'application/zip');
headers.set('Content-Disposition', 'attachment; filename="hochzeit.zip"');
headers.set('Content-Length', stat.size.toString());
const fileStream = fs.createReadStream(zipPath);
return streamToResponse(fileStream, headers);
} catch (error) {
console.error('Error in download route:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+5
View File
@@ -0,0 +1,5 @@
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({ status: 'ok', timestamp: new Date() });
}
+102
View File
@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { getClientIP, hashIP } from '@/lib/security/auth';
import { checkRateLimit, RATE_LIMITS } from '@/lib/security/rateLimit';
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
// Check voting status
const settings = await prisma.appSettings.findUnique({ where: { id: 1 } });
if (!settings?.votingOpen) {
return NextResponse.json({ error: 'Voting is closed' }, { status: 403 });
}
const ip = getClientIP(request.headers);
const ipHash = hashIP(ip);
// Rate limiting
const limit1 = await checkRateLimit(ip, RATE_LIMITS.like);
if (!limit1.allowed) {
return NextResponse.json({ error: 'Too many requests' }, { status: 429 });
}
const limit2 = await checkRateLimit(ip, RATE_LIMITS.likeHourly);
if (!limit2.allowed) {
return NextResponse.json({ error: 'Too many requests' }, { status: 429 });
}
const mediaItemId = parseInt(params.id, 10);
if (isNaN(mediaItemId)) {
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
}
// Verify media exists and is published
const media = await prisma.mediaItem.findUnique({ where: { id: mediaItemId } });
if (!media || !media.published) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
// Upsert like (idempotent)
await prisma.like.upsert({
where: {
mediaItemId_ipHash: {
mediaItemId,
ipHash,
},
},
update: {},
create: {
mediaItemId,
ipHash,
},
});
const count = await prisma.like.count({ where: { mediaItemId } });
return NextResponse.json({ liked: true, count });
} catch (error) {
console.error('[Likes] Error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const settings = await prisma.appSettings.findUnique({ where: { id: 1 } });
if (!settings?.votingOpen) {
return NextResponse.json({ error: 'Voting is closed' }, { status: 403 });
}
const ip = getClientIP(request.headers);
const ipHash = hashIP(ip);
const mediaItemId = parseInt(params.id, 10);
if (isNaN(mediaItemId)) {
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
}
try {
await prisma.like.delete({
where: {
mediaItemId_ipHash: {
mediaItemId,
ipHash,
},
},
});
} catch (e: unknown) {
// P2025 = record not found — that's fine
if (e && typeof e === 'object' && 'code' in e && e.code !== 'P2025') throw e;
}
const count = await prisma.like.count({ where: { mediaItemId } });
return NextResponse.json({ liked: false, count });
} catch (error) {
console.error('[Likes] Error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+112
View File
@@ -0,0 +1,112 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { validateMediaPath } from '@/lib/security/path';
import { getSafeContentType } from '@/lib/media/types';
import { getThumbnailPath, getGalleryPath } from '@/lib/processing/images';
import { getVideoPosterPath, getVideoPreviewPath } from '@/lib/processing/videos';
import path from 'path';
import fs from 'fs';
const MEDIA_ROOT = process.env.MEDIA_ROOT || '/media';
export async function GET(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
try {
const relativePath = params.path.join('/');
const searchParams = request.nextUrl.searchParams;
const type = searchParams.get('type') || 'original';
// Validate path security — prevent traversal, null bytes, symlink escapes
const validatedPath = validateMediaPath(relativePath, MEDIA_ROOT);
if (!validatedPath) {
console.warn(`[Security] Blocked path traversal attempt: ${relativePath}`);
return new NextResponse('Invalid path', { status: 400 });
}
// Check publication status in database
const mediaItem = await prisma.mediaItem.findFirst({
where: { relativePath },
});
if (!mediaItem) {
return new NextResponse('Not found', { status: 404 });
}
// CRITICAL: Unpublished media returns 403 — no metadata leak
if (!mediaItem.published) {
return new NextResponse('Forbidden', { status: 403 });
}
// Determine which file to serve
let filePath: string;
let contentTypeExt: string;
switch (type) {
case 'thumb':
filePath = getThumbnailPath(relativePath);
contentTypeExt = '.webp';
break;
case 'gallery':
filePath = getGalleryPath(relativePath);
contentTypeExt = '.webp';
break;
case 'poster':
filePath = getVideoPosterPath(relativePath);
contentTypeExt = '.webp';
break;
case 'preview':
filePath = getVideoPreviewPath(relativePath);
contentTypeExt = '.mp4';
break;
case 'original':
default:
filePath = validatedPath;
contentTypeExt = path.extname(relativePath);
break;
}
// Check file exists
try {
await fs.promises.access(filePath, fs.constants.R_OK);
} catch {
// Cache asset might not be generated yet — fall back to 404
return new NextResponse('Not found', { status: 404 });
}
// Get file stats for Content-Length
const stat = await fs.promises.stat(filePath);
// Build response headers
const contentType = getSafeContentType(contentTypeExt);
const headers = new Headers();
headers.set('Content-Type', contentType);
headers.set('Content-Length', stat.size.toString());
headers.set('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');
headers.set('Accept-Ranges', 'bytes');
// SVG: force download to prevent XSS
if (contentType === 'image/svg+xml') {
headers.set('Content-Disposition', `attachment; filename="${path.basename(relativePath)}"`);
}
// Stream the file
const fileStream = fs.createReadStream(filePath);
const webStream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));
fileStream.on('end', () => controller.close());
fileStream.on('error', (err) => controller.error(err));
},
cancel() {
fileStream.destroy();
},
});
return new NextResponse(webStream, { headers });
} catch (error) {
console.error('[MediaServe] Error:', error);
return new NextResponse('Internal server error', { status: 500 });
}
}
+71
View File
@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { validateSession } from '@/lib/auth/session';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const cursorParam = searchParams.get('cursor');
const limitParam = searchParams.get('limit');
const adminParam = searchParams.get('admin');
const directoryParam = searchParams.get('directory');
const limit = limitParam ? Math.min(parseInt(limitParam, 10) || 50, 100) : 50;
const isAdminRequest = adminParam === 'true';
if (isAdminRequest) {
const isAuthenticated = await validateSession(request);
if (!isAuthenticated) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
// Build where clause
const where: Record<string, unknown> = {};
if (!isAdminRequest) {
where.published = true;
}
if (directoryParam) {
where.directory = directoryParam;
}
const cursor =
cursorParam && !isNaN(parseInt(cursorParam, 10))
? parseInt(cursorParam, 10)
: undefined;
const items = await prisma.mediaItem.findMany({
where,
take: limit + 1,
...(cursor ? { skip: 1, cursor: { id: cursor } } : {}),
orderBy: [
{ featured: 'desc' },
{ sortOrder: 'asc' },
{ createdAt: 'desc' },
],
include: {
_count: {
select: { likes: true },
},
},
});
let nextCursor: number | null = null;
if (items.length > limit) {
const nextItem = items.pop();
nextCursor = nextItem!.id;
}
// Fetch voting status for the client
const settings = await prisma.appSettings.findUnique({ where: { id: 1 } });
return NextResponse.json({
items,
nextCursor,
votingOpen: settings?.votingOpen ?? false,
});
} catch (error) {
console.error('[Media] Error fetching:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+413
View File
@@ -0,0 +1,413 @@
:root {
--color-primary: #7C9A6E;
--color-primary-light: #A8C49A;
--color-primary-dark: #5A7A4E;
--color-bg: #FAFAF8;
--color-surface: #FFFFFF;
--color-text: #2D2D2D;
--color-text-light: #6B6B6B;
--color-border: #E8E8E4;
--color-overlay: rgba(0, 0, 0, 0.75);
--font-display: 'Georgia', 'Times New Roman', serif;
--font-body: system-ui, -apple-system, 'Segoe UI', sans-serif;
--radius: 4px;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12);
--transition: 0.2s ease;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
font-family: var(--font-body);
background: var(--color-bg);
color: var(--color-text);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 60px;
background: rgba(250, 250, 248, 0.9);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
z-index: 100;
}
.header-title {
font-family: var(--font-display);
font-size: 1.5rem;
color: var(--color-primary-dark);
margin: 0;
font-weight: normal;
}
.media-wall {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
grid-auto-rows: 150px;
grid-auto-flow: dense;
gap: 10px;
padding: 80px 10px 20px;
max-width: 1920px;
margin: 0 auto;
}
@media (min-width: 480px) {
.media-wall {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
grid-auto-rows: 180px;
}
}
@media (min-width: 768px) {
.media-wall {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-auto-rows: 220px;
gap: 15px;
padding: 80px 15px 20px;
}
}
@media (min-width: 1024px) {
.media-wall {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-auto-rows: 250px;
gap: 20px;
padding: 90px 20px 30px;
}
}
@media (min-width: 1440px) {
.media-wall {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
grid-auto-rows: 300px;
}
}
.media-tile {
position: relative;
overflow: hidden;
border-radius: var(--radius);
background: var(--color-border);
cursor: pointer;
transition: transform var(--transition), box-shadow var(--transition);
}
.media-tile:hover {
transform: translateY(-2px);
box-shadow: var(--shadow);
z-index: 1;
}
.media-tile img,
.media-tile video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.media-tile .play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 48px;
height: 48px;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.media-tile .play-icon::after {
content: '';
width: 0;
height: 0;
border-style: solid;
border-width: 10px 0 10px 16px;
border-color: transparent transparent transparent white;
margin-left: 4px;
}
.viewer-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--color-overlay);
backdrop-filter: blur(8px);
z-index: 1000;
display: flex;
flex-direction: column;
}
.viewer-header {
height: 60px;
display: flex;
justify-content: flex-end;
padding: 0 20px;
align-items: center;
}
.viewer-close {
background: none;
border: none;
color: white;
font-size: 2rem;
cursor: pointer;
padding: 10px;
}
.viewer-body {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.viewer-content {
max-width: 90vw;
max-height: 80vh;
object-fit: contain;
}
.viewer-nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.1);
border: none;
color: white;
font-size: 3rem;
padding: 20px;
cursor: pointer;
border-radius: 50%;
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
transition: background var(--transition);
}
.viewer-nav-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.viewer-nav-btn.left {
left: 20px;
}
.viewer-nav-btn.right {
right: 20px;
}
.viewer-footer {
height: 80px;
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
}
.btn-primary, .btn-secondary {
padding: 10px 20px;
border-radius: var(--radius);
font-weight: 500;
cursor: pointer;
border: none;
display: inline-flex;
align-items: center;
gap: 8px;
transition: background var(--transition), transform var(--transition);
font-family: var(--font-body);
}
.btn-primary {
background: var(--color-primary);
color: white;
}
.btn-primary:hover {
background: var(--color-primary-dark);
}
.btn-secondary {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-secondary:hover {
background: var(--color-bg);
}
.like-btn {
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 50%;
width: 50px;
height: 50px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
cursor: pointer;
transition: transform var(--transition);
}
.like-btn:hover {
transform: scale(1.1);
}
.like-btn.liked svg {
fill: #ff4b4b;
color: #ff4b4b;
}
.like-count {
font-size: 0.75rem;
margin-top: 2px;
}
.badge {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.6);
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.75rem;
display: flex;
align-items: center;
gap: 4px;
}
.unpublished {
opacity: 0.5;
}
.admin-layout {
display: flex;
min-height: 100vh;
}
.admin-sidebar {
width: 250px;
background: var(--color-surface);
border-right: 1px solid var(--color-border);
padding: 20px;
}
.admin-content {
flex: 1;
padding: 20px;
}
.login-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
}
.login-form {
background: var(--color-surface);
padding: 40px;
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
width: 100%;
max-width: 400px;
display: flex;
flex-direction: column;
gap: 20px;
}
.input-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.input-group input, .input-group select {
padding: 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-family: var(--font-body);
}
.admin-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
margin-top: 20px;
}
.admin-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
.admin-card-img {
width: 100%;
height: 150px;
object-fit: cover;
border-radius: var(--radius);
}
.loading-skeleton {
background: linear-gradient(90deg, var(--color-border) 25%, #f0f0f0 50%, var(--color-border) 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.error-message {
color: #d32f2f;
background: #ffebee;
padding: 10px;
border-radius: var(--radius);
font-size: 0.875rem;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
+24
View File
@@ -0,0 +1,24 @@
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'Nele & Till - Hochzeit',
description: 'Die Hochzeits-Mediengalerie von Nele und Till.',
openGraph: {
title: 'Nele & Till - Hochzeit',
description: 'Die Hochzeits-Mediengalerie von Nele und Till.',
type: 'website',
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="de">
<body>{children}</body>
</html>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { prisma } from '@/lib/db';
import Header from '@/components/Header';
import MediaWall from '@/components/MediaWall';
async function getInitialMedia() {
try {
const items = await prisma.mediaItem.findMany({
where: { published: true },
take: 51,
orderBy: [
{ featured: 'desc' },
{ sortOrder: 'asc' },
{ createdAt: 'desc' },
],
include: { _count: { select: { likes: true } } },
});
let nextCursor: number | null = null;
if (items.length > 50) {
const extra = items.pop();
nextCursor = extra!.id;
}
const settings = await prisma.appSettings.findUnique({ where: { id: 1 } });
// Serialize BigInt values for client
return {
items: JSON.parse(JSON.stringify(items, (_key, value) =>
typeof value === 'bigint' ? Number(value) : value
)),
nextCursor,
votingOpen: settings?.votingOpen ?? false,
};
} catch (error) {
console.error('[Page] Error fetching initial media:', error);
return { items: [], nextCursor: null, votingOpen: false };
}
}
export default async function HomePage() {
const data = await getInitialMedia();
return (
<main>
<Header />
<MediaWall
initialItems={data.items}
initialCursor={data.nextCursor}
initialVotingOpen={data.votingOpen}
/>
</main>
);
}
+185
View File
@@ -0,0 +1,185 @@
'use client';
import React, { useEffect, useRef, useCallback } from 'react';
import LikeButton from './LikeButton';
import ShareButton from './ShareButton';
type ViewerProps = {
item: {
id: number;
relativePath: string;
filename: string;
mediaType: string;
_count: { likes: number };
};
onClose: () => void;
onNext: () => void;
onPrev: () => void;
hasNext: boolean;
hasPrev: boolean;
votingOpen: boolean;
};
export default function FullscreenViewer({
item,
onClose,
onNext,
onPrev,
hasNext,
hasPrev,
votingOpen,
}: ViewerProps) {
const isVideo = item.mediaType === 'VIDEO';
const touchStartX = useRef<number | null>(null);
// Update URL to reflect current media
useEffect(() => {
window.history.replaceState(
{ viewerOpen: true },
'',
`/${item.relativePath}`
);
}, [item.relativePath]);
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
else if (e.key === 'ArrowRight' && hasNext) onNext();
else if (e.key === 'ArrowLeft' && hasPrev) onPrev();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose, onNext, onPrev, hasNext, hasPrev]);
// Prevent body scroll while viewer is open
useEffect(() => {
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
}, []);
const handlePointerDown = (e: React.PointerEvent) => {
touchStartX.current = e.clientX;
};
const handlePointerUp = (e: React.PointerEvent) => {
if (touchStartX.current === null) return;
const deltaX = e.clientX - touchStartX.current;
if (deltaX > 50 && hasPrev) onPrev();
else if (deltaX < -50 && hasNext) onNext();
touchStartX.current = null;
};
const handleOverlayClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
},
[onClose]
);
return (
<div className="viewer-overlay" onClick={handleOverlayClick}>
{/* Close button */}
<button
className="viewer-close"
onClick={onClose}
aria-label="Schließen"
>
×
</button>
{/* Navigation + Media */}
<div
className="viewer-body"
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
>
{hasPrev && (
<button
className="viewer-nav-btn left"
onClick={(e) => {
e.stopPropagation();
onPrev();
}}
aria-label="Vorheriges"
>
‹
</button>
)}
{isVideo ? (
<video
className="viewer-content"
src={`/api/media/${item.relativePath}`}
controls
autoPlay
playsInline
onClick={(e) => e.stopPropagation()}
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
className="viewer-content"
src={`/api/media/${item.relativePath}?type=gallery`}
alt={item.filename}
onClick={(e) => e.stopPropagation()}
/>
)}
{hasNext && (
<button
className="viewer-nav-btn right"
onClick={(e) => {
e.stopPropagation();
onNext();
}}
aria-label="Nächstes"
>
›
</button>
)}
</div>
{/* Footer controls */}
<div className="viewer-footer">
{votingOpen && (
<LikeButton
mediaId={item.id}
initialLikes={item._count?.likes ?? 0}
/>
)}
<ShareButton
url={
typeof window !== 'undefined'
? `${window.location.origin}/${item.relativePath}`
: `/${item.relativePath}`
}
/>
<a
href={`/api/media/${item.relativePath}`}
className="btn-icon"
download={item.filename}
title="Herunterladen"
onClick={(e) => e.stopPropagation()}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</a>
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import Link from 'next/link';
export default function Header() {
return (
<header className="header">
<a href="/api/download-all" className="btn-secondary" title="Download All">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
<span className="visually-hidden">Download All</span>
</a>
<h1 className="header-title">Nele & Till</h1>
<Link href="/admin" className="btn-secondary" title="Admin">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
</Link>
</header>
);
}
+68
View File
@@ -0,0 +1,68 @@
'use client';
import React, { useState } from 'react';
type LikeButtonProps = {
mediaId: number;
initialLikes: number;
};
export default function LikeButton({ mediaId, initialLikes }: LikeButtonProps) {
const [likes, setLikes] = useState(initialLikes);
const [isLiked, setIsLiked] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const handleLike = async (e: React.MouseEvent) => {
e.stopPropagation();
if (isLoading) return;
setIsLoading(true);
try {
if (isLiked) {
// Unlike
setLikes(prev => prev - 1);
setIsLiked(false);
const res = await fetch(`/api/likes/${mediaId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to unlike');
const data = await res.json();
setLikes(data.count);
} else {
// Like
setLikes(prev => prev + 1);
setIsLiked(true);
const res = await fetch(`/api/likes/${mediaId}`, { method: 'POST' });
if (!res.ok) throw new Error('Failed to like');
const data = await res.json();
setLikes(data.count);
}
} catch (error) {
console.error('Like error:', error);
// Revert on error
if (isLiked) {
setLikes(prev => prev - 1);
setIsLiked(false);
} else {
setLikes(prev => prev + 1);
setIsLiked(true);
}
} finally {
setIsLoading(false);
}
};
return (
<button
className={`btn-icon like-btn ${isLiked ? 'liked' : ''}`}
onClick={handleLike}
disabled={isLoading}
title={isLiked ? "Gefällt mir nicht mehr" : "Gefällt mir"}
aria-label="Gefällt mir"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill={isLiked ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
</svg>
{likes > 0 && <span className="like-count">{likes}</span>}
</button>
);
}
+92
View File
@@ -0,0 +1,92 @@
'use client';
import React, { useRef, useEffect, useState } from 'react';
type MediaItem = {
id: number;
relativePath: string;
filename: string;
mediaType: string;
width: number | null;
height: number | null;
gridWidth: number;
gridHeight: number;
_count: { likes: number };
};
type MediaTileProps = {
item: MediaItem;
index: number;
onClick: () => void;
votingOpen: boolean;
};
export default function MediaTile({ item, index, onClick, votingOpen }: MediaTileProps) {
const [isVisible, setIsVisible] = useState(false);
const tileRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
}, { rootMargin: '200px' });
if (tileRef.current) observer.observe(tileRef.current);
return () => observer.disconnect();
}, []);
const aspectRatio = item.width && item.height ? `${item.width} / ${item.height}` : '1 / 1';
const style: React.CSSProperties = {
gridColumn: `span ${item.gridWidth || 1}`,
gridRow: `span ${item.gridHeight || 1}`,
aspectRatio,
};
const isVideo = item.mediaType === 'VIDEO';
const likesCount = item._count?.likes ?? 0;
return (
<div
className="media-tile loading-skeleton"
ref={tileRef}
style={style}
onClick={onClick}
data-index={index}
title={item.filename}
>
{isVisible && (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={isVideo ? `/api/media/${item.relativePath}?type=poster` : `/api/media/${item.relativePath}?type=thumb`}
alt={item.filename}
loading="lazy"
onLoad={(e) => {
const target = e.target as HTMLImageElement;
target.parentElement?.classList.remove('loading-skeleton');
}}
/>
{isVideo && (
<div className="play-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<polygon points="5 3 19 12 5 21 5 3"></polygon>
</svg>
</div>
)}
{votingOpen && likesCount > 0 && (
<div className="badge">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
</svg>
<span>{likesCount}</span>
</div>
)}
</>
)}
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
'use client';
import React, { useEffect, useRef, useCallback, useState } from 'react';
import MediaTile from './MediaTile';
import FullscreenViewer from './FullscreenViewer';
import { useViewer } from '@/hooks/useViewer';
type MediaItem = {
id: number;
relativePath: string;
filename: string;
mediaType: string;
mimeType: string;
width: number | null;
height: number | null;
featured: boolean;
gridWidth: number;
gridHeight: number;
placementMode: string;
_count: { likes: number };
[key: string]: unknown;
};
type MediaWallProps = {
initialItems: MediaItem[];
initialCursor: number | null;
initialViewerIndex?: number;
initialVotingOpen?: boolean;
};
export default function MediaWall({
initialItems,
initialCursor,
initialViewerIndex = -1,
initialVotingOpen = false,
}: MediaWallProps) {
const [items, setItems] = useState<MediaItem[]>(initialItems);
const [cursor, setCursor] = useState<number | null>(initialCursor);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(!!initialCursor);
const [votingOpen, setVotingOpen] = useState(initialVotingOpen);
const { isOpen, currentItem, currentIndex, open, close, next, prev } =
useViewer(items, initialViewerIndex);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
const wallRef = useRef<HTMLDivElement>(null);
const loadMore = useCallback(async () => {
if (loading || !hasMore || !cursor) return;
setLoading(true);
try {
const res = await fetch(`/api/media?limit=50&cursor=${cursor}`);
if (res.ok) {
const data = await res.json();
setItems((prev) => {
const existingIds = new Set(prev.map((p) => p.id));
const newItems = data.items.filter(
(item: MediaItem) => !existingIds.has(item.id)
);
return [...prev, ...newItems];
});
setCursor(data.nextCursor);
setHasMore(!!data.nextCursor);
if (data.votingOpen !== undefined) {
setVotingOpen(data.votingOpen);
}
}
} catch (err) {
console.error('Error loading more media:', err);
} finally {
setLoading(false);
}
}, [loading, hasMore, cursor]);
// Infinite scroll observer
useEffect(() => {
if (loading || !hasMore) return;
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
loadMore();
}
});
if (loadMoreRef.current) {
observerRef.current.observe(loadMoreRef.current);
}
return () => {
if (observerRef.current) observerRef.current.disconnect();
};
}, [loading, hasMore, loadMore]);
// Scroll to current item when viewer closes
useEffect(() => {
if (!isOpen && currentIndex >= 0 && wallRef.current) {
const tile = wallRef.current.querySelector(
`[data-index="${currentIndex}"]`
);
if (tile) {
tile.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}, [isOpen, currentIndex]);
return (
<>
<div className="media-wall" ref={wallRef}>
{items.map((item, index) => (
<MediaTile
key={item.id}
item={item}
index={index}
onClick={() => open(index)}
votingOpen={votingOpen}
/>
))}
{hasMore && (
<div
ref={loadMoreRef}
style={{ height: '20px', width: '100%', gridColumn: '1 / -1' }}
/>
)}
{loading && (
<div
style={{
padding: '20px',
textAlign: 'center',
gridColumn: '1 / -1',
color: 'var(--color-text-light)',
}}
>
Lade mehr...
</div>
)}
{!hasMore && items.length > 0 && (
<div
style={{
padding: '40px 20px',
textAlign: 'center',
gridColumn: '1 / -1',
color: 'var(--color-text-light)',
fontFamily: 'var(--font-display)',
fontSize: '0.9rem',
}}
>
♥
</div>
)}
</div>
{isOpen && currentItem && (
<FullscreenViewer
item={currentItem}
onClose={close}
onNext={next}
onPrev={prev}
hasNext={currentIndex < items.length - 1}
hasPrev={currentIndex > 0}
votingOpen={votingOpen}
/>
)}
</>
);
}
+42
View File
@@ -0,0 +1,42 @@
'use client';
import React, { useState } from 'react';
export default function ShareButton({ url }: { url: string }) {
const [copied, setCopied] = useState(false);
const handleShare = async () => {
if (navigator.share) {
try {
await navigator.share({
title: 'Nele & Till - Hochzeit',
text: 'Schau dir dieses Bild an!',
url: url,
});
} catch (err) {
console.error('Error sharing', err);
}
} else {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy text: ', err);
}
}
};
return (
<button className="btn-secondary" onClick={handleShare} title="Teilen">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="18" cy="5" r="3"></circle>
<circle cx="6" cy="12" r="3"></circle>
<circle cx="18" cy="19" r="3"></circle>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line>
</svg>
{copied && <span style={{ marginLeft: '8px', fontSize: '0.875rem' }}>Kopiert!</span>}
</button>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { useState, useCallback } from 'react';
export function useMediaWall(initialItems: any[], initialCursor: string | null) {
const [items, setItems] = useState(initialItems);
const [cursor, setCursor] = useState(initialCursor);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(!!initialCursor);
const [votingOpen, setVotingOpen] = useState(false);
const fetchSettings = useCallback(async () => {
try {
const res = await fetch('/api/admin/settings');
if (res.ok) {
const data = await res.json();
setVotingOpen(data.votingOpen);
}
} catch (err) {
console.error('Error fetching settings:', err);
}
}, []);
const loadMore = useCallback(async () => {
if (loading || !hasMore || !cursor) return;
setLoading(true);
try {
const res = await fetch(`/api/media?limit=50&cursor=${cursor}`);
if (res.ok) {
const data = await res.json();
setItems(prev => {
// Prevent duplicates
const newItems = data.items.filter((item: any) => !prev.some(p => p.id === item.id));
return [...prev, ...newItems];
});
setCursor(data.nextCursor);
setHasMore(!!data.nextCursor);
}
} catch (err) {
console.error('Error loading more media:', err);
} finally {
setLoading(false);
}
}, [loading, hasMore, cursor]);
const findItemIndex = useCallback((relativePath: string) => {
return items.findIndex(item => item.relativePath === relativePath);
}, [items]);
return { items, loading, hasMore, loadMore, votingOpen, fetchSettings, findItemIndex };
}
+50
View File
@@ -0,0 +1,50 @@
import { useState, useEffect, useCallback } from 'react';
export function useViewer(items: any[], initialIndex: number = -1) {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const isOpen = currentIndex >= 0;
const currentItem = isOpen && items.length > 0 ? items[currentIndex] : null;
const open = useCallback((index: number) => {
setCurrentIndex(index);
if (items[index]) {
window.history.pushState({ viewerOpen: true, index }, '', `/${items[index].relativePath}`);
}
}, [items]);
const close = useCallback(() => {
setCurrentIndex(-1);
window.history.pushState(null, '', '/');
}, []);
const next = useCallback(() => {
if (currentIndex < items.length - 1) {
const nextIdx = currentIndex + 1;
setCurrentIndex(nextIdx);
window.history.replaceState({ viewerOpen: true, index: nextIdx }, '', `/${items[nextIdx].relativePath}`);
}
}, [currentIndex, items]);
const prev = useCallback(() => {
if (currentIndex > 0) {
const prevIdx = currentIndex - 1;
setCurrentIndex(prevIdx);
window.history.replaceState({ viewerOpen: true, index: prevIdx }, '', `/${items[prevIdx].relativePath}`);
}
}, [currentIndex, items]);
useEffect(() => {
const handlePopState = (e: PopStateEvent) => {
if (e.state && e.state.viewerOpen) {
setCurrentIndex(e.state.index);
} else {
setCurrentIndex(-1);
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
return { isOpen, currentItem, currentIndex, open, close, next, prev };
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Next.js instrumentation — runs once on server startup.
* Starts background workers and initial filesystem scan.
*/
export async function register() {
// Only run on the server side
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { startWorkers } = await import('@/lib/jobs/workers');
const { enqueueScan } = await import('@/lib/jobs/queue');
// Start background workers
startWorkers();
// Trigger initial scan on startup
setTimeout(async () => {
try {
await enqueueScan();
console.log('[Startup] Initial filesystem scan enqueued');
} catch (err) {
console.error('[Startup] Failed to enqueue initial scan:', err);
}
}, 5000); // Wait 5s for database to be ready
}
}
+43
View File
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { SESSION_COOKIE_NAME } from '@/lib/security/auth';
/**
* Validate a session from the request cookie.
* Returns true if the session exists and is not expired.
*/
export async function validateSession(request: NextRequest): Promise<boolean> {
const sessionCookie = request.cookies.get(SESSION_COOKIE_NAME);
if (!sessionCookie?.value) {
return false;
}
try {
const session = await prisma.adminSession.findUnique({
where: { id: sessionCookie.value },
});
if (!session) return false;
if (session.expiresAt < new Date()) {
// Clean up expired session
await prisma.adminSession.delete({ where: { id: session.id } }).catch(() => {});
return false;
}
return true;
} catch {
return false;
}
}
/**
* Require authentication for an API route.
* Returns a 401 response if not authenticated, or null if OK.
*/
export async function requireAuth(request: NextRequest): Promise<NextResponse | null> {
const isValid = await validateSession(request);
if (!isValid) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
return null;
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Prisma client singleton — prevents multiple instances in development.
*/
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'warn', 'error'] : ['warn', 'error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
export default prisma;
+123
View File
@@ -0,0 +1,123 @@
/**
* Background job queue system using BullMQ + Redis.
* Manages: thumbnail generation, video processing, ZIP building, scanning.
*/
import { Queue, Worker, Job } from 'bullmq';
import { Redis } from 'ioredis';
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
// Shared Redis connection for all queues
let connection: Redis | null = null;
function getConnection(): Redis {
if (!connection) {
connection = new Redis(REDIS_URL, {
maxRetriesPerRequest: null, // Required by BullMQ
lazyConnect: true,
});
connection.on('error', (err) => {
console.error('[Queue] Redis connection error:', err.message);
});
}
return connection;
}
// --- Queue Definitions ---
export const scanQueue = new Queue('scan', {
connection: { url: REDIS_URL },
defaultJobOptions: {
removeOnComplete: 10,
removeOnFail: 50,
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
},
});
export const thumbnailQueue = new Queue('thumbnails', {
connection: { url: REDIS_URL },
defaultJobOptions: {
removeOnComplete: 100,
removeOnFail: 50,
attempts: 3,
backoff: { type: 'exponential', delay: 3000 },
},
});
export const videoQueue = new Queue('video-previews', {
connection: { url: REDIS_URL },
defaultJobOptions: {
removeOnComplete: 50,
removeOnFail: 50,
attempts: 2,
backoff: { type: 'exponential', delay: 10000 },
},
});
export const zipQueue = new Queue('zip', {
connection: { url: REDIS_URL },
defaultJobOptions: {
removeOnComplete: 5,
removeOnFail: 10,
attempts: 3,
backoff: { type: 'exponential', delay: 30000 },
},
});
// --- Job Enqueueing ---
/**
* Enqueue processing jobs for a media item.
*/
export async function enqueueProcessing(
relativePath: string,
mediaType: string
): Promise<void> {
if (mediaType === 'IMAGE') {
await thumbnailQueue.add('generate-thumbnail', { relativePath });
} else if (mediaType === 'VIDEO') {
await videoQueue.add('generate-preview', { relativePath });
}
// AUDIO and OTHER types don't need visual processing
}
/**
* Schedule a ZIP rebuild with debounce.
* Removes any pending ZIP jobs and schedules a new one with delay.
*/
export async function scheduleZipRebuild(): Promise<void> {
const debounceMs =
parseInt(process.env.ZIP_DEBOUNCE_MINUTES || '5', 10) * 60 * 1000;
// Remove any existing delayed ZIP jobs
const delayed = await zipQueue.getDelayed();
for (const job of delayed) {
await job.remove();
}
// Schedule new job with debounce delay
await zipQueue.add(
'build-zip',
{ triggeredAt: new Date().toISOString() },
{ delay: debounceMs }
);
console.log(`[ZIP] Rebuild scheduled in ${debounceMs / 1000}s`);
}
/**
* Enqueue an immediate full filesystem scan.
*/
export async function enqueueScan(): Promise<void> {
// Avoid duplicate scan jobs
const waiting = await scanQueue.getWaiting();
const active = await scanQueue.getActive();
if (waiting.length > 0 || active.length > 0) {
console.log('[Scanner] Scan already queued or running, skipping');
return;
}
await scanQueue.add('full-scan', { triggeredAt: new Date().toISOString() });
}
+142
View File
@@ -0,0 +1,142 @@
/**
* Background job workers — process thumbnails, video previews, filesystem scans, and ZIP builds.
* Workers are started alongside the Next.js server via instrumentation.
*/
import { Worker } from 'bullmq';
import { processImage } from '@/lib/processing/images';
import { processVideo } from '@/lib/processing/videos';
import { buildZip } from '@/lib/processing/zip';
import { runFullScan } from '@/lib/scanner';
import { prisma } from '@/lib/db';
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
let workersStarted = false;
/**
* Start all background workers.
* Called once from instrumentation.ts on server startup.
*/
export function startWorkers(): void {
if (workersStarted) return;
workersStarted = true;
console.log('[Workers] Starting background workers...');
// --- Scan Worker ---
const scanWorker = new Worker(
'scan',
async () => {
await runFullScan();
},
{
connection: { url: REDIS_URL },
concurrency: 1,
limiter: { max: 1, duration: 10000 },
}
);
scanWorker.on('completed', (job) => {
console.log(`[Workers] Scan job ${job.id} completed`);
});
scanWorker.on('failed', (job, err) => {
console.error(`[Workers] Scan job ${job?.id} failed:`, err.message);
});
// --- Thumbnail Worker ---
const thumbnailWorker = new Worker(
'thumbnails',
async (job) => {
const { relativePath } = job.data;
const dimensions = await processImage(relativePath);
if (dimensions) {
await prisma.mediaItem.update({
where: { relativePath },
data: {
width: dimensions.width,
height: dimensions.height,
thumbnailReady: true,
},
});
}
},
{
connection: { url: REDIS_URL },
concurrency: 2, // Process 2 images at a time
}
);
thumbnailWorker.on('failed', (job, err) => {
console.error(
`[Workers] Thumbnail job failed for ${job?.data?.relativePath}:`,
err.message
);
});
// --- Video Worker ---
const videoWorker = new Worker(
'video-previews',
async (job) => {
const { relativePath } = job.data;
const dimensions = await processVideo(relativePath);
if (dimensions) {
await prisma.mediaItem.update({
where: { relativePath },
data: {
width: dimensions.width,
height: dimensions.height,
duration: dimensions.duration,
previewReady: true,
thumbnailReady: true, // Poster counts as thumbnail
},
});
}
},
{
connection: { url: REDIS_URL },
concurrency: 1, // FFmpeg is CPU-intensive
}
);
videoWorker.on('failed', (job, err) => {
console.error(
`[Workers] Video job failed for ${job?.data?.relativePath}:`,
err.message
);
});
// --- ZIP Worker ---
const zipWorker = new Worker(
'zip',
async () => {
await buildZip();
},
{
connection: { url: REDIS_URL },
concurrency: 1,
}
);
zipWorker.on('completed', (job) => {
console.log(`[Workers] ZIP job ${job.id} completed`);
});
zipWorker.on('failed', (job, err) => {
console.error(`[Workers] ZIP job ${job?.id} failed:`, err.message);
});
// --- Periodic Scan ---
const scanInterval = parseInt(process.env.SCAN_INTERVAL_SECONDS || '60', 10) * 1000;
setInterval(async () => {
try {
const { enqueueScan } = await import('@/lib/jobs/queue');
await enqueueScan();
} catch (err) {
console.error('[Workers] Failed to enqueue periodic scan:', err);
}
}, scanInterval);
console.log('[Workers] All workers started');
}
+157
View File
@@ -0,0 +1,157 @@
/**
* MIME type and media type detection based on file extensions.
* No client-provided MIME types are trusted.
*/
import { MediaType } from '@prisma/client';
const IMAGE_EXTENSIONS: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.heic': 'image/heic',
'.heif': 'image/heif',
'.avif': 'image/avif',
'.bmp': 'image/bmp',
'.tiff': 'image/tiff',
'.tif': 'image/tiff',
'.svg': 'image/svg+xml',
};
const VIDEO_EXTENSIONS: Record<string, string> = {
'.mp4': 'video/mp4',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo',
'.mkv': 'video/x-matroska',
'.webm': 'video/webm',
'.wmv': 'video/x-ms-wmv',
'.flv': 'video/x-flv',
'.m4v': 'video/mp4',
'.3gp': 'video/3gpp',
'.mts': 'video/mp2t',
};
const AUDIO_EXTENSIONS: Record<string, string> = {
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.flac': 'audio/flac',
'.m4a': 'audio/mp4',
'.aac': 'audio/aac',
'.wma': 'audio/x-ms-wma',
'.opus': 'audio/opus',
};
/** Files to always exclude from scanning */
const EXCLUDED_FILENAMES = new Set([
'.ds_store',
'desktop.ini',
'thumbs.db',
'.gitignore',
'.gitkeep',
'.gitattributes',
]);
const EXCLUDED_PREFIXES = [
'.syncthing.',
'~syncthing~',
'.~lock.',
'~$',
];
const EXCLUDED_SUFFIXES = [
'.tmp',
'.temp',
'.partial',
'.crdownload',
'.part',
];
const EXCLUDED_DIRECTORIES = new Set([
'.git',
'.syncthing',
'node_modules',
'.cache',
'__pycache__',
'.trash',
'@eadir', // Synology
]);
/**
* Determine the MediaType enum from a file extension.
*/
export function getMediaType(ext: string): MediaType {
const lower = ext.toLowerCase();
if (lower in IMAGE_EXTENSIONS) return 'IMAGE';
if (lower in VIDEO_EXTENSIONS) return 'VIDEO';
if (lower in AUDIO_EXTENSIONS) return 'AUDIO';
return 'OTHER';
}
/**
* Get the MIME type for a file extension.
*/
export function getMimeType(ext: string): string {
const lower = ext.toLowerCase();
return (
IMAGE_EXTENSIONS[lower] ||
VIDEO_EXTENSIONS[lower] ||
AUDIO_EXTENSIONS[lower] ||
'application/octet-stream'
);
}
/**
* Check if a file extension is a displayable image.
*/
export function isDisplayableImage(ext: string): boolean {
const lower = ext.toLowerCase();
// SVG is excluded from display for XSS safety
return lower in IMAGE_EXTENSIONS && lower !== '.svg';
}
/**
* Check if a file extension is a displayable video.
*/
export function isDisplayableVideo(ext: string): boolean {
return ext.toLowerCase() in VIDEO_EXTENSIONS;
}
/**
* Check if a file should be excluded from scanning.
*/
export function isExcludedFile(filename: string): boolean {
const lower = filename.toLowerCase();
if (EXCLUDED_FILENAMES.has(lower)) return true;
for (const prefix of EXCLUDED_PREFIXES) {
if (lower.startsWith(prefix)) return true;
}
for (const suffix of EXCLUDED_SUFFIXES) {
if (lower.endsWith(suffix)) return true;
}
return false;
}
/**
* Check if a directory should be excluded from scanning.
*/
export function isExcludedDirectory(dirname: string): boolean {
return EXCLUDED_DIRECTORIES.has(dirname.toLowerCase());
}
/**
* Get the content type to serve for a file.
* For security, SVGs are served as plain XML (not rendered in browser context).
*/
export function getSafeContentType(ext: string): string {
const lower = ext.toLowerCase();
// SVG can contain scripts — serve as attachment or plain content
if (lower === '.svg') return 'image/svg+xml';
return getMimeType(lower);
}
+136
View File
@@ -0,0 +1,136 @@
/**
* Image processing — thumbnail and gallery-size generation using sharp.
* Handles EXIF orientation, HEIC/HEIF conversion, and WebP output.
*/
import sharp from 'sharp';
import path from 'path';
import fs from 'fs';
const MEDIA_ROOT = process.env.MEDIA_ROOT || '/media';
const CACHE_DIR = process.env.CACHE_DIR || '/cache';
/** Thumbnail: small preview for the media wall */
const THUMB_WIDTH = 400;
const THUMB_QUALITY = 75;
/** Gallery: medium-size for the fullscreen viewer */
const GALLERY_WIDTH = 1600;
const GALLERY_QUALITY = 82;
export interface ImageDimensions {
width: number;
height: number;
}
/**
* Generate thumbnail and gallery images for a media item.
* Returns the extracted dimensions of the original image.
*/
export async function processImage(relativePath: string): Promise<ImageDimensions | null> {
const srcPath = path.join(MEDIA_ROOT, relativePath);
const cacheBase = path.join(CACHE_DIR, 'thumbnails', relativePath);
// Ensure output directory exists
const cacheDir = path.dirname(cacheBase);
await fs.promises.mkdir(cacheDir, { recursive: true });
try {
// Read source and auto-rotate based on EXIF
const image = sharp(srcPath, { failOn: 'none' }).rotate();
const metadata = await image.metadata();
const width = metadata.width || 0;
const height = metadata.height || 0;
if (width === 0 || height === 0) {
console.error(`[ImageProcessor] Invalid dimensions for ${relativePath}`);
return null;
}
// Generate thumbnail (WebP)
const thumbPath = cacheBase.replace(/\.[^.]+$/, '_thumb.webp');
await image
.clone()
.resize(THUMB_WIDTH, undefined, {
withoutEnlargement: true,
fit: 'inside',
})
.webp({ quality: THUMB_QUALITY })
.toFile(thumbPath);
// Generate gallery image (WebP) — only if original is larger
const galleryPath = cacheBase.replace(/\.[^.]+$/, '_gallery.webp');
if (width > GALLERY_WIDTH) {
await image
.clone()
.resize(GALLERY_WIDTH, undefined, {
withoutEnlargement: true,
fit: 'inside',
})
.webp({ quality: GALLERY_QUALITY })
.toFile(galleryPath);
} else {
// For smaller images, just convert to WebP at original size
await image
.clone()
.webp({ quality: GALLERY_QUALITY })
.toFile(galleryPath);
}
console.log(`[ImageProcessor] Processed: ${relativePath} (${width}x${height})`);
return { width, height };
} catch (error) {
console.error(
`[ImageProcessor] Failed to process ${relativePath}:`,
error instanceof Error ? error.message : String(error)
);
return null;
}
}
/**
* Get the cache path for a thumbnail.
*/
export function getThumbnailPath(relativePath: string): string {
return path.join(
CACHE_DIR,
'thumbnails',
relativePath.replace(/\.[^.]+$/, '_thumb.webp')
);
}
/**
* Get the cache path for a gallery image.
*/
export function getGalleryPath(relativePath: string): string {
return path.join(
CACHE_DIR,
'thumbnails',
relativePath.replace(/\.[^.]+$/, '_gallery.webp')
);
}
/**
* Check if processed assets exist for a media item.
*/
export function hasProcessedAssets(relativePath: string): boolean {
return (
fs.existsSync(getThumbnailPath(relativePath)) &&
fs.existsSync(getGalleryPath(relativePath))
);
}
/**
* Delete processed assets for a media item.
*/
export async function deleteProcessedAssets(relativePath: string): Promise<void> {
const paths = [getThumbnailPath(relativePath), getGalleryPath(relativePath)];
for (const p of paths) {
try {
await fs.promises.unlink(p);
} catch {
// File might not exist — ignore
}
}
}
+167
View File
@@ -0,0 +1,167 @@
/**
* Video processing — poster image and short muted preview generation using FFmpeg.
* Uses child_process directly to avoid fragile wrapper dependencies.
*/
import { execFile } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import fs from 'fs';
const execFileAsync = promisify(execFile);
const MEDIA_ROOT = process.env.MEDIA_ROOT || '/media';
const CACHE_DIR = process.env.CACHE_DIR || '/cache';
/** Preview: short muted clip */
const PREVIEW_DURATION = 4; // seconds
const PREVIEW_WIDTH = 480; // pixels (height auto)
const PREVIEW_BITRATE = '500k';
export interface VideoDimensions {
width: number;
height: number;
duration: number;
}
/**
* Generate poster image and short muted preview for a video.
* Returns video dimensions and duration.
*/
export async function processVideo(relativePath: string): Promise<VideoDimensions | null> {
const srcPath = path.join(MEDIA_ROOT, relativePath);
const cacheBase = path.join(CACHE_DIR, 'previews', relativePath);
const cacheDir = path.dirname(cacheBase);
await fs.promises.mkdir(cacheDir, { recursive: true });
try {
// 1. Probe video for dimensions and duration
const probeResult = await probeVideo(srcPath);
if (!probeResult) {
console.error(`[VideoProcessor] Failed to probe ${relativePath}`);
return null;
}
// 2. Generate poster image (WebP, from 1 second in)
const posterPath = cacheBase.replace(/\.[^.]+$/, '_poster.webp');
const seekTime = Math.min(1, probeResult.duration / 2);
await execFileAsync('ffmpeg', [
'-y',
'-ss', seekTime.toString(),
'-i', srcPath,
'-vframes', '1',
'-vf', `scale=${PREVIEW_WIDTH}:-2`,
'-q:v', '80',
posterPath,
], { timeout: 30000 });
// 3. Generate short muted MP4 preview
const previewPath = cacheBase.replace(/\.[^.]+$/, '_preview.mp4');
const previewDuration = Math.min(PREVIEW_DURATION, probeResult.duration);
await execFileAsync('ffmpeg', [
'-y',
'-ss', '0',
'-i', srcPath,
'-t', previewDuration.toString(),
'-an', // No audio
'-vf', `scale=${PREVIEW_WIDTH}:-2`,
'-c:v', 'libx264',
'-preset', 'fast',
'-b:v', PREVIEW_BITRATE,
'-movflags', '+faststart',
'-pix_fmt', 'yuv420p',
previewPath,
], { timeout: 120000 });
console.log(
`[VideoProcessor] Processed: ${relativePath} (${probeResult.width}x${probeResult.height}, ${probeResult.duration.toFixed(1)}s)`
);
return probeResult;
} catch (error) {
console.error(
`[VideoProcessor] Failed to process ${relativePath}:`,
error instanceof Error ? error.message : String(error)
);
return null;
}
}
/**
* Probe a video file for dimensions and duration using FFprobe.
*/
async function probeVideo(filePath: string): Promise<VideoDimensions | null> {
try {
const { stdout } = await execFileAsync('ffprobe', [
'-v', 'quiet',
'-print_format', 'json',
'-show_streams',
'-show_format',
filePath,
], { timeout: 15000 });
const data = JSON.parse(stdout);
const videoStream = data.streams?.find(
(s: { codec_type: string }) => s.codec_type === 'video'
);
if (!videoStream) return null;
return {
width: parseInt(videoStream.width, 10) || 0,
height: parseInt(videoStream.height, 10) || 0,
duration: parseFloat(data.format?.duration || '0'),
};
} catch {
return null;
}
}
/**
* Get the cache path for a video poster.
*/
export function getVideoPosterPath(relativePath: string): string {
return path.join(
CACHE_DIR,
'previews',
relativePath.replace(/\.[^.]+$/, '_poster.webp')
);
}
/**
* Get the cache path for a video preview.
*/
export function getVideoPreviewPath(relativePath: string): string {
return path.join(
CACHE_DIR,
'previews',
relativePath.replace(/\.[^.]+$/, '_preview.mp4')
);
}
/**
* Check if processed assets exist for a video.
*/
export function hasVideoAssets(relativePath: string): boolean {
return (
fs.existsSync(getVideoPosterPath(relativePath)) &&
fs.existsSync(getVideoPreviewPath(relativePath))
);
}
/**
* Delete processed assets for a video.
*/
export async function deleteVideoAssets(relativePath: string): Promise<void> {
const paths = [getVideoPosterPath(relativePath), getVideoPreviewPath(relativePath)];
for (const p of paths) {
try {
await fs.promises.unlink(p);
} catch {
// Ignore
}
}
}
+163
View File
@@ -0,0 +1,163 @@
/**
* ZIP generation — creates a filtered archive of published files
* with atomic replacement and failure protection.
*/
import archiver from 'archiver';
import fs from 'fs';
import path from 'path';
import { prisma } from '@/lib/db';
const MEDIA_ROOT = process.env.MEDIA_ROOT || '/media';
const ZIP_OUTPUT_PATH = process.env.ZIP_OUTPUT_PATH || '/cache/download/hochzeit.zip';
/**
* Build a ZIP archive containing only published files.
* Uses atomic replacement — writes to a temp file, then renames.
*/
export async function buildZip(): Promise<boolean> {
console.log('[ZIP] Starting ZIP build...');
const startTime = Date.now();
// 1. Get all published files
const publishedItems = await prisma.mediaItem.findMany({
where: { published: true },
select: { relativePath: true },
orderBy: { relativePath: 'asc' },
});
if (publishedItems.length === 0) {
console.log('[ZIP] No published files — skipping ZIP build');
return true;
}
console.log(`[ZIP] Building ZIP with ${publishedItems.length} files`);
// 2. Ensure output directory exists
const outputDir = path.dirname(ZIP_OUTPUT_PATH);
await fs.promises.mkdir(outputDir, { recursive: true });
// 3. Create temp file for atomic replacement
const tempPath = `${ZIP_OUTPUT_PATH}.tmp.${Date.now()}`;
try {
// 4. Create ZIP archive
const output = fs.createWriteStream(tempPath);
const archive = archiver('zip', {
zlib: { level: 1 }, // Fast compression — media is usually already compressed
});
// Track errors
let archiveError: Error | null = null;
archive.on('error', (err) => {
archiveError = err;
});
archive.on('warning', (err) => {
console.warn('[ZIP] Warning:', err.message);
});
// Pipe to file
archive.pipe(output);
// 5. Add each published file
let addedCount = 0;
for (const item of publishedItems) {
const absolutePath = path.join(MEDIA_ROOT, item.relativePath);
// Verify file still exists
try {
await fs.promises.access(absolutePath, fs.constants.R_OK);
archive.file(absolutePath, { name: item.relativePath });
addedCount++;
} catch {
console.warn(`[ZIP] File missing, skipping: ${item.relativePath}`);
}
}
// 6. Finalize archive
await archive.finalize();
// Wait for the output stream to finish
await new Promise<void>((resolve, reject) => {
output.on('close', resolve);
output.on('error', reject);
});
if (archiveError) {
throw archiveError;
}
// 7. Validate the ZIP file (check it's not empty/corrupt)
const stat = await fs.promises.stat(tempPath);
if (stat.size < 22) {
// Minimum ZIP file size is 22 bytes (empty ZIP)
throw new Error('Generated ZIP file is too small — likely corrupt');
}
// 8. Atomic replacement
await fs.promises.rename(tempPath, ZIP_OUTPUT_PATH);
// 9. Update database
await prisma.appSettings.upsert({
where: { id: 1 },
update: {
zipReady: true,
zipBuildAt: new Date(),
zipFilePath: ZIP_OUTPUT_PATH,
zipVersion: { increment: 1 },
},
create: {
id: 1,
zipReady: true,
zipBuildAt: new Date(),
zipFilePath: ZIP_OUTPUT_PATH,
zipVersion: 1,
},
});
const elapsed = Date.now() - startTime;
const sizeMB = (stat.size / 1024 / 1024).toFixed(1);
console.log(
`[ZIP] Build complete in ${elapsed}ms: ${addedCount} files, ${sizeMB} MB`
);
return true;
} catch (error) {
console.error(
'[ZIP] Build failed:',
error instanceof Error ? error.message : String(error)
);
// Clean up temp file on failure
try {
await fs.promises.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
// Do NOT touch the existing valid ZIP — it remains available
return false;
}
}
/**
* Check if a valid ZIP file exists and is ready for download.
*/
export async function isZipReady(): Promise<boolean> {
try {
const settings = await prisma.appSettings.findUnique({ where: { id: 1 } });
if (!settings?.zipReady) return false;
await fs.promises.access(ZIP_OUTPUT_PATH, fs.constants.R_OK);
return true;
} catch {
return false;
}
}
/**
* Get the path to the current valid ZIP file.
*/
export function getZipPath(): string {
return ZIP_OUTPUT_PATH;
}
+205
View File
@@ -0,0 +1,205 @@
/**
* Filesystem scanner — recursively scans media directory,
* detects new/changed/removed files, and syncs with the database.
*/
import fs from 'fs';
import path from 'path';
import { prisma } from '@/lib/db';
import { getMediaType, getMimeType, isExcludedFile, isExcludedDirectory } from '@/lib/media/types';
import { getRelativePath } from '@/lib/security/path';
import { enqueueProcessing } from '@/lib/jobs/queue';
const MEDIA_ROOT = process.env.MEDIA_ROOT || '/media';
const STABILITY_DELAY = parseInt(process.env.FILE_STABILITY_DELAY_MS || '3000', 10);
interface ScannedFile {
relativePath: string;
absolutePath: string;
filename: string;
directory: string;
ext: string;
size: bigint;
mtime: Date;
}
/**
* Run a full filesystem scan and reconcile with the database.
*/
export async function runFullScan(): Promise<{
added: number;
removed: number;
unchanged: number;
errors: string[];
}> {
console.log('[Scanner] Starting full scan of', MEDIA_ROOT);
const startTime = Date.now();
const errors: string[] = [];
// 1. Walk filesystem and collect all files
const scannedFiles = new Map<string, ScannedFile>();
await walkDirectory(MEDIA_ROOT, scannedFiles, errors);
console.log(`[Scanner] Found ${scannedFiles.size} files on disk`);
// 2. Get all known files from database
const dbItems = await prisma.mediaItem.findMany({
select: { id: true, relativePath: true, fileSize: true, lastModified: true },
});
const dbPaths = new Map(dbItems.map((item) => [item.relativePath, item]));
let added = 0;
let removed = 0;
let unchanged = 0;
// 3. Find new and changed files
for (const [relPath, file] of scannedFiles) {
const existing = dbPaths.get(relPath);
if (!existing) {
// New file — check stability first
const isStable = await checkFileStability(file.absolutePath);
if (!isStable) {
console.log(`[Scanner] Skipping unstable file: ${relPath}`);
continue;
}
try {
const mediaType = getMediaType(file.ext);
const mimeType = getMimeType(file.ext);
await prisma.mediaItem.create({
data: {
relativePath: relPath,
filename: file.filename,
directory: file.directory,
mediaType,
mimeType,
fileSize: file.size,
lastModified: file.mtime,
lastScanned: new Date(),
published: false,
},
});
// Enqueue processing jobs
await enqueueProcessing(relPath, mediaType);
added++;
console.log(`[Scanner] Added: ${relPath}`);
} catch (error) {
const msg = `Failed to add ${relPath}: ${error instanceof Error ? error.message : String(error)}`;
errors.push(msg);
console.error(`[Scanner] ${msg}`);
}
} else {
unchanged++;
dbPaths.delete(relPath); // Mark as seen
}
}
// 4. Find removed files (remaining in dbPaths = not found on disk)
for (const [relPath, item] of dbPaths) {
try {
await prisma.mediaItem.delete({ where: { id: item.id } });
removed++;
console.log(`[Scanner] Removed: ${relPath}`);
} catch (error) {
const msg = `Failed to remove ${relPath}: ${error instanceof Error ? error.message : String(error)}`;
errors.push(msg);
console.error(`[Scanner] ${msg}`);
}
}
// 5. Update scan timestamp
await prisma.appSettings.upsert({
where: { id: 1 },
update: { lastScanAt: new Date() },
create: { id: 1, lastScanAt: new Date() },
});
const elapsed = Date.now() - startTime;
console.log(
`[Scanner] Scan complete in ${elapsed}ms: +${added} -${removed} =${unchanged} errors=${errors.length}`
);
return { added, removed, unchanged, errors };
}
/**
* Recursively walk a directory and collect all eligible files.
*/
async function walkDirectory(
dirPath: string,
results: Map<string, ScannedFile>,
errors: string[]
): Promise<void> {
let entries: fs.Dirent[];
try {
entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
} catch (error) {
const msg = `Cannot read directory ${dirPath}: ${error instanceof Error ? error.message : String(error)}`;
errors.push(msg);
console.error(`[Scanner] ${msg}`);
return;
}
for (const entry of entries) {
try {
if (entry.isDirectory()) {
if (isExcludedDirectory(entry.name)) continue;
await walkDirectory(path.join(dirPath, entry.name), results, errors);
} else if (entry.isFile()) {
if (isExcludedFile(entry.name)) continue;
const absolutePath = path.join(dirPath, entry.name);
const relativePath = getRelativePath(absolutePath, MEDIA_ROOT);
if (!relativePath) continue; // Safety check
const ext = path.extname(entry.name);
const stat = await fs.promises.stat(absolutePath);
// Determine directory name (first level under media root)
const parts = relativePath.split('/');
const directory = parts.length > 1 ? parts[0] : '';
results.set(relativePath, {
relativePath,
absolutePath,
filename: entry.name,
directory,
ext,
size: BigInt(stat.size),
mtime: stat.mtime,
});
}
} catch (error) {
const msg = `Error processing ${entry.name}: ${error instanceof Error ? error.message : String(error)}`;
errors.push(msg);
console.error(`[Scanner] ${msg}`);
// Continue scanning other files
}
}
}
/**
* Check if a file has stabilized (not still being transferred by Syncthing).
* Compares size and mtime at two intervals.
*/
async function checkFileStability(filePath: string): Promise<boolean> {
try {
const stat1 = await fs.promises.stat(filePath);
await new Promise((resolve) => setTimeout(resolve, STABILITY_DELAY));
const stat2 = await fs.promises.stat(filePath);
return (
stat1.size === stat2.size &&
stat1.mtimeMs === stat2.mtimeMs
);
} catch {
return false;
}
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Authentication utilities — secure password verification, session management.
*/
import { randomBytes, createHash } from 'crypto';
import bcrypt from 'bcrypt';
const BCRYPT_ROUNDS = 12;
/**
* Hash the admin password for comparison.
* On first startup, the plain-text env password is hashed and cached.
*/
let cachedPasswordHash: string | null = null;
export async function verifyPassword(plaintext: string): Promise<boolean> {
const envPassword = process.env.ADMIN_PASSWORD;
if (!envPassword) {
throw new Error('ADMIN_PASSWORD environment variable is not set');
}
// Hash the env password once for bcrypt comparison
if (!cachedPasswordHash) {
cachedPasswordHash = await bcrypt.hash(envPassword, BCRYPT_ROUNDS);
}
// Use constant-time comparison via bcrypt
return bcrypt.compare(plaintext, cachedPasswordHash);
}
/**
* Generate a secure random session ID.
*/
export function generateSessionId(): string {
return randomBytes(32).toString('hex');
}
/**
* Hash an IP address for storage (privacy-preserving).
*/
export function hashIP(ip: string): string {
const secret = process.env.APP_SECRET || 'fallback-secret';
return createHash('sha256')
.update(ip + secret)
.digest('hex');
}
/**
* Get the real client IP from request headers.
* Handles X-Forwarded-For from reverse proxy.
*/
export function getClientIP(headers: Headers): string {
const forwarded = headers.get('x-forwarded-for');
if (forwarded) {
// Take the first IP (original client)
return forwarded.split(',')[0].trim();
}
const realIp = headers.get('x-real-ip');
if (realIp) {
return realIp.trim();
}
return '127.0.0.1';
}
/**
* Session cookie configuration
*/
export const SESSION_COOKIE_NAME = 'wedding_admin_session';
export const SESSION_MAX_AGE = 24 * 60 * 60; // 24 hours in seconds
export function getSessionCookieOptions(): string {
const isProduction = process.env.NODE_ENV === 'production';
const parts = [
`${SESSION_COOKIE_NAME}=`,
'Path=/',
'HttpOnly',
`SameSite=${isProduction ? 'Strict' : 'Lax'}`,
`Max-Age=${SESSION_MAX_AGE}`,
];
if (isProduction) {
parts.push('Secure');
}
return parts.join('; ');
}
export function getClearSessionCookieOptions(): string {
return `${SESSION_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`;
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Path security utilities — prevents path traversal and escape attacks.
* This is a CRITICAL security module.
*/
import path from 'path';
import fs from 'fs';
/**
* Validates and resolves a user-provided path to ensure it stays within the media root.
* Returns the resolved absolute path if safe, or null if the path is dangerous.
*
* Security checks:
* - Null byte injection
* - Path traversal (../)
* - Encoded traversal (%2e%2e, etc.)
* - Absolute path override
* - Symlink escape
* - Backslash traversal (Windows-style)
*/
export function validateMediaPath(
userPath: string,
mediaRoot: string
): string | null {
// Reject null bytes
if (userPath.includes('\0')) return null;
// Reject obviously dangerous patterns before any processing
const decoded = safeDecodeURI(userPath);
if (decoded === null) return null;
// Reject backslash (Windows path separator used for traversal)
if (decoded.includes('\\')) return null;
// Reject any form of .. traversal
const segments = decoded.split('/');
for (const seg of segments) {
const trimmed = seg.trim();
if (trimmed === '..' || trimmed === '.') return null;
// Check for null bytes or control characters in segments
if (/[\x00-\x1f]/.test(trimmed)) return null;
}
// Resolve the absolute path
const resolvedRoot = path.resolve(mediaRoot);
const resolvedPath = path.resolve(resolvedRoot, decoded);
// Ensure the resolved path is inside the media root
// Use path.sep to handle OS differences correctly
if (
!resolvedPath.startsWith(resolvedRoot + path.sep) &&
resolvedPath !== resolvedRoot
) {
return null;
}
// Check for symlink escape (resolve realpath and verify it's still inside root)
try {
if (fs.existsSync(resolvedPath)) {
const realPath = fs.realpathSync(resolvedPath);
const realRoot = fs.realpathSync(resolvedRoot);
if (
!realPath.startsWith(realRoot + path.sep) &&
realPath !== realRoot
) {
return null;
}
}
} catch {
// If we can't resolve the real path, it might not exist yet (safe to continue)
// But the path was already validated above
}
return resolvedPath;
}
/**
* Safely decode URI component, handling double encoding.
* Returns null if the input is malicious.
*/
function safeDecodeURI(input: string): string | null {
try {
// Decode once
let decoded = decodeURIComponent(input);
// Check if double-encoded (decode again and check for traversal)
try {
const doubleDecoded = decodeURIComponent(decoded);
if (doubleDecoded !== decoded) {
// Was double-encoded — check the fully decoded version for traversal
if (doubleDecoded.includes('..') || doubleDecoded.includes('\0')) {
return null;
}
decoded = doubleDecoded;
}
} catch {
// Single encoding only — that's fine
}
// Final safety check on decoded result
if (decoded.includes('\0')) return null;
return decoded;
} catch {
return null;
}
}
/**
* Sanitize a filename for safe filesystem operations.
* Strips dangerous characters but preserves the basic name.
*/
export function sanitizeFilename(filename: string): string {
return filename
.replace(/[\x00-\x1f]/g, '') // Control characters
.replace(/[<>:"|?*\\]/g, '') // Windows-unsafe characters
.replace(/\.\./g, '') // Traversal
.trim();
}
/**
* Get the relative path from the media root to a file.
* Returns null if the file is not inside the media root.
*/
export function getRelativePath(
absolutePath: string,
mediaRoot: string
): string | null {
const resolvedRoot = path.resolve(mediaRoot);
const resolvedPath = path.resolve(absolutePath);
if (!resolvedPath.startsWith(resolvedRoot + path.sep)) {
return null;
}
// Return with forward slashes for URL compatibility
return path.relative(resolvedRoot, resolvedPath).replace(/\\/g, '/');
}
+142
View File
@@ -0,0 +1,142 @@
/**
* Redis-backed sliding window rate limiter.
* Provides per-IP, per-action rate limiting with configurable windows.
*/
import { Redis } from 'ioredis';
import { hashIP } from './auth';
let redis: Redis | null = null;
function getRedis(): Redis {
if (!redis) {
redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
maxRetriesPerRequest: 3,
lazyConnect: true,
});
redis.on('error', (err) => {
console.error('[RateLimit] Redis error:', err.message);
});
}
return redis;
}
export interface RateLimitConfig {
/** Maximum requests allowed in the window */
maxRequests: number;
/** Window size in seconds */
windowSeconds: number;
/** Key prefix for namespacing */
prefix: string;
}
export const RATE_LIMITS = {
login: {
maxRequests: 5,
windowSeconds: 15 * 60, // 15 minutes
prefix: 'rl:login',
},
like: {
maxRequests: 30,
windowSeconds: 60, // 1 minute
prefix: 'rl:like:min',
},
likeHourly: {
maxRequests: 200,
windowSeconds: 60 * 60, // 1 hour
prefix: 'rl:like:hour',
},
api: {
maxRequests: 120,
windowSeconds: 60, // 1 minute
prefix: 'rl:api',
},
} as const satisfies Record<string, RateLimitConfig>;
export interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: Date;
}
/**
* Check if a request is within rate limits.
* Uses Redis sliding window counter.
*/
export async function checkRateLimit(
ip: string,
config: RateLimitConfig
): Promise<RateLimitResult> {
const r = getRedis();
const ipH = hashIP(ip);
const now = Math.floor(Date.now() / 1000);
const windowStart = now - config.windowSeconds;
const key = `${config.prefix}:${ipH}`;
try {
// Use sorted set with timestamps as scores
const pipeline = r.pipeline();
// Remove expired entries
pipeline.zremrangebyscore(key, 0, windowStart);
// Count current entries
pipeline.zcard(key);
// Add current request (with unique member)
pipeline.zadd(key, now, `${now}:${Math.random().toString(36).slice(2, 8)}`);
// Set TTL
pipeline.expire(key, config.windowSeconds);
const results = await pipeline.exec();
if (!results) {
// Redis unavailable — fail open (allow request)
return { allowed: true, remaining: config.maxRequests, resetAt: new Date((now + config.windowSeconds) * 1000) };
}
const currentCount = (results[1]?.[1] as number) || 0;
const allowed = currentCount < config.maxRequests;
const remaining = Math.max(0, config.maxRequests - currentCount - 1);
if (!allowed) {
// Remove the request we just added since it's not allowed
const lastResult = results[2];
if (lastResult) {
// We already added it, so decrement remaining
}
}
return {
allowed,
remaining,
resetAt: new Date((now + config.windowSeconds) * 1000),
};
} catch (error) {
console.error('[RateLimit] Error checking rate limit:', error);
// Fail open on Redis errors — don't block legitimate users
return {
allowed: true,
remaining: config.maxRequests,
resetAt: new Date((now + config.windowSeconds) * 1000),
};
}
}
/**
* Add rate limit headers to a response.
*/
export function addRateLimitHeaders(
headers: Headers,
result: RateLimitResult,
config: RateLimitConfig
): void {
headers.set('X-RateLimit-Limit', config.maxRequests.toString());
headers.set('X-RateLimit-Remaining', result.remaining.toString());
headers.set('X-RateLimit-Reset', Math.floor(result.resetAt.getTime() / 1000).toString());
if (!result.allowed) {
headers.set('Retry-After', Math.ceil((result.resetAt.getTime() - Date.now()) / 1000).toString());
}
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Next.js Middleware — security headers and admin route protection.
*/
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
const { pathname } = request.nextUrl;
// --- Security Headers (all responses) ---
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set('X-DNS-Prefetch-Control', 'off');
response.headers.set(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=()'
);
// Content Security Policy
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-eval' 'unsafe-inline'", // Next.js requires these
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"media-src 'self' blob:",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; ');
response.headers.set('Content-Security-Policy', csp);
// Remove server identity
response.headers.delete('X-Powered-By');
// --- Admin Route Protection ---
if (pathname.startsWith('/admin') && !pathname.startsWith('/admin/login')) {
const sessionCookie = request.cookies.get('wedding_admin_session');
if (!sessionCookie?.value) {
return NextResponse.redirect(new URL('/admin/login', request.url));
}
// Note: Full session validation happens in the API routes server-side.
// The middleware only does a quick cookie-presence check for redirects.
}
// --- Block access to internal paths ---
const blockedPaths = [
'/.env',
'/.git',
'/prisma',
'/docker-compose',
'/Dockerfile',
'/node_modules',
'/.next',
];
for (const blocked of blockedPaths) {
if (pathname.startsWith(blocked)) {
return new NextResponse('Not Found', { status: 404 });
}
}
return response;
}
export const config = {
matcher: [
/*
* Match all request paths except:
* - _next/static (static files)
* - _next/image (image optimization)
* - favicon.ico
*/
'/((?!_next/static|_next/image|favicon.ico).*)',
],
};
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
},
"forceConsistentCasingInFileNames": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}