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

View File

@@ -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 (
<html lang="en">
<body className="min-h-screen">
@@ -22,7 +25,9 @@ export default function RootLayout({
MediaLore
</a>
<nav className="flex items-center gap-1">
<NavLink href="/manage">Manage</NavLink>
{session.userId && (
<HeaderNav username={session.username} isAdmin={session.role === 'admin'} />
)}
</nav>
</div>
</header>

View File

@@ -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 (
<div>
<div className="flex items-center gap-2 mb-6">

158
src/app/login/LoginForm.tsx Normal file
View File

@@ -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 (
<div className="w-full max-w-sm px-6">
<div className="mb-8 text-center">
<span style={{ color: 'var(--accent)', fontSize: '2rem' }}></span>
<h1 className="mt-2 text-2xl font-semibold" style={{ color: 'var(--text-primary)' }}>
MediaLore
</h1>
<p className="text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
{isFirstRun ? 'Create your admin account to get started' : 'Sign in to your account'}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-primary)' }}>
Username
</label>
<input
type="text"
value={username}
onChange={(e) => 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)',
}}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-primary)' }}>
Password
</label>
<input
type="password"
value={password}
onChange={(e) => 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)',
}}
/>
</div>
{isFirstRun && (
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-primary)' }}>
Confirm Password
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => 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)',
}}
/>
</div>
)}
{error && (
<p className="text-sm" style={{ color: 'var(--error, #ef4444)' }}>
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2 px-4 rounded-lg text-sm font-medium transition-opacity"
style={{
backgroundColor: 'var(--accent)',
color: 'var(--background)',
opacity: loading ? 0.6 : 1,
}}
>
{loading ? 'Please wait…' : isFirstRun ? 'Create Account' : 'Sign In'}
</button>
</form>
</div>
)
}

16
src/app/login/layout.tsx Normal file
View File

@@ -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 (
<html lang="en">
<body className="min-h-screen flex items-center justify-center">
{children}
</body>
</html>
)
}

14
src/app/login/page.tsx Normal file
View File

@@ -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 (
<Suspense>
<LoginForm isFirstRun={isFirstRun} />
</Suspense>
)
}

View File

@@ -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 (
<div>
<ManageSubNav />

View File

@@ -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<User[]>([])
const [libraries, setLibraries] = useState<Library[]>([])
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 (
<div className="max-w-2xl">
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
Manage Users
</h1>
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
Create users and manage library access permissions.
</p>
<Section title="Users">
{loading ? (
<LoadingRows />
) : users.length === 0 ? (
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
No users found.
</p>
) : (
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
{users.map((user) => (
<UserRow key={user.id} user={user} libraries={libraries} onChanged={refresh} />
))}
</div>
)}
</Section>
<Section title="Add a User">
<AddUserForm onAdded={refresh} />
</Section>
</div>
)
}
// ─── Section ──────────────────────────────────────────────────────────────────
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-10">
<h2
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--text-secondary)' }}
>
{title}
</h2>
<div
className="rounded-xl border"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
>
<div className="px-5 py-4">{children}</div>
</div>
</div>
)
}
// ─── 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<ReturnType<typeof setTimeout> | 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 (
<div>
<div className="flex items-center justify-between gap-3 py-3">
<div className="flex items-center gap-3 min-w-0">
<span className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>
{user.username}
</span>
<span
className="text-xs px-2 py-0.5 rounded-full font-medium"
style={{
backgroundColor: user.role === 'admin' ? 'var(--accent)' : 'var(--surface-raised, var(--border))',
color: user.role === 'admin' ? 'var(--background)' : 'var(--text-secondary)',
}}
>
{user.role}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{user.role === 'user' && (
<button
onClick={() => setShowPermissions((v) => !v)}
className="text-xs px-3 py-1.5 rounded-lg border transition-colors"
style={{
borderColor: 'var(--border)',
color: 'var(--text-secondary)',
}}
>
{showPermissions ? 'Hide' : 'Libraries'}
</button>
)}
{confirming ? (
<>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
Sure?
</span>
<button
onClick={handleDeleteClick}
disabled={removing}
className="text-xs px-3 py-1.5 rounded-lg transition-colors"
style={{ color: '#ef4444' }}
>
{removing ? 'Deleting…' : 'Yes, Delete'}
</button>
<button
onClick={handleCancel}
className="text-xs px-3 py-1.5 rounded-lg transition-colors"
style={{ color: 'var(--text-secondary)' }}
>
Cancel
</button>
</>
) : (
<button
onClick={handleDeleteClick}
className="text-xs px-3 py-1.5 rounded-lg transition-colors"
style={{ color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.color = '#ef4444'
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
>
Delete
</button>
)}
</div>
</div>
{showPermissions && user.role === 'user' && (
<PermissionsPanel userId={user.id} libraries={libraries} />
)}
</div>
)
}
// ─── Permissions Panel ────────────────────────────────────────────────────────
function PermissionsPanel({ userId, libraries }: { userId: string; libraries: Library[] }) {
const [permitted, setPermitted] = useState<string[]>([])
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 (
<div className="pb-3 pl-2">
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>Loading</p>
</div>
)
}
return (
<div
className="mb-3 ml-2 p-3 rounded-lg border"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--background)' }}
>
<p className="text-xs font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>
Library Access
</p>
{libraries.length === 0 ? (
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>No libraries configured.</p>
) : (
<div className="space-y-1.5">
{libraries.map((lib) => (
<label key={lib.id} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={permitted.includes(lib.id)}
onChange={() => toggle(lib.id)}
className="rounded"
/>
<span className="text-sm" style={{ color: 'var(--text-primary)' }}>
{lib.name}
</span>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
({lib.type})
</span>
</label>
))}
</div>
)}
<button
onClick={save}
disabled={saving}
className="mt-3 text-xs px-3 py-1.5 rounded-lg transition-opacity"
style={{
backgroundColor: 'var(--accent)',
color: 'var(--background)',
opacity: saving ? 0.6 : 1,
}}
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
)
}
// ─── 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 (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
Username
</label>
<input
type="text"
value={username}
onChange={(e) => 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)',
}}
/>
</div>
<div>
<label className="block text-xs font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
Password
</label>
<input
type="password"
value={password}
onChange={(e) => 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)',
}}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
Role
</label>
<select
value={role}
onChange={(e) => setRole(e.target.value as 'user' | 'admin')}
className="w-full px-3 py-2 rounded-lg border text-sm"
style={{
backgroundColor: 'var(--background)',
borderColor: 'var(--border)',
color: 'var(--text-primary)',
}}
>
<option value="user">User access only permitted libraries</option>
<option value="admin">Admin access all libraries and settings</option>
</select>
</div>
{error && (
<p className="text-sm" style={{ color: '#ef4444' }}>
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="text-sm px-4 py-2 rounded-lg font-medium transition-opacity"
style={{
backgroundColor: 'var(--accent)',
color: 'var(--background)',
opacity: loading ? 0.6 : 1,
}}
>
{loading ? 'Creating…' : 'Create User'}
</button>
</form>
)
}
// ─── Loading skeleton ─────────────────────────────────────────────────────────
function LoadingRows() {
return (
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
{[1, 2, 3].map((i) => (
<div key={i} className="py-3 flex items-center gap-3">
<div
className="h-4 rounded animate-pulse"
style={{ width: '120px', backgroundColor: 'var(--border)' }}
/>
<div
className="h-4 rounded animate-pulse"
style={{ width: '50px', backgroundColor: 'var(--border)' }}
/>
</div>
))}
</div>
)
}

