Files
openmaic/OpenMAIC/app/api/coursewares/route.ts

145 lines
5.7 KiB
TypeScript

// Courseware registry API — L2 entry points.
//
// GET /api/coursewares list latest published coursewares
// POST /api/coursewares publish a frozen bundle (ops token)
//
// The publish endpoint accepts a multipart form:
// zip: File the frozen bundle ZIP (produced by the ops packager)
// coursewareId: string must equal the bundle's internal id
// version?: string explicit version (must match ZIP); when omitted the
// server allocates and stamps the next monotonic version
// status?: string 'published' | 'unpublished' (default 'published')
//
// Authorization: `Authorization: Bearer <COURSEWARE_PUBLISH_TOKEN>`. The route
// refuses to publish when the token is not configured (secure by default).
import { type NextRequest } from 'next/server';
import { apiError, apiSuccess, API_ERROR_CODES } from '@/lib/server/api-response';
import { buildRequestOrigin } from '@/lib/server/classroom-storage';
import { COURSEWARES_DIR, createFileCoursewareRepo } from '@/lib/courseware-repo/store';
import {
COURSEWARE_PUBLISH_TOKEN_ENV,
isPublishTokenConfigured,
publishCourseware,
toSummary,
verifyPublishToken,
} from '@/lib/courseware-repo';
import { createLogger } from '@/lib/logger';
import { getServerDeploymentRole, requireOpsAccess } from '@/lib/server/ops-access';
import { filterLearnerVisibleCoursewares } from '@/lib/courseware-repo/learner-visibility';
import { isValidCoursewareId, isValidCoursewareVersion } from '@/lib/courseware-repo/identity';
import {
CoursePublishTransportError,
resolveConfiguredCoursewarePublicBaseUrl,
} from '@/lib/server/course-publish-contract';
const log = createLogger('Courseware API');
const MAX_UPLOAD_BYTES = Number(process.env.COURSEWARE_MAX_UPLOAD_BYTES ?? 300 * 1024 * 1024);
function bearerToken(request: NextRequest): string | null {
const header = request.headers.get('authorization');
if (!header?.startsWith('Bearer ')) return null;
return header.slice('Bearer '.length).trim() || null;
}
export async function GET(_request: NextRequest) {
try {
const repo = createFileCoursewareRepo(COURSEWARES_DIR);
const visible = await filterLearnerVisibleCoursewares(
await repo.listRecords({ status: 'published' }),
);
const byCourseware = new Map<string, (typeof visible)[number]>();
for (const record of visible) {
const current = byCourseware.get(record.coursewareId);
if (!current || record.version > current.version)
byCourseware.set(record.coursewareId, record);
}
const latest = [...byCourseware.values()].sort((a, b) =>
a.publishedAt < b.publishedAt ? 1 : -1,
);
return apiSuccess({ items: latest.map(toSummary) });
} catch (error) {
log.error('Courseware listing failed:', error);
return apiError(API_ERROR_CODES.INTERNAL_ERROR, 500, 'Courseware listing failed');
}
}
export async function POST(request: NextRequest) {
const role = getServerDeploymentRole();
const sameOriginOpsSession = role === 'ops' || role === 'all';
if (sameOriginOpsSession) {
const denied = await requireOpsAccess(request);
if (denied) return denied;
} else if (role === 'server') {
if (!isPublishTokenConfigured()) {
return apiError(
API_ERROR_CODES.INTERNAL_ERROR,
503,
`Publish is disabled: ${COURSEWARE_PUBLISH_TOKEN_ENV} is not configured`,
);
}
if (!verifyPublishToken(bearerToken(request))) {
return apiError(API_ERROR_CODES.INVALID_CREDENTIALS, 401, 'Invalid publish token');
}
} else {
return apiError(API_ERROR_CODES.FORBIDDEN, 403, 'Publishing is disabled on this deployment');
}
try {
const form = await request.formData();
const zipFile = form.get('zip');
if (!(zipFile instanceof File)) {
return apiError(API_ERROR_CODES.MISSING_REQUIRED_FIELD, 400, 'Missing required field: zip');
}
const coursewareId = String(form.get('coursewareId') ?? '');
if (!coursewareId) {
return apiError(
API_ERROR_CODES.MISSING_REQUIRED_FIELD,
400,
'Missing required field: coursewareId',
);
}
if (!isValidCoursewareId(coursewareId)) {
return apiError(API_ERROR_CODES.INVALID_REQUEST, 400, 'Invalid coursewareId');
}
const versionField = form.get('version');
const version = versionField ? Number(versionField) : undefined;
if (version !== undefined && !isValidCoursewareVersion(version)) {
return apiError(API_ERROR_CODES.INVALID_REQUEST, 400, 'Invalid version');
}
const statusField = String(form.get('status') ?? 'published');
if (statusField !== 'published' && statusField !== 'unpublished') {
return apiError(API_ERROR_CODES.INVALID_REQUEST, 400, 'Invalid status');
}
if (zipFile.size > MAX_UPLOAD_BYTES) {
return apiError(API_ERROR_CODES.INVALID_REQUEST, 413, 'Bundle exceeds upload size limit');
}
const zipBytes = new Uint8Array(await zipFile.arrayBuffer());
const baseUrl = resolveConfiguredCoursewarePublicBaseUrl(buildRequestOrigin(request));
const { record } = await publishCourseware({
zipBytes,
coursewareId,
baseUrl,
version,
status: statusField,
});
log.info(`Published courseware ${record.coursewareId} v${record.version}`);
return apiSuccess({ record: toSummary(record) }, 201);
} catch (error) {
if (error instanceof CoursePublishTransportError) {
return apiError(API_ERROR_CODES.INTERNAL_ERROR, error.status, error.message, error.errorCode);
}
log.error('Courseware publish failed:', error);
return apiError(
API_ERROR_CODES.INVALID_REQUEST,
400,
'Courseware publish failed',
error instanceof Error ? error.message : undefined,
);
}
}