From eecee9bc5f359f83b03494d5865d73d029349d56 Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:44:24 -0400 Subject: [PATCH 1/2] add auth --- .session_secret | 1 + package-lock.json | 40 ++ package.json | 1 + src/app/api/auth/login/route.ts | 36 ++ src/app/api/auth/logout/route.ts | 9 + src/app/api/auth/register/route.ts | 59 +++ src/app/api/browse/route.ts | 6 +- src/app/api/file/route.ts | 4 + src/app/api/game-cover/route.ts | 4 + src/app/api/games/route.ts | 6 +- src/app/api/libraries/[id]/route.ts | 6 +- src/app/api/libraries/route.ts | 16 +- src/app/api/library-cover/[id]/route.ts | 14 +- src/app/api/movies/route.ts | 11 +- src/app/api/tags/assignments/route.ts | 28 ++ src/app/api/tags/categories/[id]/route.ts | 7 + src/app/api/tags/categories/route.ts | 9 +- src/app/api/tags/items/[id]/route.ts | 9 +- src/app/api/tags/items/route.ts | 7 + src/app/api/tags/library-assignments/route.ts | 14 +- src/app/api/thumbnail/route.ts | 4 + src/app/api/tv/route.ts | 11 +- src/app/api/users/[id]/permissions/route.ts | 58 +++ src/app/api/users/[id]/route.ts | 33 ++ src/app/api/users/route.ts | 10 + src/app/layout.tsx | 11 +- src/app/library/[id]/page.tsx | 12 +- src/app/login/LoginForm.tsx | 158 +++++++ src/app/login/layout.tsx | 16 + src/app/login/page.tsx | 14 + src/app/manage/layout.tsx | 8 +- src/app/manage/users/page.tsx | 436 ++++++++++++++++++ src/app/page.tsx | 27 +- src/components/HeaderNav.tsx | 43 ++ src/components/ManageSubNav.tsx | 1 + src/instrumentation.ts | 6 + src/lib/auth.ts | 112 +++++ src/lib/db.ts | 14 + src/lib/secret.ts | 25 + src/lib/users.ts | 119 +++++ src/middleware.ts | 28 ++ 41 files changed, 1405 insertions(+), 28 deletions(-) create mode 100644 .session_secret create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/app/api/auth/register/route.ts create mode 100644 src/app/api/users/[id]/permissions/route.ts create mode 100644 src/app/api/users/[id]/route.ts create mode 100644 src/app/api/users/route.ts create mode 100644 src/app/login/LoginForm.tsx create mode 100644 src/app/login/layout.tsx create mode 100644 src/app/login/page.tsx create mode 100644 src/app/manage/users/page.tsx create mode 100644 src/components/HeaderNav.tsx create mode 100644 src/instrumentation.ts create mode 100644 src/lib/auth.ts create mode 100644 src/lib/secret.ts create mode 100644 src/lib/users.ts create mode 100644 src/middleware.ts diff --git a/.session_secret b/.session_secret new file mode 100644 index 0000000..ace81f4 --- /dev/null +++ b/.session_secret @@ -0,0 +1 @@ +e63d0c98a24a2c5a25438b22b426d27e8ca9e3889e8d7a344b556ac48c5d51f3 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 40a69d3..aa9f88f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "better-sqlite3": "^12.8.0", "fast-xml-parser": "^5.5.10", + "iron-session": "^8.0.4", "next": "^15.5.14", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -2855,6 +2856,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4327,6 +4337,30 @@ "node": ">= 0.4" } }, + "node_modules/iron-session": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/iron-session/-/iron-session-8.0.4.tgz", + "integrity": "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA==", + "funding": [ + "https://github.com/sponsors/vvo", + "https://github.com/sponsors/brc-dd" + ], + "license": "MIT", + "dependencies": { + "cookie": "^0.7.2", + "iron-webcrypto": "^1.2.1", + "uncrypto": "^0.1.3" + } + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -6953,6 +6987,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", diff --git a/package.json b/package.json index a4b911c..b21aee3 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dependencies": { "better-sqlite3": "^12.8.0", "fast-xml-parser": "^5.5.10", + "iron-session": "^8.0.4", "next": "^15.5.14", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..3442ac8 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getWritableSession, verifyPassword, type SessionData } from '@/lib/auth' +import { getUserByUsername } from '@/lib/users' + +export async function POST(request: NextRequest) { + let body: { username?: string; password?: string } + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const { username, password } = body + if (!username || !password) { + return NextResponse.json({ error: 'username and password are required' }, { status: 400 }) + } + + const user = getUserByUsername(username) + if (!user) { + return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }) + } + + const valid = await verifyPassword(password, user.passwordHash) + if (!valid) { + return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }) + } + + const response = NextResponse.json({ role: user.role }) + const session = await getWritableSession(request, response) + session.userId = user.id + session.username = user.username + session.role = user.role + await session.save() + + return response +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..6e2a5cb --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,9 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getWritableSession } from '@/lib/auth' + +export async function POST(request: NextRequest) { + const response = new NextResponse(null, { status: 204 }) + const session = await getWritableSession(request, response) + session.destroy() + return response +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..c83a972 --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getSessionOptions, hashPassword, type SessionData } from '@/lib/auth' +import { getIronSession } from 'iron-session' +import { getUserCount, createUser } from '@/lib/users' + +export async function POST(request: NextRequest) { + let body: { username?: string; password?: string; role?: string } + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const { username, password } = body + let { role } = body + + if (!username || !password) { + return NextResponse.json({ error: 'username and password are required' }, { status: 400 }) + } + if (username.trim().length < 2) { + return NextResponse.json({ error: 'Username must be at least 2 characters' }, { status: 400 }) + } + if (password.length < 8) { + return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 }) + } + + const userCount = getUserCount() + + if (userCount === 0) { + // First user always becomes admin + role = 'admin' + } else { + // Subsequent users require an admin session + const res = new NextResponse() + const session = await getIronSession(request, res, getSessionOptions()) + if (!session.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + if (session.role !== 'admin') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + if (role !== 'admin' && role !== 'user') { + role = 'user' + } + } + + const passwordHash = await hashPassword(password) + + try { + const user = createUser(username.trim(), passwordHash, role as 'admin' | 'user') + return NextResponse.json({ id: user.id, username: user.username, role: user.role }, { status: 201 }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create user' + if (message.includes('UNIQUE constraint failed')) { + return NextResponse.json({ error: 'Username already taken' }, { status: 409 }) + } + return NextResponse.json({ error: message }, { status: 400 }) + } +} diff --git a/src/app/api/browse/route.ts b/src/app/api/browse/route.ts index b4a35ce..6c72934 100644 --- a/src/app/api/browse/route.ts +++ b/src/app/api/browse/route.ts @@ -1,8 +1,9 @@ import { NextRequest, NextResponse } from 'next/server' import { getLibrary, resolveLibraryRoot } from '@/lib/libraries' import { scanDirectory } from '@/lib/files' +import { requireLibraryAccess } from '@/lib/auth' -export function GET(request: NextRequest) { +export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl const libraryId = searchParams.get('libraryId') const subpath = searchParams.get('path') ?? '' @@ -11,6 +12,9 @@ export function GET(request: NextRequest) { return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 }) } + const auth = await requireLibraryAccess(request, libraryId) + if (auth instanceof NextResponse) return auth + const library = getLibrary(libraryId) if (!library) { return NextResponse.json({ error: 'Library not found' }, { status: 404 }) diff --git a/src/app/api/file/route.ts b/src/app/api/file/route.ts index b7df28c..1036a15 100644 --- a/src/app/api/file/route.ts +++ b/src/app/api/file/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import fs from 'fs' import path from 'path' import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' +import { requireLibraryAccess } from '@/lib/auth' const MIME_TYPES: Record = { '.mp4': 'video/mp4', @@ -35,6 +36,9 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Missing libraryId or path' }, { status: 400 }) } + const auth = await requireLibraryAccess(request, libraryId) + if (auth instanceof NextResponse) return auth + const library = getLibrary(libraryId) if (!library) { return NextResponse.json({ error: 'Library not found' }, { status: 404 }) diff --git a/src/app/api/game-cover/route.ts b/src/app/api/game-cover/route.ts index b2fcaf3..2c8b552 100644 --- a/src/app/api/game-cover/route.ts +++ b/src/app/api/game-cover/route.ts @@ -3,6 +3,7 @@ import fs from 'fs' import sharp from 'sharp' import { NextRequest, NextResponse } from 'next/server' import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' +import { requireAdmin } from '@/lib/auth' const MAX_COVER_BYTES = 10 * 1024 * 1024 // 10 MB @@ -13,6 +14,9 @@ function isCoverType(s: string | null): s is CoverType { } export async function POST(request: NextRequest) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + const { searchParams } = request.nextUrl const libraryId = searchParams.get('libraryId') const itemId = searchParams.get('itemId') diff --git a/src/app/api/games/route.ts b/src/app/api/games/route.ts index 30fb649..54ae4ad 100644 --- a/src/app/api/games/route.ts +++ b/src/app/api/games/route.ts @@ -1,8 +1,9 @@ import { NextRequest, NextResponse } from 'next/server' import { getLibrary, resolveLibraryRoot } from '@/lib/libraries' import { scanGamesLibrary } from '@/lib/games' +import { requireLibraryAccess } from '@/lib/auth' -export function GET(request: NextRequest) { +export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl const libraryId = searchParams.get('libraryId') @@ -10,6 +11,9 @@ export function GET(request: NextRequest) { return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 }) } + const auth = await requireLibraryAccess(request, libraryId) + if (auth instanceof NextResponse) return auth + const library = getLibrary(libraryId) if (!library) { return NextResponse.json({ error: 'Library not found' }, { status: 404 }) diff --git a/src/app/api/libraries/[id]/route.ts b/src/app/api/libraries/[id]/route.ts index 15c659b..370e013 100644 --- a/src/app/api/libraries/[id]/route.ts +++ b/src/app/api/libraries/[id]/route.ts @@ -1,11 +1,15 @@ import { NextRequest, NextResponse } from 'next/server' import { getLibrary, removeLibrary } from '@/lib/libraries' import { removeAllAssignmentsForLibrary } from '@/lib/tags' +import { requireAdmin } from '@/lib/auth' export async function DELETE( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + const { id } = await params const library = getLibrary(id) diff --git a/src/app/api/libraries/route.ts b/src/app/api/libraries/route.ts index 52fcde3..c09eec1 100644 --- a/src/app/api/libraries/route.ts +++ b/src/app/api/libraries/route.ts @@ -1,10 +1,19 @@ import { NextRequest, NextResponse } from 'next/server' import { getLibraries, addLibrary } from '@/lib/libraries' +import { getLibrariesForUser } from '@/lib/users' +import { requireAuth, requireAdmin } from '@/lib/auth' import type { LibraryType } from '@/types' -export function GET() { +export async function GET(request: NextRequest) { + const auth = await requireAuth(request) + if (auth instanceof NextResponse) return auth + const { session } = auth + try { - const libraries = getLibraries() + const libraries = + session.role === 'admin' + ? getLibraries() + : getLibrariesForUser(session.userId, session.role) return NextResponse.json(libraries) } catch (err) { console.error('Failed to read libraries', err) @@ -13,6 +22,9 @@ export function GET() { } export async function POST(request: NextRequest) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + let body: { name?: string; path?: string; type?: string } try { body = await request.json() diff --git a/src/app/api/library-cover/[id]/route.ts b/src/app/api/library-cover/[id]/route.ts index d87553c..cda9e6c 100644 --- a/src/app/api/library-cover/[id]/route.ts +++ b/src/app/api/library-cover/[id]/route.ts @@ -3,6 +3,7 @@ import fs from 'fs' import sharp from 'sharp' import { NextRequest, NextResponse } from 'next/server' import { getLibrary, updateLibraryCover, clearLibraryCover } from '@/lib/libraries' +import { requireAuth, requireAdmin } from '@/lib/auth' const COVERS_DIR = path.resolve(process.cwd(), '.covers') const MAX_COVER_BYTES = 10 * 1024 * 1024 // 10 MB @@ -12,9 +13,12 @@ function coverPath(id: string, ext: string) { } export async function GET( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { + const auth = await requireAuth(request) + if (auth instanceof NextResponse) return auth + const { id } = await params const library = getLibrary(id) if (!library?.coverExt) { @@ -39,6 +43,9 @@ export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + const { id } = await params const library = getLibrary(id) if (!library) { @@ -85,9 +92,12 @@ export async function POST( } export async function DELETE( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + const { id } = await params const library = getLibrary(id) if (!library) { diff --git a/src/app/api/movies/route.ts b/src/app/api/movies/route.ts index b4bdf68..74caf9f 100644 --- a/src/app/api/movies/route.ts +++ b/src/app/api/movies/route.ts @@ -4,8 +4,9 @@ import { NextRequest, NextResponse } from 'next/server' import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' import { scanMoviesLibrary } from '@/lib/movies' import { removeAllAssignmentsForItem } from '@/lib/tags' +import { requireLibraryAccess, requireAdmin } from '@/lib/auth' -export function GET(request: NextRequest) { +export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl const libraryId = searchParams.get('libraryId') @@ -13,6 +14,9 @@ export function GET(request: NextRequest) { return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 }) } + const auth = await requireLibraryAccess(request, libraryId) + if (auth instanceof NextResponse) return auth + const library = getLibrary(libraryId) if (!library) { return NextResponse.json({ error: 'Library not found' }, { status: 404 }) @@ -26,7 +30,10 @@ export function GET(request: NextRequest) { return NextResponse.json(movies) } -export function DELETE(request: NextRequest) { +export async function DELETE(request: NextRequest) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + const { searchParams } = request.nextUrl const libraryId = searchParams.get('libraryId') const movieId = searchParams.get('movieId') diff --git a/src/app/api/tags/assignments/route.ts b/src/app/api/tags/assignments/route.ts index fe6944e..887ddd7 100644 --- a/src/app/api/tags/assignments/route.ts +++ b/src/app/api/tags/assignments/route.ts @@ -1,5 +1,12 @@ import { NextRequest, NextResponse } from 'next/server' import { getResolvedTagsForItem, addTagToItem, removeTagFromItem } from '@/lib/tags' +import { requireLibraryAccess } from '@/lib/auth' + +function extractLibraryId(mediaKey: string): string | null { + const colonIdx = mediaKey.indexOf(':') + if (colonIdx === -1) return null + return mediaKey.slice(0, colonIdx) +} export async function GET(request: NextRequest) { try { @@ -8,6 +15,13 @@ export async function GET(request: NextRequest) { if (!mediaKey) { return NextResponse.json({ error: 'mediaKey is required' }, { status: 400 }) } + const libraryId = extractLibraryId(mediaKey) + if (!libraryId) { + return NextResponse.json({ error: 'Invalid mediaKey' }, { status: 400 }) + } + const auth = await requireLibraryAccess(request, libraryId) + if (auth instanceof NextResponse) return auth + return NextResponse.json(getResolvedTagsForItem(mediaKey)) } catch (err) { return NextResponse.json({ error: (err as Error).message }, { status: 500 }) @@ -20,6 +34,13 @@ export async function POST(request: NextRequest) { if (!mediaKey || !tagId) { return NextResponse.json({ error: 'mediaKey and tagId are required' }, { status: 400 }) } + const libraryId = extractLibraryId(mediaKey) + if (!libraryId) { + return NextResponse.json({ error: 'Invalid mediaKey' }, { status: 400 }) + } + const auth = await requireLibraryAccess(request, libraryId) + if (auth instanceof NextResponse) return auth + addTagToItem(mediaKey, tagId) return new NextResponse(null, { status: 204 }) } catch (err) { @@ -35,6 +56,13 @@ export async function DELETE(request: NextRequest) { if (!mediaKey || !tagId) { return NextResponse.json({ error: 'mediaKey and tagId are required' }, { status: 400 }) } + const libraryId = extractLibraryId(mediaKey) + if (!libraryId) { + return NextResponse.json({ error: 'Invalid mediaKey' }, { status: 400 }) + } + const auth = await requireLibraryAccess(request, libraryId) + if (auth instanceof NextResponse) return auth + removeTagFromItem(mediaKey, tagId) return new NextResponse(null, { status: 204 }) } catch (err) { diff --git a/src/app/api/tags/categories/[id]/route.ts b/src/app/api/tags/categories/[id]/route.ts index 47123a3..ab7e9a7 100644 --- a/src/app/api/tags/categories/[id]/route.ts +++ b/src/app/api/tags/categories/[id]/route.ts @@ -1,10 +1,14 @@ import { NextRequest, NextResponse } from 'next/server' import { updateCategory, deleteCategory, deleteCategoryForce, getTags } from '@/lib/tags' +import { requireAdmin } from '@/lib/auth' export async function PATCH( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + try { const { id } = await params const { name } = await request.json() @@ -19,6 +23,9 @@ export async function DELETE( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + try { const { id } = await params const { searchParams } = new URL(request.url) diff --git a/src/app/api/tags/categories/route.ts b/src/app/api/tags/categories/route.ts index 548d22a..780e36f 100644 --- a/src/app/api/tags/categories/route.ts +++ b/src/app/api/tags/categories/route.ts @@ -1,7 +1,11 @@ import { NextRequest, NextResponse } from 'next/server' import { getCategories, addCategory } from '@/lib/tags' +import { requireAuth, requireAdmin } from '@/lib/auth' + +export async function GET(request: NextRequest) { + const auth = await requireAuth(request) + if (auth instanceof NextResponse) return auth -export async function GET() { try { return NextResponse.json(getCategories()) } catch (err) { @@ -10,6 +14,9 @@ export async function GET() { } export async function POST(request: NextRequest) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + try { const { name } = await request.json() const category = addCategory(name) diff --git a/src/app/api/tags/items/[id]/route.ts b/src/app/api/tags/items/[id]/route.ts index 0c48eab..059fc8b 100644 --- a/src/app/api/tags/items/[id]/route.ts +++ b/src/app/api/tags/items/[id]/route.ts @@ -1,10 +1,14 @@ import { NextRequest, NextResponse } from 'next/server' import { updateTag, deleteTag } from '@/lib/tags' +import { requireAdmin } from '@/lib/auth' export async function PATCH( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + try { const { id } = await params const { name } = await request.json() @@ -16,9 +20,12 @@ export async function PATCH( } export async function DELETE( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + try { const { id } = await params deleteTag(id) diff --git a/src/app/api/tags/items/route.ts b/src/app/api/tags/items/route.ts index 1ddc6ee..c11a87b 100644 --- a/src/app/api/tags/items/route.ts +++ b/src/app/api/tags/items/route.ts @@ -1,7 +1,11 @@ import { NextRequest, NextResponse } from 'next/server' import { getTags, getTagsSortedByUsage, addTag } from '@/lib/tags' +import { requireAuth, requireAdmin } from '@/lib/auth' export async function GET(request: NextRequest) { + const auth = await requireAuth(request) + if (auth instanceof NextResponse) return auth + try { const { searchParams } = new URL(request.url) const categoryId = searchParams.get('categoryId') ?? undefined @@ -15,6 +19,9 @@ export async function GET(request: NextRequest) { } export async function POST(request: NextRequest) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + try { const { name, categoryId } = await request.json() const tag = addTag(name, categoryId) diff --git a/src/app/api/tags/library-assignments/route.ts b/src/app/api/tags/library-assignments/route.ts index c4b2c1d..3f383be 100644 --- a/src/app/api/tags/library-assignments/route.ts +++ b/src/app/api/tags/library-assignments/route.ts @@ -1,8 +1,14 @@ +import { NextRequest, NextResponse } from 'next/server' import { getTagAssignmentsForLibrary } from '@/lib/tags' +import { requireLibraryAccess } from '@/lib/auth' -export async function GET(req: Request) { - const { searchParams } = new URL(req.url) +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url) const libraryId = searchParams.get('libraryId') - if (!libraryId) return Response.json({ error: 'libraryId required' }, { status: 400 }) - return Response.json(getTagAssignmentsForLibrary(libraryId)) + if (!libraryId) return NextResponse.json({ error: 'libraryId required' }, { status: 400 }) + + const auth = await requireLibraryAccess(request, libraryId) + if (auth instanceof NextResponse) return auth + + return NextResponse.json(getTagAssignmentsForLibrary(libraryId)) } diff --git a/src/app/api/thumbnail/route.ts b/src/app/api/thumbnail/route.ts index 6a1c031..cf2ced4 100644 --- a/src/app/api/thumbnail/route.ts +++ b/src/app/api/thumbnail/route.ts @@ -3,6 +3,7 @@ import fs from 'fs' import path from 'path' import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' import { getThumbnailPath } from '@/lib/thumbnails' +import { requireLibraryAccess } from '@/lib/auth' const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.mkv', '.avi', '.webm', '.m4v']) const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif']) @@ -23,6 +24,9 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Missing libraryId or path' }, { status: 400 }) } + const auth = await requireLibraryAccess(request, libraryId) + if (auth instanceof NextResponse) return auth + const library = getLibrary(libraryId) if (!library) { return NextResponse.json({ error: 'Library not found' }, { status: 404 }) diff --git a/src/app/api/tv/route.ts b/src/app/api/tv/route.ts index 6d29ed5..4e14839 100644 --- a/src/app/api/tv/route.ts +++ b/src/app/api/tv/route.ts @@ -4,8 +4,9 @@ import { NextRequest, NextResponse } from 'next/server' import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' import { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from '@/lib/tv' import { removeAllAssignmentsForItem } from '@/lib/tags' +import { requireLibraryAccess, requireAdmin } from '@/lib/auth' -export function GET(request: NextRequest) { +export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl const libraryId = searchParams.get('libraryId') const seriesId = searchParams.get('seriesId') @@ -15,6 +16,9 @@ export function GET(request: NextRequest) { return NextResponse.json({ error: 'Missing libraryId' }, { status: 400 }) } + const auth = await requireLibraryAccess(request, libraryId) + if (auth instanceof NextResponse) return auth + const library = getLibrary(libraryId) if (!library) { return NextResponse.json({ error: 'Library not found' }, { status: 404 }) @@ -39,7 +43,10 @@ export function GET(request: NextRequest) { return NextResponse.json(series) } -export function DELETE(request: NextRequest) { +export async function DELETE(request: NextRequest) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + const { searchParams } = request.nextUrl const libraryId = searchParams.get('libraryId') const seriesId = searchParams.get('seriesId') diff --git a/src/app/api/users/[id]/permissions/route.ts b/src/app/api/users/[id]/permissions/route.ts new file mode 100644 index 0000000..7c3f021 --- /dev/null +++ b/src/app/api/users/[id]/permissions/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireAdmin } from '@/lib/auth' +import { getUserById, getPermittedLibraryIds, setLibraryPermissions } from '@/lib/users' +import { getLibraries } from '@/lib/libraries' + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + + const { id } = await params + + const user = getUserById(id) + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + const libraryIds = getPermittedLibraryIds(id) + return NextResponse.json({ libraryIds }) +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + + const { id } = await params + + const user = getUserById(id) + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + let body: { libraryIds?: unknown } + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + if (!Array.isArray(body.libraryIds) || !body.libraryIds.every((id) => typeof id === 'string')) { + return NextResponse.json({ error: 'libraryIds must be an array of strings' }, { status: 400 }) + } + + const allLibraries = getLibraries() + const validIds = new Set(allLibraries.map((l) => l.id)) + const invalid = body.libraryIds.filter((id) => !validIds.has(id)) + if (invalid.length > 0) { + return NextResponse.json({ error: `Unknown library IDs: ${invalid.join(', ')}` }, { status: 400 }) + } + + setLibraryPermissions(id, body.libraryIds) + return new NextResponse(null, { status: 204 }) +} diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts new file mode 100644 index 0000000..0d62a99 --- /dev/null +++ b/src/app/api/users/[id]/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireAdmin } from '@/lib/auth' +import { getUserById, deleteUser, listUsers } from '@/lib/users' + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + const { session } = auth + + const { id } = await params + + if (id === session.userId) { + return NextResponse.json({ error: 'Cannot delete your own account' }, { status: 409 }) + } + + const target = getUserById(id) + if (!target) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + if (target.role === 'admin') { + const admins = listUsers().filter((u) => u.role === 'admin') + if (admins.length <= 1) { + return NextResponse.json({ error: 'Cannot delete the last admin account' }, { status: 409 }) + } + } + + deleteUser(id) + return new NextResponse(null, { status: 204 }) +} diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts new file mode 100644 index 0000000..52c5c1c --- /dev/null +++ b/src/app/api/users/route.ts @@ -0,0 +1,10 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireAdmin } from '@/lib/auth' +import { listUsers } from '@/lib/users' + +export async function GET(request: NextRequest) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + + return NextResponse.json(listUsers()) +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e8f207a..25a260e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next' -import NavLink from '@/components/NavLink' +import { getServerSession } from '@/lib/auth' +import HeaderNav from '@/components/HeaderNav' import './globals.css' export const metadata: Metadata = { @@ -7,11 +8,13 @@ export const metadata: Metadata = { description: 'Your personal media library', } -export default function RootLayout({ +export default async function RootLayout({ children, }: { children: React.ReactNode }) { + const session = await getServerSession() + return ( @@ -22,7 +25,9 @@ export default function RootLayout({ MediaLore diff --git a/src/app/library/[id]/page.tsx b/src/app/library/[id]/page.tsx index 15d0e9b..45179f5 100644 --- a/src/app/library/[id]/page.tsx +++ b/src/app/library/[id]/page.tsx @@ -1,5 +1,7 @@ import { getLibrary } from '@/lib/libraries' -import { notFound } from 'next/navigation' +import { notFound, redirect } from 'next/navigation' +import { getServerSession } from '@/lib/auth' +import { getPermittedLibraryIds } from '@/lib/users' import GamesView from '@/components/games/GamesView' import MixedView from '@/components/mixed/MixedView' import MoviesView from '@/components/movies/MoviesView' @@ -14,9 +16,17 @@ export default async function LibraryPage({ params, searchParams }: Props) { const { id } = await params const { path: subpath } = await searchParams + const session = await getServerSession() + if (!session.userId) redirect('/login') + const library = getLibrary(id) if (!library) notFound() + if (session.role !== 'admin') { + const permitted = getPermittedLibraryIds(session.userId) + if (!permitted.includes(id)) notFound() + } + return (
diff --git a/src/app/login/LoginForm.tsx b/src/app/login/LoginForm.tsx new file mode 100644 index 0000000..7379fc0 --- /dev/null +++ b/src/app/login/LoginForm.tsx @@ -0,0 +1,158 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' + +interface Props { + isFirstRun: boolean +} + +export default function LoginForm({ isFirstRun }: Props) { + const router = useRouter() + const searchParams = useSearchParams() + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const from = searchParams.get('from') ?? '/' + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError('') + + if (isFirstRun && password !== confirmPassword) { + setError('Passwords do not match') + return + } + + setLoading(true) + try { + if (isFirstRun) { + const res = await fetch('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password, role: 'admin' }), + }) + if (!res.ok) { + const data = await res.json() + setError(data.error ?? 'Registration failed') + return + } + } + + const loginRes = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }) + + if (!loginRes.ok) { + const data = await loginRes.json() + setError(data.error ?? 'Login failed') + return + } + + router.push(from) + router.refresh() + } finally { + setLoading(false) + } + } + + return ( +
+
+ +

+ MediaLore +

+

+ {isFirstRun ? 'Create your admin account to get started' : 'Sign in to your account'} +

+
+ +
+
+ + setUsername(e.target.value)} + required + autoFocus + autoComplete="username" + className="w-full px-3 py-2 rounded-lg border text-sm" + style={{ + backgroundColor: 'var(--surface)', + borderColor: 'var(--border)', + color: 'var(--text-primary)', + }} + /> +
+ +
+ + setPassword(e.target.value)} + required + autoComplete={isFirstRun ? 'new-password' : 'current-password'} + className="w-full px-3 py-2 rounded-lg border text-sm" + style={{ + backgroundColor: 'var(--surface)', + borderColor: 'var(--border)', + color: 'var(--text-primary)', + }} + /> +
+ + {isFirstRun && ( +
+ + setConfirmPassword(e.target.value)} + required + autoComplete="new-password" + className="w-full px-3 py-2 rounded-lg border text-sm" + style={{ + backgroundColor: 'var(--surface)', + borderColor: 'var(--border)', + color: 'var(--text-primary)', + }} + /> +
+ )} + + {error && ( +

+ {error} +

+ )} + + +
+
+ ) +} diff --git a/src/app/login/layout.tsx b/src/app/login/layout.tsx new file mode 100644 index 0000000..58fe75a --- /dev/null +++ b/src/app/login/layout.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from 'next' +import '../globals.css' + +export const metadata: Metadata = { + title: 'MediaLore — Sign In', +} + +export default function LoginLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ) +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..4ba92c4 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,14 @@ +import { Suspense } from 'react' +import { getUserCount } from '@/lib/users' +import LoginForm from './LoginForm' + +export const dynamic = 'force-dynamic' + +export default function LoginPage() { + const isFirstRun = getUserCount() === 0 + return ( + + + + ) +} diff --git a/src/app/manage/layout.tsx b/src/app/manage/layout.tsx index 78f525f..b75383b 100644 --- a/src/app/manage/layout.tsx +++ b/src/app/manage/layout.tsx @@ -1,6 +1,12 @@ +import { redirect } from 'next/navigation' +import { getServerSession } from '@/lib/auth' import ManageSubNav from '@/components/ManageSubNav' -export default function ManageLayout({ children }: { children: React.ReactNode }) { +export default async function ManageLayout({ children }: { children: React.ReactNode }) { + const session = await getServerSession() + if (!session.userId) redirect('/login') + if (session.role !== 'admin') redirect('/') + return (
diff --git a/src/app/manage/users/page.tsx b/src/app/manage/users/page.tsx new file mode 100644 index 0000000..3a5790e --- /dev/null +++ b/src/app/manage/users/page.tsx @@ -0,0 +1,436 @@ +'use client' + +import { useEffect, useState, useRef } from 'react' + +interface User { + id: string + username: string + role: 'admin' | 'user' + createdAt: number +} + +interface Library { + id: string + name: string + type: string +} + +// ─── Main Page ──────────────────────────────────────────────────────────────── + +export default function UsersPage() { + const [users, setUsers] = useState([]) + const [libraries, setLibraries] = useState([]) + const [loading, setLoading] = useState(true) + + const refresh = () => { + Promise.all([ + fetch('/api/users').then((r) => r.json()), + fetch('/api/libraries').then((r) => r.json()), + ]) + .then(([usersData, librariesData]) => { + setUsers(usersData) + setLibraries(librariesData) + setLoading(false) + }) + .catch(() => setLoading(false)) + } + + useEffect(() => { + refresh() + }, []) + + return ( +
+

+ Manage Users +

+

+ Create users and manage library access permissions. +

+ +
+ {loading ? ( + + ) : users.length === 0 ? ( +

+ No users found. +

+ ) : ( +
+ {users.map((user) => ( + + ))} +
+ )} +
+ +
+ +
+
+ ) +} + +// ─── Section ────────────────────────────────────────────────────────────────── + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+
+
{children}
+
+
+ ) +} + +// ─── User Row ───────────────────────────────────────────────────────────────── + +function UserRow({ + user, + libraries, + onChanged, +}: { + user: User + libraries: Library[] + onChanged: () => void +}) { + const [confirming, setConfirming] = useState(false) + const [removing, setRemoving] = useState(false) + const [showPermissions, setShowPermissions] = useState(false) + const cancelRef = useRef | null>(null) + + const handleDeleteClick = () => { + if (!confirming) { + setConfirming(true) + cancelRef.current = setTimeout(() => setConfirming(false), 4000) + return + } + if (cancelRef.current) clearTimeout(cancelRef.current) + setRemoving(true) + fetch(`/api/users/${encodeURIComponent(user.id)}`, { method: 'DELETE' }) + .then(async (r) => { + if (!r.ok) { + const data = await r.json() + alert(data.error ?? 'Failed to delete user') + setRemoving(false) + setConfirming(false) + } else { + onChanged() + } + }) + .catch(() => { + setRemoving(false) + setConfirming(false) + }) + } + + const handleCancel = () => { + if (cancelRef.current) clearTimeout(cancelRef.current) + setConfirming(false) + } + + return ( +
+
+
+ + {user.username} + + + {user.role} + +
+
+ {user.role === 'user' && ( + + )} + {confirming ? ( + <> + + Sure? + + + + + ) : ( + + )} +
+
+ + {showPermissions && user.role === 'user' && ( + + )} +
+ ) +} + +// ─── Permissions Panel ──────────────────────────────────────────────────────── + +function PermissionsPanel({ userId, libraries }: { userId: string; libraries: Library[] }) { + const [permitted, setPermitted] = useState([]) + const [saving, setSaving] = useState(false) + const [loaded, setLoaded] = useState(false) + + useEffect(() => { + fetch(`/api/users/${encodeURIComponent(userId)}/permissions`) + .then((r) => r.json()) + .then((data: { libraryIds: string[] }) => { + setPermitted(data.libraryIds) + setLoaded(true) + }) + }, [userId]) + + const toggle = (libraryId: string) => { + setPermitted((prev) => + prev.includes(libraryId) ? prev.filter((id) => id !== libraryId) : [...prev, libraryId] + ) + } + + const save = async () => { + setSaving(true) + await fetch(`/api/users/${encodeURIComponent(userId)}/permissions`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ libraryIds: permitted }), + }) + setSaving(false) + } + + if (!loaded) { + return ( +
+

