Initialize Nele & Till Wedding Gallery
This commit is contained in:
commit
a1c808cc6b
59 files changed
+13518
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
Generated
+8802
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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&T</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 251 B |
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="admin-layout">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ status: 'ok', timestamp: new Date() });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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() });
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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, '/');
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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).*)',
|
||||
],
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user