This commit is contained in:
Garret Patti
2026-04-05 17:44:24 -04:00
parent f0666c0649
commit eecee9bc5f
41 changed files with 1405 additions and 28 deletions

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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<SessionData>(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 })
}
}

View File

@@ -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 })

View File

@@ -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<string, string> = {
'.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 })

View File

@@ -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')

View File

@@ -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 })

View File

@@ -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)

View File

@@ -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()

View File

@@ -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) {

View File

@@ -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')

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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))
}

View File

@@ -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 })

View File

@@ -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')

View File

@@ -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 })
}

View File

@@ -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 })
}

View File

@@ -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())
}