Files
openmaic/OpenMAIC/lib/server/course-publish-route.ts

286 lines
9.2 KiB
TypeScript

// Server-only transactional large-course ingestion.
//
// One multipart request carries `metadata` JSON plus `module-{index}` ZIPs.
// The server stages every frozen bundle as unpublished, promotes the complete
// batch, commits one schema-v2 manifest, and compensates this batch back to
// unpublished on failure. Learner APIs never expose sourceClassroomId.
import { type NextRequest, NextResponse } from 'next/server';
import { CoursePublishInProgressError } from '@/lib/course-framework/publish-state';
import type { CourseManifestRepo } from '@/lib/course-manifest-repo/types';
import {
COURSEWARE_BUNDLES_DIR,
createFileBundleByteStore,
type BundleByteStore,
} from '@/lib/courseware-repo/bundle-store';
import { COURSEWARES_DIR, createFileCoursewareRepo } from '@/lib/courseware-repo/store';
import {
COURSE_PUBLISH_MAX_UPLOAD_BYTES_ENV,
COURSEWARE_PUBLIC_BASE_URL_ENV,
CoursePublishTransportError,
DEFAULT_COURSE_PUBLISH_MAX_UPLOAD_BYTES,
DEFAULT_COURSEWARE_MAX_UPLOAD_BYTES,
MAX_COURSE_PUBLISH_METADATA_BYTES,
parseRemoteCoursePublishMetadata,
positiveIntegerEnv,
resolveCoursewarePublicBaseUrl,
type RemoteCoursePublishErrorBody,
type RemoteCoursePublishSuccessBody,
} from '@/lib/server/course-publish-contract';
import {
commitRemoteCoursePublish,
type CoursePublishTransactionRepos,
} from '@/lib/server/course-publish-transaction';
import { buildRequestOrigin } from '@/lib/server/classroom-storage';
import { capBodyStream } from '@/lib/server/capped-stream';
import { createLogger } from '@/lib/logger';
import {
COURSEWARE_PUBLISH_TOKEN_ENV,
isPublishTokenConfigured,
verifyPublishToken,
} from '@/lib/courseware-repo';
import type { CoursewareRepo } from '@/lib/courseware-repo/types';
import { getServerDeploymentRole } from '@/lib/server/ops-access';
const log = createLogger('Internal Course Publish API');
export interface CoursePublishRouteDependencies {
coursewares?: CoursewareRepo;
bundles?: BundleByteStore;
manifests?: CourseManifestRepo;
publicBaseUrl?: string;
}
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;
}
function errorResponse(error: CoursePublishTransportError): NextResponse {
const body: RemoteCoursePublishErrorBody = {
success: false,
errorCode: error.errorCode,
error: error.message,
phase: error.phase,
...(error.moduleIndex ? { moduleIndex: error.moduleIndex } : {}),
...(error.details ? { details: error.details } : {}),
};
return NextResponse.json(body, { status: error.status });
}
function routeRepos(deps: CoursePublishRouteDependencies): Partial<CoursePublishTransactionRepos> {
return {
coursewares: deps.coursewares ?? createFileCoursewareRepo(COURSEWARES_DIR),
bundles: deps.bundles ?? createFileBundleByteStore(COURSEWARE_BUNDLES_DIR),
...(deps.manifests ? { manifests: deps.manifests } : {}),
};
}
function canonicalPublicBaseUrl(
request: NextRequest,
deps: CoursePublishRouteDependencies,
): string {
const configured =
deps.publicBaseUrl?.trim() || process.env[COURSEWARE_PUBLIC_BASE_URL_ENV]?.trim();
if (!configured && process.env.NODE_ENV === 'production') {
throw new CoursePublishTransportError(
'PUBLIC_URL_MISSING',
`${COURSEWARE_PUBLIC_BASE_URL_ENV} is required on a production server`,
'request',
503,
);
}
return resolveCoursewarePublicBaseUrl(configured || buildRequestOrigin(request));
}
export async function handleCoursePublishRequest(
request: NextRequest,
deps: CoursePublishRouteDependencies = {},
): Promise<NextResponse> {
const role = getServerDeploymentRole();
if (role !== 'server' && role !== 'all') {
return errorResponse(
new CoursePublishTransportError(
'FORBIDDEN',
'Transactional course publishing is only available on the server deployment',
'request',
403,
),
);
}
if (!isPublishTokenConfigured()) {
return errorResponse(
new CoursePublishTransportError(
'PUBLISH_DISABLED',
`Publish is disabled: ${COURSEWARE_PUBLISH_TOKEN_ENV} is not configured`,
'request',
503,
),
);
}
const token = bearerToken(request);
if (!verifyPublishToken(token)) {
return errorResponse(
new CoursePublishTransportError(
'UNAUTHORIZED',
'Invalid or missing publish token',
'request',
401,
),
);
}
try {
if (!request.headers.get('content-type')?.toLowerCase().startsWith('multipart/form-data')) {
throw new CoursePublishTransportError(
'INVALID_CONTENT_TYPE',
'Course publish requires multipart/form-data',
'request',
415,
);
}
const maxUploadBytes = positiveIntegerEnv(
COURSE_PUBLISH_MAX_UPLOAD_BYTES_ENV,
DEFAULT_COURSE_PUBLISH_MAX_UPLOAD_BYTES,
);
const declaredLength = Number(request.headers.get('content-length') ?? '');
if (Number.isFinite(declaredLength) && declaredLength > maxUploadBytes) {
throw new CoursePublishTransportError(
'COURSE_UPLOAD_TOO_LARGE',
`Course publish exceeds ${maxUploadBytes} bytes`,
'request',
413,
);
}
if (!request.body) {
throw new CoursePublishTransportError(
'BODY_MISSING',
'Course publish body is missing',
'request',
400,
);
}
const capped = capBodyStream(request.body, maxUploadBytes);
let form: FormData;
try {
form = await new Response(capped.stream, {
headers: { 'content-type': request.headers.get('content-type')! },
}).formData();
} catch (error) {
if (capped.exceeded()) {
throw new CoursePublishTransportError(
'COURSE_UPLOAD_TOO_LARGE',
`Course publish exceeds ${maxUploadBytes} bytes`,
'request',
413,
);
}
throw new CoursePublishTransportError(
'MULTIPART_INVALID',
'Course publish multipart body could not be parsed',
'request',
400,
undefined,
error instanceof Error ? error.message : undefined,
);
}
const metadataField = form.get('metadata');
if (typeof metadataField !== 'string') {
throw new CoursePublishTransportError(
'METADATA_MISSING',
'Course publish metadata is missing',
'request',
400,
);
}
if (new TextEncoder().encode(metadataField).byteLength > MAX_COURSE_PUBLISH_METADATA_BYTES) {
throw new CoursePublishTransportError(
'METADATA_TOO_LARGE',
'Course publish metadata exceeds the size limit',
'request',
413,
);
}
let metadataValue: unknown;
try {
metadataValue = JSON.parse(metadataField) as unknown;
} catch {
throw new CoursePublishTransportError(
'INVALID_METADATA',
'Course publish metadata is not valid JSON',
'request',
400,
);
}
const metadata = parseRemoteCoursePublishMetadata(metadataValue);
const perModuleLimit = positiveIntegerEnv(
'COURSEWARE_MAX_UPLOAD_BYTES',
DEFAULT_COURSEWARE_MAX_UPLOAD_BYTES,
);
const archives = metadata.modules.map((moduleRecord) => {
const fieldName = `module-${moduleRecord.index}`;
const fields = form.getAll(fieldName);
if (fields.length !== 1 || !(fields[0] instanceof File)) {
throw new CoursePublishTransportError(
'MODULE_ARCHIVE_MISSING',
`Module ${moduleRecord.index} requires exactly one ZIP`,
'request',
400,
moduleRecord.index,
);
}
const file = fields[0];
if (file.size > perModuleLimit) {
throw new CoursePublishTransportError(
'MODULE_ARCHIVE_TOO_LARGE',
`Module ${moduleRecord.index} ZIP exceeds ${perModuleLimit} bytes`,
'request',
413,
moduleRecord.index,
);
}
return { index: moduleRecord.index, file };
});
const loadedArchives = await Promise.all(
archives.map(async ({ index, file }) => ({
index,
zipBytes: new Uint8Array(await file.arrayBuffer()),
})),
);
const result = await commitRemoteCoursePublish({
metadata,
archives: loadedArchives,
token,
publicBaseUrl: canonicalPublicBaseUrl(request, deps),
repos: routeRepos(deps),
});
const body: RemoteCoursePublishSuccessBody = {
success: true,
record: result.record,
idempotent: result.idempotent,
};
return NextResponse.json(body, { status: result.idempotent ? 200 : 201 });
} catch (error) {
if (error instanceof CoursePublishTransportError) return errorResponse(error);
if (error instanceof CoursePublishInProgressError) {
return errorResponse(
new CoursePublishTransportError('PUBLISH_IN_PROGRESS', error.message, 'request', 409),
);
}
log.error('Transactional course publish failed:', error);
return errorResponse(
new CoursePublishTransportError(
'INTERNAL_ERROR',
'Transactional course publish failed',
'commit',
500,
undefined,
error instanceof Error ? error.message : undefined,
),
);
}
}