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

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