import { promises as fs } from 'fs'; import path from 'path'; import type { NextRequest } from 'next/server'; import type { Scene, Stage } from '@/lib/types/stage'; import { shouldTrustAccessCodeProxyHeaders } from '@/lib/server/access-code-rate-limit'; import { normalizePrincipalSubjectId, normalizeResourceOwnerBinding, ResourceOwnerBindingError, type ResourceOwnerBinding, } from '@/lib/server/authz/owner-binding'; export const CLASSROOMS_DIR = process.env.CLASSROOM_DATA_DIR ?? path.join(process.env.LEARNING_DATA_DIR ?? path.join(process.cwd(), 'data'), 'classrooms'); export const CLASSROOM_JOBS_DIR = process.env.CLASSROOM_JOBS_DIR ?? path.join(process.env.LEARNING_DATA_DIR ?? path.join(process.cwd(), 'data'), 'classroom-jobs'); async function ensureDir(dir: string) { await fs.mkdir(dir, { recursive: true }); } export async function ensureClassroomsDir() { await ensureDir(CLASSROOMS_DIR); } export async function ensureClassroomJobsDir() { await ensureDir(CLASSROOM_JOBS_DIR); } export async function writeJsonFileAtomic(filePath: string, data: unknown) { const dir = path.dirname(filePath); await ensureDir(dir); const tempFilePath = `${filePath}.${process.pid}.${Date.now()}.tmp`; const content = JSON.stringify(data, null, 2); await fs.writeFile(tempFilePath, content, 'utf-8'); await fs.rename(tempFilePath, filePath); } export function buildRequestOrigin(req: NextRequest): string { if (shouldTrustAccessCodeProxyHeaders()) { const forwardedHost = req.headers.get('x-forwarded-host')?.split(',')[0]?.trim(); const forwardedProto = req.headers.get('x-forwarded-proto')?.split(',')[0]?.trim(); if (forwardedHost && (forwardedProto === 'http' || forwardedProto === 'https')) { try { return new URL(`${forwardedProto}://${forwardedHost}`).origin; } catch { // Fall through to the request URL when trusted proxy metadata is malformed. } } } return req.nextUrl?.origin ?? (req.url ? new URL(req.url).origin : ''); } export interface PersistedClassroomData { id: string; /** Permanent account owner. Missing only for legacy/shadow-mode classrooms. */ ownerPrincipalId?: string; /** Temporary device guest subject, removable only through explicit migration. */ guestPrincipalId?: string; ownershipBoundAt?: string; ownershipMigratedAt?: string; stage: Stage; scenes: Scene[]; createdAt: string; } export interface PersistClassroomOwnership extends ResourceOwnerBinding { ownershipBoundAt?: string; ownershipMigratedAt?: string; } const classroomLocks = new Map>(); async function withClassroomLock(id: string, fn: () => Promise): Promise { const previous = classroomLocks.get(id) ?? Promise.resolve(); let release: () => void; const next = new Promise((resolve) => { release = resolve; }); classroomLocks.set(id, next); try { await previous; return await fn(); } finally { release!(); if (classroomLocks.get(id) === next) classroomLocks.delete(id); } } export function isValidClassroomId(id: unknown): id is string { return typeof id === 'string' && /^[a-zA-Z0-9_-]+$/.test(id); } /** Resolve only a direct JSON child of CLASSROOMS_DIR. */ function classroomFilePath(id: unknown): string { if (!isValidClassroomId(id)) { throw new Error('Invalid classroom id'); } const root = path.resolve(CLASSROOMS_DIR); const filePath = path.resolve(root, `${id}.json`); if (path.dirname(filePath) !== root) { throw new Error('Invalid classroom path'); } return filePath; } export async function readClassroom(id: string): Promise { const filePath = classroomFilePath(id); try { const content = await fs.readFile(filePath, 'utf-8'); return JSON.parse(content) as PersistedClassroomData; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return null; } throw error; } } export async function persistClassroom( data: { id: string; stage: Stage; scenes: Scene[]; }, baseUrl: string, ownership?: PersistClassroomOwnership, ): Promise { const filePath = classroomFilePath(data.id); const normalizedOwnership = normalizeResourceOwnerBinding(ownership, 'A classroom'); const now = new Date().toISOString(); const hasOwnership = !!normalizedOwnership.ownerPrincipalId || !!normalizedOwnership.guestPrincipalId; const classroomData: PersistedClassroomData = { id: data.id, ...normalizedOwnership, ...(hasOwnership ? { ownershipBoundAt: ownership?.ownershipBoundAt ?? now } : {}), ...(ownership?.ownershipMigratedAt ? { ownershipMigratedAt: ownership.ownershipMigratedAt } : {}), stage: data.stage, scenes: data.scenes, createdAt: now, }; await withClassroomLock(data.id, async () => { await ensureClassroomsDir(); await writeJsonFileAtomic(filePath, classroomData); }); return { ...classroomData, url: `${baseUrl}/classroom/${data.id}`, }; } export interface MigrateClassroomOwnershipInput { ownerPrincipalId: string; expectedGuestPrincipalId?: string; allowLegacyUnowned?: boolean; } /** Compare-and-set migration from a guest/legacy classroom to its permanent account owner. */ export async function migrateClassroomOwnership( id: string, input: MigrateClassroomOwnershipInput, ): Promise { const filePath = classroomFilePath(id); const ownerPrincipalId = normalizePrincipalSubjectId( input.ownerPrincipalId, 'owner principal id', ); if (!ownerPrincipalId) throw new ResourceOwnerBindingError('Owner principal id is required'); const expectedGuestPrincipalId = normalizePrincipalSubjectId( input.expectedGuestPrincipalId, 'expected guest principal id', ); return withClassroomLock(id, async () => { const existing = await readClassroom(id); if (!existing) throw new Error(`Classroom not found: ${id}`); const hasOwner = Object.prototype.hasOwnProperty.call(existing, 'ownerPrincipalId'); const hasGuest = Object.prototype.hasOwnProperty.call(existing, 'guestPrincipalId'); if (hasOwner && hasGuest) { throw new ResourceOwnerBindingError('Classroom has conflicting ownership subjects'); } if (hasOwner) { const existingOwner = normalizePrincipalSubjectId( existing.ownerPrincipalId, 'stored owner principal id', ); if (existingOwner === ownerPrincipalId) return existing; throw new ResourceOwnerBindingError('Classroom already belongs to a different account'); } if (hasGuest) { const existingGuest = normalizePrincipalSubjectId( existing.guestPrincipalId, 'stored guest principal id', ); if (!expectedGuestPrincipalId || expectedGuestPrincipalId !== existingGuest) { throw new ResourceOwnerBindingError( 'Guest ownership changed or was not proven for this migration', ); } } else if (!input.allowLegacyUnowned) { throw new ResourceOwnerBindingError( 'Legacy ownerless classrooms require an explicit administrative migration', ); } const now = new Date().toISOString(); const { guestPrincipalId: _guestPrincipalId, ...withoutGuest } = existing; const updated: PersistedClassroomData = { ...withoutGuest, ownerPrincipalId, ownershipBoundAt: existing.ownershipBoundAt ?? now, ownershipMigratedAt: now, }; await writeJsonFileAtomic(filePath, updated); return updated; }); } /** Delete a persisted classroom (used by job cancel+delete). */ export async function deleteClassroom(id: string): Promise { const filePath = classroomFilePath(id); return withClassroomLock(id, async () => { try { await fs.rm(filePath, { force: true }); return true; } catch { return false; } }); }