View File

@@ -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 (
<div className="text-center py-16">
<p className="text-lg font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
No libraries available
</p>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
An administrator needs to grant you access to libraries.
</p>
</div>
)
}
return (
@@ -20,7 +35,7 @@ export default function HomePage() {
Libraries
</h1>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{libraries.length} {libraries.length === 1 ? 'library' : 'libraries'} configured
{libraries.length} {libraries.length === 1 ? 'library' : 'libraries'} available
</p>
</div>
</div>

View File

@@ -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 (
<div className="flex items-center gap-3">
{isAdmin && <NavLink href="/manage">Manage</NavLink>}
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{username}
</span>
<button
onClick={handleLogout}
className="text-sm px-3 py-1.5 rounded-lg transition-colors"
style={{ color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'transparent'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
>
Sign Out
</button>
</div>
)
}

View File

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

6
src/instrumentation.ts Normal file
View File

@@ -0,0 +1,6 @@
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { initializeSecret } = await import('./lib/secret')
initializeSecret()
}
}

112
src/lib/auth.ts Normal file
View File

@@ -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<IronSession<SessionData>> {
const cookieStore = await cookies()
return getIronSession<SessionData>(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<IronSession<SessionData>> {
return getIronSession<SessionData>(req, res, getSessionOptions())
}
// Password hashing using Node.js built-in crypto (Node.js runtime only)
export async function hashPassword(password: string): Promise<string> {
const salt = crypto.randomBytes(16)
const derivedKey = await new Promise<Buffer>((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<boolean> {
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<Buffer>((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<SessionData> }
type AuthResult = AuthSuccess | NextResponse
// Read-only session from an API route request (throwaway response)
async function getReadonlySession(req: NextRequest): Promise<IronSession<SessionData>> {
const res = new NextResponse()
return getIronSession<SessionData>(req, res, getSessionOptions())
}
export async function requireAuth(req: NextRequest): Promise<AuthResult> {
const session = await getReadonlySession(req)
if (!session.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return { session }
}
export async function requireAdmin(req: NextRequest): Promise<AuthResult> {
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<AuthResult> {
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 }
}

View File

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

25
src/lib/secret.ts Normal file
View File

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

119
src/lib/users.ts Normal file
View File

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

28
src/middleware.ts Normal file
View File

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