286 lines
9.2 KiB
TypeScript
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,
|
|
),
|
|
);
|
|
}
|
|
}
|