Loading…

+
+ ) + } + + return ( +
+

+ Library Access +

+ {libraries.length === 0 ? ( +

No libraries configured.

+ ) : ( +
+ {libraries.map((lib) => ( + + ))} +
+ )} + +
+ ) +} + +// ─── Add User Form ──────────────────────────────────────────────────────────── + +function AddUserForm({ onAdded }: { onAdded: () => void }) { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [role, setRole] = useState<'user' | 'admin'>('user') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + setLoading(true) + try { + const res = await fetch('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password, role }), + }) + if (!res.ok) { + const data = await res.json() + setError(data.error ?? 'Failed to create user') + return + } + setUsername('') + setPassword('') + setRole('user') + onAdded() + } finally { + setLoading(false) + } + } + + return ( +
+
+
+ + setUsername(e.target.value)} + required + className="w-full px-3 py-2 rounded-lg border text-sm" + style={{ + backgroundColor: 'var(--background)', + borderColor: 'var(--border)', + color: 'var(--text-primary)', + }} + /> +
+
+ + setPassword(e.target.value)} + required + autoComplete="new-password" + className="w-full px-3 py-2 rounded-lg border text-sm" + style={{ + backgroundColor: 'var(--background)', + borderColor: 'var(--border)', + color: 'var(--text-primary)', + }} + /> +
+
+ +
+ + +
+ + {error && ( +

+ {error} +

+ )} + + +
+ ) +} + +// ─── Loading skeleton ───────────────────────────────────────────────────────── + +function LoadingRows() { + return ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+ ))} +
+ ) +} diff --git a/src/app/page.tsx b/src/app/page.tsx index ae13cce..6a4398d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,15 +1,30 @@ export const dynamic = 'force-dynamic' import { redirect } from 'next/navigation' -import { getLibraries } from '@/lib/libraries' +import { getServerSession } from '@/lib/auth' +import { getLibrariesForUser } from '@/lib/users' import LibraryCard from '@/components/LibraryCard' -import Link from 'next/link' -export default function HomePage() { - const libraries = getLibraries() +export default async function HomePage() { + const session = await getServerSession() + if (!session.userId) redirect('/login') + + const libraries = getLibrariesForUser(session.userId, session.role) if (libraries.length === 0) { - redirect('/manage') + if (session.role === 'admin') { + redirect('/manage') + } + return ( +
+

+ No libraries available +

+

+ An administrator needs to grant you access to libraries. +

+
+ ) } return ( @@ -20,7 +35,7 @@ export default function HomePage() { Libraries

- {libraries.length} {libraries.length === 1 ? 'library' : 'libraries'} configured + {libraries.length} {libraries.length === 1 ? 'library' : 'libraries'} available

diff --git a/src/components/HeaderNav.tsx b/src/components/HeaderNav.tsx new file mode 100644 index 0000000..f9fafd0 --- /dev/null +++ b/src/components/HeaderNav.tsx @@ -0,0 +1,43 @@ +'use client' + +import { useRouter } from 'next/navigation' +import NavLink from './NavLink' + +interface Props { + username: string + isAdmin: boolean +} + +export default function HeaderNav({ username, isAdmin }: Props) { + const router = useRouter() + + async function handleLogout() { + await fetch('/api/auth/logout', { method: 'POST' }) + router.push('/login') + router.refresh() + } + + return ( +
+ {isAdmin && Manage} + + {username} + + +
+ ) +} diff --git a/src/components/ManageSubNav.tsx b/src/components/ManageSubNav.tsx index a5dbafa..88f9048 100644 --- a/src/components/ManageSubNav.tsx +++ b/src/components/ManageSubNav.tsx @@ -6,6 +6,7 @@ import { usePathname } from 'next/navigation' const TABS = [ { href: '/manage', label: 'Libraries' }, { href: '/manage/tags', label: 'Tags' }, + { href: '/manage/users', label: 'Users' }, ] export default function ManageSubNav() { diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..4c35da8 --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,6 @@ +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + const { initializeSecret } = await import('./lib/secret') + initializeSecret() + } +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..2411436 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,112 @@ +import { getIronSession, type IronSession, type SessionOptions } from 'iron-session' +import { NextRequest, NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import crypto from 'crypto' + +export interface SessionData { + userId: string + username: string + role: 'admin' | 'user' +} + +export function getSessionOptions(): SessionOptions { + const secret = process.env.SESSION_SECRET + if (!secret) throw new Error('SESSION_SECRET is not set') + return { + password: secret, + cookieName: 'ml_session', + ttl: 60 * 60 * 24 * 30, // 30 days + cookieOptions: { + secure: process.env.NODE_ENV === 'production', + httpOnly: true, + sameSite: 'lax', + }, + } +} + +// For use in server components and layouts (Node.js runtime, read-only) +export async function getServerSession(): Promise> { + const cookieStore = await cookies() + return getIronSession(cookieStore, getSessionOptions()) +} + +// For use in API routes where the session must be written (login/logout) +// The caller is responsible for returning the response so Set-Cookie headers are sent +export async function getWritableSession( + req: NextRequest, + res: NextResponse +): Promise> { + return getIronSession(req, res, getSessionOptions()) +} + +// Password hashing using Node.js built-in crypto (Node.js runtime only) +export async function hashPassword(password: string): Promise { + const salt = crypto.randomBytes(16) + const derivedKey = await new Promise((resolve, reject) => { + crypto.scrypt(password, salt, 64, { N: 16384, r: 8, p: 1 }, (err, key) => { + if (err) reject(err) + else resolve(key) + }) + }) + return `scrypt:${salt.toString('hex')}:${derivedKey.toString('hex')}` +} + +export async function verifyPassword(password: string, hash: string): Promise { + const parts = hash.split(':') + if (parts.length !== 3 || parts[0] !== 'scrypt') return false + const salt = Buffer.from(parts[1], 'hex') + const storedKey = Buffer.from(parts[2], 'hex') + const derivedKey = await new Promise((resolve, reject) => { + crypto.scrypt(password, salt, 64, { N: 16384, r: 8, p: 1 }, (err, key) => { + if (err) reject(err) + else resolve(key) + }) + }) + if (derivedKey.length !== storedKey.length) return false + return crypto.timingSafeEqual(derivedKey, storedKey) +} + +// Auth guard result type +type AuthSuccess = { session: IronSession } +type AuthResult = AuthSuccess | NextResponse + +// Read-only session from an API route request (throwaway response) +async function getReadonlySession(req: NextRequest): Promise> { + const res = new NextResponse() + return getIronSession(req, res, getSessionOptions()) +} + +export async function requireAuth(req: NextRequest): Promise { + const session = await getReadonlySession(req) + if (!session.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + return { session } +} + +export async function requireAdmin(req: NextRequest): Promise { + const session = await getReadonlySession(req) + if (!session.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + if (session.role !== 'admin') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + return { session } +} + +export async function requireLibraryAccess(req: NextRequest, libraryId: string): Promise { + const session = await getReadonlySession(req) + if (!session.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + if (session.role === 'admin') return { session } + + // Lazy import to avoid pulling DB into edge contexts + const { getPermittedLibraryIds } = await import('./users') + const permitted = getPermittedLibraryIds(session.userId) + if (!permitted.includes(libraryId)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + return { session } +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 1ae1da4..5b8724c 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -44,6 +44,20 @@ function initDb(db: Database.Database): void { type TEXT NOT NULL CHECK(type IN ('games', 'mixed', 'movies', 'tv')), cover_ext TEXT NULL ); + + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('admin', 'user')), + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS library_permissions ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, library_id) + ); `) migrateLibrariesType(db) diff --git a/src/lib/secret.ts b/src/lib/secret.ts new file mode 100644 index 0000000..0a66b78 --- /dev/null +++ b/src/lib/secret.ts @@ -0,0 +1,25 @@ +import path from 'path' +import fs from 'fs' +import crypto from 'crypto' + +const CONFIG_PATH = process.env.CONFIG_PATH ?? process.cwd() +const SECRET_FILE = path.resolve(CONFIG_PATH, '.session_secret') + +export function initializeSecret(): void { + if (process.env.SESSION_SECRET) return + + if (fs.existsSync(SECRET_FILE)) { + process.env.SESSION_SECRET = fs.readFileSync(SECRET_FILE, 'utf8').trim() + return + } + + const secret = crypto.randomBytes(32).toString('hex') + fs.writeFileSync(SECRET_FILE, secret, { mode: 0o600 }) + process.env.SESSION_SECRET = secret +} + +export function getSessionSecret(): string { + const secret = process.env.SESSION_SECRET + if (!secret) throw new Error('SESSION_SECRET is not set — call initializeSecret() at startup') + return secret +} diff --git a/src/lib/users.ts b/src/lib/users.ts new file mode 100644 index 0000000..63eacc4 --- /dev/null +++ b/src/lib/users.ts @@ -0,0 +1,119 @@ +import { getDb } from './db' +import { getLibraries } from './libraries' +import type { Library } from '@/types' + +export interface User { + id: string + username: string + role: 'admin' | 'user' + createdAt: number +} + +export interface UserWithHash extends User { + passwordHash: string +} + +export function getUserCount(): number { + const db = getDb() + const row = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number } + return row.count +} + +export function getUserByUsername(username: string): UserWithHash | undefined { + const db = getDb() + const row = db + .prepare('SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?') + .get(username) as { id: string; username: string; password_hash: string; role: string; created_at: number } | undefined + if (!row) return undefined + return { + id: row.id, + username: row.username, + passwordHash: row.password_hash, + role: row.role as 'admin' | 'user', + createdAt: row.created_at, + } +} + +export function getUserById(id: string): User | undefined { + const db = getDb() + const row = db + .prepare('SELECT id, username, role, created_at FROM users WHERE id = ?') + .get(id) as { id: string; username: string; role: string; created_at: number } | undefined + if (!row) return undefined + return { + id: row.id, + username: row.username, + role: row.role as 'admin' | 'user', + createdAt: row.created_at, + } +} + +export function createUser(username: string, passwordHash: string, role: 'admin' | 'user'): User { + const db = getDb() + const id = crypto.randomUUID() + const now = Math.floor(Date.now() / 1000) + db.prepare( + 'INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)' + ).run(id, username, passwordHash, role, now) + return { id, username, role, createdAt: now } +} + +export function deleteUser(id: string): boolean { + const db = getDb() + const result = db.prepare('DELETE FROM users WHERE id = ?').run(id) + return result.changes > 0 +} + +export function listUsers(): User[] { + const db = getDb() + const rows = db + .prepare('SELECT id, username, role, created_at FROM users ORDER BY created_at ASC') + .all() as { id: string; username: string; role: string; created_at: number }[] + return rows.map((r) => ({ + id: r.id, + username: r.username, + role: r.role as 'admin' | 'user', + createdAt: r.created_at, + })) +} + +export function getPermittedLibraryIds(userId: string): string[] { + const db = getDb() + const rows = db + .prepare('SELECT library_id FROM library_permissions WHERE user_id = ?') + .all(userId) as { library_id: string }[] + return rows.map((r) => r.library_id) +} + +export function setLibraryPermissions(userId: string, libraryIds: string[]): void { + const db = getDb() + const tx = db.transaction(() => { + db.prepare('DELETE FROM library_permissions WHERE user_id = ?').run(userId) + const insert = db.prepare('INSERT INTO library_permissions (user_id, library_id) VALUES (?, ?)') + for (const libraryId of libraryIds) { + insert.run(userId, libraryId) + } + }) + tx() +} + +export function getLibrariesForUser(userId: string, role: 'admin' | 'user'): Library[] { + if (role === 'admin') return getLibraries() + const db = getDb() + const rows = db + .prepare( + `SELECT l.id, l.name, l.path, l.type, l.cover_ext + FROM libraries l + INNER JOIN library_permissions lp ON lp.library_id = l.id + WHERE lp.user_id = ? + ORDER BY l.name ASC` + ) + .all(userId) as { id: string; name: string; path: string; type: string; cover_ext: string | null }[] + return rows.map((r) => ({ + id: r.id, + name: r.name, + path: r.path, + type: r.type as Library['type'], + coverExt: r.cover_ext, + })) +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..d639e77 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getIronSession } from 'iron-session' +import { getSessionOptions, type SessionData } from '@/lib/auth' + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], +} + +export async function middleware(request: NextRequest) { + const { pathname } = request.nextUrl + + if (pathname === '/login' || pathname.startsWith('/api/auth/')) { + return NextResponse.next() + } + + const response = NextResponse.next() + const session = await getIronSession(request, response, getSessionOptions()) + + if (!session.userId) { + const loginUrl = new URL('/login', request.url) + if (pathname !== '/') { + loginUrl.searchParams.set('from', pathname) + } + return NextResponse.redirect(loginUrl) + } + + return response +} -- 2.49.1 From 5b5503b7a6d8a4cc42224d3c09c68dfda0fd8f2a Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:15:08 -0400 Subject: [PATCH 2/2] add user settings --- src/app/api/settings/route.ts | 39 ++++++ src/app/settings/SettingsForm.tsx | 136 +++++++++++++++++++++ src/app/settings/page.tsx | 27 ++++ src/components/HeaderNav.tsx | 11 +- src/components/mixed/VideoPlayerModal.tsx | 20 +-- src/components/movies/MovieDetailModal.tsx | 1 + src/components/tv/TvView.tsx | 1 + src/hooks/useUserSettings.ts | 31 +++++ src/lib/db.ts | 13 ++ src/lib/settings.ts | 81 ++++++++++++ src/types/index.ts | 12 ++ 11 files changed, 363 insertions(+), 9 deletions(-) create mode 100644 src/app/api/settings/route.ts create mode 100644 src/app/settings/SettingsForm.tsx create mode 100644 src/app/settings/page.tsx create mode 100644 src/hooks/useUserSettings.ts create mode 100644 src/lib/settings.ts diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts new file mode 100644 index 0000000..ad18e5b --- /dev/null +++ b/src/app/api/settings/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireAuth } from '@/lib/auth' +import { getUserSettings, updateUserSettings } from '@/lib/settings' +import type { UserSettings } from '@/types' + +export async function GET(req: NextRequest) { + const auth = await requireAuth(req) + if (auth instanceof NextResponse) return auth + + const settings = getUserSettings(auth.session.userId) + return NextResponse.json(settings) +} + +export async function PUT(req: NextRequest) { + const auth = await requireAuth(req) + if (auth instanceof NextResponse) return auth + + let body: unknown + try { + body = await req.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + } + + const s = body as Record + const boolFields: (keyof UserSettings)[] = [ + 'mixedAutoplay', 'mixedLoop', 'mixedMuted', + 'moviesAutoplay', 'moviesLoop', 'moviesMuted', + 'tvAutoplay', 'tvLoop', 'tvMuted', + ] + for (const field of boolFields) { + if (typeof s[field] !== 'boolean') { + return NextResponse.json({ error: `Invalid value for ${field}` }, { status: 400 }) + } + } + + updateUserSettings(auth.session.userId, s as unknown as UserSettings) + return NextResponse.json({ ok: true }) +} diff --git a/src/app/settings/SettingsForm.tsx b/src/app/settings/SettingsForm.tsx new file mode 100644 index 0000000..96a25c4 --- /dev/null +++ b/src/app/settings/SettingsForm.tsx @@ -0,0 +1,136 @@ +'use client' + +import { useState } from 'react' +import type { UserSettings } from '@/types' + +interface Props { + initialSettings: UserSettings +} + +interface ToggleProps { + label: string + checked: boolean + onChange: (v: boolean) => void +} + +function Toggle({ label, checked, onChange }: ToggleProps) { + return ( + + ) +} + +interface SectionProps { + title: string + icon: string + children: React.ReactNode +} + +function SettingsSection({ title, icon, children }: SectionProps) { + return ( +
+

+ {icon} + {title} +

+
+ {children} +
+
+ ) +} + +export default function SettingsForm({ initialSettings }: Props) { + const [settings, setSettings] = useState(initialSettings) + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [error, setError] = useState(null) + + function set(key: K, value: boolean) { + setSettings((prev) => ({ ...prev, [key]: value })) + setSaved(false) + } + + async function handleSave() { + setSaving(true) + setError(null) + try { + const res = await fetch('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + }) + if (!res.ok) throw new Error('Failed to save') + setSaved(true) + } catch { + setError('Failed to save settings. Please try again.') + } finally { + setSaving(false) + } + } + + return ( +
+ + set('mixedAutoplay', v)} /> + set('mixedLoop', v)} /> + set('mixedMuted', v)} /> + + + + set('moviesAutoplay', v)} /> + set('moviesLoop', v)} /> + set('moviesMuted', v)} /> + + + + set('tvAutoplay', v)} /> + set('tvLoop', v)} /> + set('tvMuted', v)} /> + + +
+ + {saved && ( + + Saved + + )} + {error && ( + + {error} + + )} +
+
+ ) +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx new file mode 100644 index 0000000..5fbede2 --- /dev/null +++ b/src/app/settings/page.tsx @@ -0,0 +1,27 @@ +import { redirect } from 'next/navigation' +import { getServerSession } from '@/lib/auth' +import { getUserSettings } from '@/lib/settings' +import SettingsForm from './SettingsForm' + +export default async function SettingsPage() { + const session = await getServerSession() + if (!session.userId) redirect('/login') + + const settings = getUserSettings(session.userId) + + return ( +
+

+ Settings +

+

+ Signed in as {session.username} +

+ +

+ Video Playback +

+ +
+ ) +} diff --git a/src/components/HeaderNav.tsx b/src/components/HeaderNav.tsx index f9fafd0..80f4e76 100644 --- a/src/components/HeaderNav.tsx +++ b/src/components/HeaderNav.tsx @@ -1,6 +1,7 @@ 'use client' import { useRouter } from 'next/navigation' +import Link from 'next/link' import NavLink from './NavLink' interface Props { @@ -20,9 +21,15 @@ export default function HeaderNav({ username, isAdmin }: Props) { return (
{isAdmin && Manage} - + ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')} + onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')} + > {username} - +