145 lines
5.7 KiB
TypeScript
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,
|
|
);
|
|
}
|
|
}
|