diff --git a/app/admin/page.tsx b/app/admin/page.tsx
index 5d1f1ed..432a91d 100644
--- a/app/admin/page.tsx
+++ b/app/admin/page.tsx
@@ -151,8 +151,30 @@ export default function AdminPage() {
}
};
+ const handleLogout = () => {
+ localStorage.removeItem('hoerdle_admin_auth');
+ setIsAuthenticated(false);
+ setPassword('');
+ // Reset all state
+ setSongs([]);
+ setGenres([]);
+ setSpecials([]);
+ setDailyPuzzles([]);
+ };
+
+ // Helper function to add auth headers to requests
+ const getAuthHeaders = () => {
+ const authToken = localStorage.getItem('hoerdle_admin_auth');
+ return {
+ 'Content-Type': 'application/json',
+ 'x-admin-auth': authToken || ''
+ };
+ };
+
const fetchSongs = async () => {
- const res = await fetch('/api/songs');
+ const res = await fetch('/api/songs', {
+ headers: getAuthHeaders()
+ });
if (res.ok) {
const data = await res.json();
setSongs(data);
@@ -160,7 +182,9 @@ export default function AdminPage() {
};
const fetchGenres = async () => {
- const res = await fetch('/api/genres');
+ const res = await fetch('/api/genres', {
+ headers: getAuthHeaders()
+ });
if (res.ok) {
const data = await res.json();
setGenres(data);
@@ -171,6 +195,7 @@ export default function AdminPage() {
if (!newGenreName.trim()) return;
const res = await fetch('/api/genres', {
method: 'POST',
+ headers: getAuthHeaders(),
body: JSON.stringify({ name: newGenreName, subtitle: newGenreSubtitle }),
});
if (res.ok) {
@@ -192,7 +217,7 @@ export default function AdminPage() {
if (editingGenreId === null) return;
const res = await fetch('/api/genres', {
method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
+ headers: getAuthHeaders(),
body: JSON.stringify({
id: editingGenreId,
name: editGenreName,
@@ -209,7 +234,9 @@ export default function AdminPage() {
// Specials functions
const fetchSpecials = async () => {
- const res = await fetch('/api/specials');
+ const res = await fetch('/api/specials', {
+ headers: getAuthHeaders()
+ });
if (res.ok) {
const data = await res.json();
setSpecials(data);
@@ -220,7 +247,7 @@ export default function AdminPage() {
e.preventDefault();
const res = await fetch('/api/specials', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: getAuthHeaders(),
body: JSON.stringify({
name: newSpecialName,
subtitle: newSpecialSubtitle,
@@ -249,7 +276,7 @@ export default function AdminPage() {
if (!confirm('Delete this special?')) return;
const res = await fetch('/api/specials', {
method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
+ headers: getAuthHeaders(),
body: JSON.stringify({ id }),
});
if (res.ok) fetchSpecials();
@@ -258,7 +285,9 @@ export default function AdminPage() {
// Daily Puzzles functions
const fetchDailyPuzzles = async () => {
- const res = await fetch('/api/admin/daily-puzzles');
+ const res = await fetch('/api/admin/daily-puzzles', {
+ headers: getAuthHeaders()
+ });
if (res.ok) {
const data = await res.json();
setDailyPuzzles(data);
@@ -269,7 +298,7 @@ export default function AdminPage() {
if (!confirm('Delete this daily puzzle? A new one will be generated automatically.')) return;
const res = await fetch('/api/admin/daily-puzzles', {
method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
+ headers: getAuthHeaders(),
body: JSON.stringify({ puzzleId }),
});
if (res.ok) {
@@ -328,7 +357,7 @@ export default function AdminPage() {
if (editingSpecialId === null) return;
const res = await fetch('/api/specials', {
method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
+ headers: getAuthHeaders(),
body: JSON.stringify({
id: editingSpecialId,
name: editSpecialName,
@@ -357,6 +386,7 @@ export default function AdminPage() {
if (!confirm('Delete this genre?')) return;
const res = await fetch('/api/genres', {
method: 'DELETE',
+ headers: getAuthHeaders(),
body: JSON.stringify({ id }),
});
if (res.ok) {
@@ -383,7 +413,7 @@ export default function AdminPage() {
while (hasMore) {
const res = await fetch('/api/categorize', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: getAuthHeaders(),
body: JSON.stringify({ offset })
});
@@ -453,6 +483,7 @@ export default function AdminPage() {
const res = await fetch('/api/songs', {
method: 'POST',
+ headers: { 'x-admin-auth': localStorage.getItem('hoerdle_admin_auth') || '' },
body: formData,
});
@@ -555,7 +586,7 @@ export default function AdminPage() {
const res = await fetch('/api/songs', {
method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
+ headers: getAuthHeaders(),
body: JSON.stringify({
id: uploadedSong.id,
title: uploadedSong.title,
@@ -596,7 +627,7 @@ export default function AdminPage() {
const saveEditing = async (id: number) => {
const res = await fetch('/api/songs', {
method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
+ headers: getAuthHeaders(),
body: JSON.stringify({
id,
title: editTitle,
@@ -623,7 +654,7 @@ export default function AdminPage() {
const res = await fetch('/api/songs', {
method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
+ headers: getAuthHeaders(),
body: JSON.stringify({ id }),
});
@@ -759,7 +790,24 @@ export default function AdminPage() {
return (
-
Hördle Admin Dashboard
+
+
Hördle Admin Dashboard
+
+
{/* Special Management */}
diff --git a/app/api/admin/daily-puzzles/route.ts b/app/api/admin/daily-puzzles/route.ts
index 231d40c..860a52e 100644
--- a/app/api/admin/daily-puzzles/route.ts
+++ b/app/api/admin/daily-puzzles/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
+import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient();
@@ -63,6 +64,10 @@ export async function GET() {
}
export async function DELETE(request: Request) {
+ // Check authentication
+ const authError = await requireAdminAuth(request as any);
+ if (authError) return authError;
+
try {
const { puzzleId } = await request.json();
diff --git a/app/api/admin/login/route.ts b/app/api/admin/login/route.ts
index 0331fc5..9a6f57a 100644
--- a/app/api/admin/login/route.ts
+++ b/app/api/admin/login/route.ts
@@ -1,7 +1,12 @@
-import { NextResponse } from 'next/server';
+import { NextRequest, NextResponse } from 'next/server';
import bcrypt from 'bcryptjs';
+import { rateLimit } from '@/lib/rateLimit';
+
+export async function POST(request: NextRequest) {
+ // Rate limiting: 5 login attempts per minute
+ const rateLimitError = rateLimit(request, { windowMs: 60000, maxRequests: 5 });
+ if (rateLimitError) return rateLimitError;
-export async function POST(request: Request) {
try {
const { password } = await request.json();
// Default is hash for 'admin123'
diff --git a/app/api/audio/[filename]/route.ts b/app/api/audio/[filename]/route.ts
index 1ad8e82..c9f01c1 100644
--- a/app/api/audio/[filename]/route.ts
+++ b/app/api/audio/[filename]/route.ts
@@ -8,8 +8,28 @@ export async function GET(
) {
try {
const { filename } = await params;
+
+ // Security: Prevent path traversal attacks
+ // Only allow alphanumeric, hyphens, underscores, and dots
+ const safeFilenamePattern = /^[a-zA-Z0-9_\-\.]+\.mp3$/;
+ if (!safeFilenamePattern.test(filename)) {
+ return new NextResponse('Invalid filename', { status: 400 });
+ }
+
+ // Additional check: ensure no path separators
+ if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
+ return new NextResponse('Invalid filename', { status: 400 });
+ }
+
const filePath = path.join(process.cwd(), 'public/uploads', filename);
+ // Security: Verify the resolved path is still within uploads directory
+ const uploadsDir = path.join(process.cwd(), 'public/uploads');
+ const resolvedPath = path.resolve(filePath);
+ if (!resolvedPath.startsWith(uploadsDir)) {
+ return new NextResponse('Forbidden', { status: 403 });
+ }
+
// Check if file exists
try {
await stat(filePath);
diff --git a/app/api/categorize/route.ts b/app/api/categorize/route.ts
index da04137..2d78691 100644
--- a/app/api/categorize/route.ts
+++ b/app/api/categorize/route.ts
@@ -1,6 +1,7 @@
'use server';
import { PrismaClient } from '@prisma/client';
+import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient();
@@ -16,6 +17,10 @@ interface CategorizeResult {
}
export async function POST(request: Request) {
+ // Check authentication
+ const authError = await requireAdminAuth(request as any);
+ if (authError) return authError;
+
try {
if (!OPENROUTER_API_KEY) {
return Response.json(
diff --git a/app/api/genres/route.ts b/app/api/genres/route.ts
index f7d588a..c80145f 100644
--- a/app/api/genres/route.ts
+++ b/app/api/genres/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
+import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient();
@@ -21,6 +22,10 @@ export async function GET() {
}
export async function POST(request: Request) {
+ // Check authentication
+ const authError = await requireAdminAuth(request as any);
+ if (authError) return authError;
+
try {
const { name, subtitle } = await request.json();
@@ -43,6 +48,10 @@ export async function POST(request: Request) {
}
export async function DELETE(request: Request) {
+ // Check authentication
+ const authError = await requireAdminAuth(request as any);
+ if (authError) return authError;
+
try {
const { id } = await request.json();
@@ -62,6 +71,10 @@ export async function DELETE(request: Request) {
}
export async function PUT(request: Request) {
+ // Check authentication
+ const authError = await requireAdminAuth(request as any);
+ if (authError) return authError;
+
try {
const { id, name, subtitle } = await request.json();
diff --git a/app/api/songs/route.ts b/app/api/songs/route.ts
index 4ca9f30..1173e78 100644
--- a/app/api/songs/route.ts
+++ b/app/api/songs/route.ts
@@ -4,6 +4,7 @@ import { writeFile, unlink } from 'fs/promises';
import path from 'path';
import { parseBuffer } from 'music-metadata';
import { isDuplicateSong } from '@/lib/fuzzyMatch';
+import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient();
@@ -42,6 +43,10 @@ export async function GET() {
}
export async function POST(request: Request) {
+ // Check authentication
+ const authError = await requireAdminAuth(request as any);
+ if (authError) return authError;
+
try {
const formData = await request.formData();
const file = formData.get('file') as File;
@@ -52,6 +57,29 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
+ // Security: Validate file size (max 50MB)
+ const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
+ if (file.size > MAX_FILE_SIZE) {
+ return NextResponse.json({
+ error: `File too large. Maximum size is 50MB, got ${(file.size / 1024 / 1024).toFixed(2)}MB`
+ }, { status: 400 });
+ }
+
+ // Security: Validate MIME type
+ const allowedMimeTypes = ['audio/mpeg', 'audio/mp3'];
+ if (!allowedMimeTypes.includes(file.type)) {
+ return NextResponse.json({
+ error: `Invalid file type. Expected MP3, got ${file.type}`
+ }, { status: 400 });
+ }
+
+ // Security: Validate file extension
+ if (!file.name.toLowerCase().endsWith('.mp3')) {
+ return NextResponse.json({
+ error: 'Invalid file extension. Only .mp3 files are allowed'
+ }, { status: 400 });
+ }
+
const buffer = Buffer.from(await file.arrayBuffer());
// Validate and extract metadata from file
@@ -214,6 +242,10 @@ export async function POST(request: Request) {
}
export async function PUT(request: Request) {
+ // Check authentication
+ const authError = await requireAdminAuth(request as any);
+ if (authError) return authError;
+
try {
const { id, title, artist, releaseYear, genreIds, specialIds } = await request.json();
@@ -289,6 +321,10 @@ export async function PUT(request: Request) {
}
export async function DELETE(request: Request) {
+ // Check authentication
+ const authError = await requireAdminAuth(request as any);
+ if (authError) return authError;
+
try {
const { id } = await request.json();
diff --git a/app/api/specials/route.ts b/app/api/specials/route.ts
index 0260f16..4fd42b3 100644
--- a/app/api/specials/route.ts
+++ b/app/api/specials/route.ts
@@ -1,5 +1,6 @@
import { PrismaClient, Special } from '@prisma/client';
import { NextResponse } from 'next/server';
+import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient();
@@ -16,6 +17,10 @@ export async function GET() {
}
export async function POST(request: Request) {
+ // Check authentication
+ const authError = await requireAdminAuth(request as any);
+ if (authError) return authError;
+
const { name, subtitle, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]', launchDate, endDate, curator } = await request.json();
if (!name) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
@@ -35,6 +40,10 @@ export async function POST(request: Request) {
}
export async function DELETE(request: Request) {
+ // Check authentication
+ const authError = await requireAdminAuth(request as any);
+ if (authError) return authError;
+
const { id } = await request.json();
if (!id) {
return NextResponse.json({ error: 'ID required' }, { status: 400 });
@@ -44,6 +53,10 @@ export async function DELETE(request: Request) {
}
export async function PUT(request: Request) {
+ // Check authentication
+ const authError = await requireAdminAuth(request as any);
+ if (authError) return authError;
+
const { id, name, subtitle, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json();
if (!id) {
return NextResponse.json({ error: 'ID required' }, { status: 400 });
diff --git a/lib/auth.ts b/lib/auth.ts
new file mode 100644
index 0000000..3496676
--- /dev/null
+++ b/lib/auth.ts
@@ -0,0 +1,37 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+/**
+ * Authentication middleware for admin API routes
+ * Verifies that the request includes a valid admin session token
+ */
+export async function requireAdminAuth(request: NextRequest): Promise {
+ const authHeader = request.headers.get('x-admin-auth');
+
+ if (!authHeader || authHeader !== 'authenticated') {
+ return NextResponse.json(
+ { error: 'Unauthorized - Admin authentication required' },
+ { status: 401 }
+ );
+ }
+
+ return null; // Auth successful
+}
+
+/**
+ * Helper to verify admin password
+ */
+export async function verifyAdminPassword(password: string): Promise {
+ const bcrypt = await import('bcryptjs');
+
+ // Validate that ADMIN_PASSWORD is set (security best practice)
+ if (!process.env.ADMIN_PASSWORD) {
+ console.error('SECURITY WARNING: ADMIN_PASSWORD environment variable is not set!');
+ // Fallback to default hash only in development
+ if (process.env.NODE_ENV === 'production') {
+ throw new Error('ADMIN_PASSWORD environment variable is required in production');
+ }
+ }
+
+ const adminPasswordHash = process.env.ADMIN_PASSWORD || '$2b$10$SHOt9G1qUNIvHoWre7499.eEtp5PtOII0daOQGNV.dhDEuPmOUdsq';
+ return bcrypt.compare(password, adminPasswordHash);
+}
diff --git a/lib/rateLimit.ts b/lib/rateLimit.ts
new file mode 100644
index 0000000..9aa8eac
--- /dev/null
+++ b/lib/rateLimit.ts
@@ -0,0 +1,76 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+/**
+ * Rate limiting configuration
+ * Simple in-memory rate limiter for API endpoints
+ */
+interface RateLimitEntry {
+ count: number;
+ resetTime: number;
+}
+
+const rateLimitMap = new Map();
+
+// Clean up old entries every 5 minutes
+setInterval(() => {
+ const now = Date.now();
+ for (const [key, entry] of rateLimitMap.entries()) {
+ if (now > entry.resetTime) {
+ rateLimitMap.delete(key);
+ }
+ }
+}, 5 * 60 * 1000);
+
+export interface RateLimitConfig {
+ windowMs: number; // Time window in milliseconds
+ maxRequests: number; // Maximum requests per window
+}
+
+/**
+ * Rate limiting middleware
+ * @param request - The incoming request
+ * @param config - Rate limit configuration
+ * @returns NextResponse with 429 status if rate limit exceeded, null otherwise
+ */
+export function rateLimit(
+ request: NextRequest,
+ config: RateLimitConfig = { windowMs: 60000, maxRequests: 100 }
+): NextResponse | null {
+ // Get client identifier (IP address or fallback)
+ const identifier =
+ request.headers.get('x-forwarded-for')?.split(',')[0] ||
+ request.headers.get('x-real-ip') ||
+ 'unknown';
+
+ const now = Date.now();
+ const entry = rateLimitMap.get(identifier);
+
+ if (!entry || now > entry.resetTime) {
+ // Create new entry or reset expired entry
+ rateLimitMap.set(identifier, {
+ count: 1,
+ resetTime: now + config.windowMs
+ });
+ return null;
+ }
+
+ if (entry.count >= config.maxRequests) {
+ const retryAfter = Math.ceil((entry.resetTime - now) / 1000);
+ return NextResponse.json(
+ { error: 'Too many requests. Please try again later.' },
+ {
+ status: 429,
+ headers: {
+ 'Retry-After': retryAfter.toString(),
+ 'X-RateLimit-Limit': config.maxRequests.toString(),
+ 'X-RateLimit-Remaining': '0',
+ 'X-RateLimit-Reset': new Date(entry.resetTime).toISOString()
+ }
+ }
+ );
+ }
+
+ // Increment counter
+ entry.count++;
+ return null;
+}
diff --git a/middleware.ts b/middleware.ts
new file mode 100644
index 0000000..3442f70
--- /dev/null
+++ b/middleware.ts
@@ -0,0 +1,52 @@
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/server';
+
+export function middleware(request: NextRequest) {
+ const response = NextResponse.next();
+
+ // Security Headers
+ const headers = response.headers;
+
+ // Prevent clickjacking
+ headers.set('X-Frame-Options', 'SAMEORIGIN');
+
+ // XSS Protection (legacy but still useful)
+ headers.set('X-XSS-Protection', '1; mode=block');
+
+ // Prevent MIME type sniffing
+ headers.set('X-Content-Type-Options', 'nosniff');
+
+ // Referrer Policy
+ headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
+
+ // Permissions Policy (restrict features)
+ headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
+
+ // Content Security Policy
+ const csp = [
+ "default-src 'self'",
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Next.js requires unsafe-inline/eval
+ "style-src 'self' 'unsafe-inline'", // Allow inline styles
+ "img-src 'self' data: blob:",
+ "font-src 'self' data:",
+ "connect-src 'self' https://openrouter.ai https://gotify.example.com https://musicbrainz.org",
+ "media-src 'self' blob:",
+ "frame-ancestors 'self'",
+ ].join('; ');
+ headers.set('Content-Security-Policy', csp);
+
+ return response;
+}
+
+// Apply middleware to all routes
+export const config = {
+ matcher: [
+ /*
+ * Match all request paths except for the ones starting with:
+ * - _next/static (static files)
+ * - _next/image (image optimization files)
+ * - favicon.ico (favicon file)
+ */
+ '/((?!_next/static|_next/image|favicon.ico).*)',
+ ],
+};