241 lines
7.8 KiB
TypeScript
241 lines
7.8 KiB
TypeScript
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<string, Promise<void>>();
|
|
|
|
async function withClassroomLock<T>(id: string, fn: () => Promise<T>): Promise<T> {
|
|
const previous = classroomLocks.get(id) ?? Promise.resolve();
|
|
let release: () => void;
|
|
const next = new Promise<void>((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<PersistedClassroomData | null> {
|
|
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<PersistedClassroomData & { url: string }> {
|
|
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<PersistedClassroomData> {
|
|
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<boolean> {
|
|
const filePath = classroomFilePath(id);
|
|
return withClassroomLock(id, async () => {
|
|
try {
|
|
await fs.rm(filePath, { force: true });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
}
|