merge: integrate remote learning module safely
This commit is contained in:
@@ -24,6 +24,13 @@ type CreateProjectInput = {
|
||||
type PublishProjectSourceInput = {
|
||||
projectId?: unknown;
|
||||
project?: unknown;
|
||||
cover?: unknown;
|
||||
};
|
||||
|
||||
type ProjectCoverUpload = {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
bytes: Buffer;
|
||||
};
|
||||
|
||||
type DownloadAssetInput = {
|
||||
@@ -50,6 +57,9 @@ type AgentAvatarUploadInput = {
|
||||
|
||||
const MAX_AGENT_AVATAR_BYTES = 4 * 1024 * 1024;
|
||||
const AGENT_AVATAR_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
const MAX_PROJECT_COVER_BYTES = 10 * 1024 * 1024;
|
||||
const PROJECT_COVER_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
const SAFE_PROJECT_STATUSES = new Set(['draft', 'published']);
|
||||
|
||||
function readRequiredString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
@@ -62,6 +72,15 @@ function readOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readOptionalInteger(value: unknown, field: string, min: number, max: number): number | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
const numeric = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isInteger(numeric) || numeric < min || numeric > max) {
|
||||
throw new Error(`Invalid ${field}`);
|
||||
}
|
||||
return numeric;
|
||||
}
|
||||
|
||||
function readRequiredHeader(req: IncomingMessage, name: string): string {
|
||||
const value = req.headers[name.toLowerCase()];
|
||||
const firstValue = Array.isArray(value) ? value[0] : value;
|
||||
@@ -248,20 +267,28 @@ function readNullableStringField(
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
function projectSafeProject(value: unknown): Record<string, unknown> | null {
|
||||
function projectSafeProject(
|
||||
value: unknown,
|
||||
options: { requireStatus?: boolean } = {},
|
||||
): Record<string, unknown> | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const appId = readOptionalString(value.app_id);
|
||||
const title = readOptionalString(value.title);
|
||||
const summary = readOptionalString(value.summary);
|
||||
if (!appId || !title || !summary) return null;
|
||||
|
||||
const status = readOptionalString(value.status);
|
||||
if ((options.requireStatus && !status) || (status && !SAFE_PROJECT_STATUSES.has(status))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const projected: Record<string, unknown> = { app_id: appId, title, summary };
|
||||
for (const field of [
|
||||
'description',
|
||||
'cover_url',
|
||||
'category',
|
||||
'age_band',
|
||||
'difficulty',
|
||||
'status',
|
||||
'updated_at',
|
||||
'play_url',
|
||||
'runtime_url',
|
||||
@@ -279,6 +306,11 @@ function projectSafeProject(value: unknown): Record<string, unknown> | null {
|
||||
const fieldValue = value[field];
|
||||
if (fieldValue === null || typeof fieldValue === 'string') projected[field] = fieldValue;
|
||||
}
|
||||
if (status) projected.status = status;
|
||||
|
||||
if (Number.isInteger(value.creator_age)) {
|
||||
projected.creator_age = value.creator_age;
|
||||
}
|
||||
|
||||
const versionName = readOptionalString(value.version_name);
|
||||
if (value.version_name !== undefined) projected.version_name = versionName ?? null;
|
||||
@@ -356,7 +388,7 @@ function projectSafeVersion(value: unknown): Record<string, unknown> | null {
|
||||
|
||||
function projectSafeStatusPayload(value: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(value) || !Array.isArray(value.versions)) return null;
|
||||
const project = projectSafeProject(value.project);
|
||||
const project = projectSafeProject(value.project, { requireStatus: true });
|
||||
if (!project) return null;
|
||||
const versions = value.versions.map(projectSafeVersion);
|
||||
if (versions.some((version) => version === null)) return null;
|
||||
@@ -846,6 +878,10 @@ const LOCAL_PREVIEW_BINDING_WARNING = {
|
||||
code: 'LOCAL_PREVIEW_BINDING_SAVE_FAILED',
|
||||
message: '已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交。',
|
||||
} as const;
|
||||
const PUBLISHED_METADATA_PRESERVED = {
|
||||
code: 'PUBLISHED_METADATA_PRESERVED',
|
||||
message: '作品已发布;本次仅提交新版本,名称、简介、作者信息和封面保持不变。',
|
||||
} as const;
|
||||
|
||||
function createFallbackVersionName(now = new Date()): string {
|
||||
return `v${now.toISOString().replace(/\D/g, '').slice(0, 14)}`;
|
||||
@@ -960,16 +996,33 @@ function readProjectMetadata(value: unknown): Record<string, unknown> | null {
|
||||
title: readRequiredString(source.title, 'project.title'),
|
||||
summary: readRequiredString(source.summary, 'project.summary'),
|
||||
};
|
||||
for (const field of ['cover_url', 'category', 'age_band', 'difficulty'] as const) {
|
||||
const description = readOptionalString(source.description);
|
||||
if (description) metadata.description = description;
|
||||
for (const field of ['cover_url', 'category', 'age_band', 'difficulty', 'creator_name'] as const) {
|
||||
const fieldValue = readOptionalString(source[field]);
|
||||
if (fieldValue) metadata[field] = fieldValue;
|
||||
}
|
||||
const creatorAge = readOptionalInteger(source.creator_age, 'project.creator_age', 1, 150);
|
||||
if (creatorAge !== undefined) metadata.creator_age = creatorAge;
|
||||
return metadata;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readProjectCoverUpload(value: unknown): ProjectCoverUpload | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (!isRecord(value)) return null;
|
||||
const fileName = readOptionalString(value.fileName);
|
||||
const mimeType = readOptionalString(value.mimeType)?.toLowerCase();
|
||||
const dataBase64 = readOptionalString(value.dataBase64);
|
||||
if (!fileName || !mimeType || !dataBase64 || !PROJECT_COVER_MIME_TYPES.has(mimeType)) return null;
|
||||
if (dataBase64.length > Math.ceil(MAX_PROJECT_COVER_BYTES * 4 / 3) + 4) return null;
|
||||
const bytes = Buffer.from(dataBase64, 'base64');
|
||||
if (bytes.length === 0 || bytes.length > MAX_PROJECT_COVER_BYTES) return null;
|
||||
return { fileName, mimeType, bytes };
|
||||
}
|
||||
|
||||
async function handlePublishProjectSource(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
@@ -984,7 +1037,19 @@ async function handlePublishProjectSource(
|
||||
|
||||
const projectId = readOptionalString(body.projectId);
|
||||
const projectMetadata = readProjectMetadata(body.project);
|
||||
if (!projectId || !projectMetadata) {
|
||||
const projectCover = readProjectCoverUpload(body.cover);
|
||||
const submittedProjectStatusPresent = isRecord(body.project) && body.project.status !== undefined;
|
||||
const submittedProjectStatus = isRecord(body.project)
|
||||
? readOptionalString(body.project.status)
|
||||
: undefined;
|
||||
const expectsExistingMetadata = submittedProjectStatus
|
||||
? SAFE_PROJECT_STATUSES.has(submittedProjectStatus)
|
||||
: false;
|
||||
if (
|
||||
!projectId
|
||||
|| !projectMetadata
|
||||
|| (submittedProjectStatusPresent && !expectsExistingMetadata)
|
||||
) {
|
||||
sendPublishSourceFailure(
|
||||
res,
|
||||
400,
|
||||
@@ -993,6 +1058,15 @@ async function handlePublishProjectSource(
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (body.cover !== undefined && body.cover !== null && !projectCover) {
|
||||
sendPublishSourceFailure(
|
||||
res,
|
||||
400,
|
||||
'PROJECT_COVER_INVALID',
|
||||
'封面图片无效,请重新选择 PNG、JPEG 或 WebP 图片。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const appId = projectMetadata.app_id as string;
|
||||
const localProject = (await ctx.opencodeProjectStore.listProjects())
|
||||
.find((candidate) => candidate.id === projectId);
|
||||
@@ -1008,8 +1082,72 @@ async function handlePublishProjectSource(
|
||||
await ctx.agentBrowser.preflightStaticArtifact(prepared.staticArtifact);
|
||||
const versionName = await readAutomaticVersionName(localProject.path);
|
||||
const idempotencyKey = `makelore-${randomUUID()}`;
|
||||
let existingMetadataPreserved = false;
|
||||
let publishedMetadataPreserved = false;
|
||||
|
||||
const createResponse = await proxyAwareFetch(createWorksUrl('/api/projects').toString(), {
|
||||
const ownershipPreflight = await proxyAwareFetch(
|
||||
createWorksUrl(`/api/projects/mine/${encodeURIComponent(appId)}/status`).toString(),
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
},
|
||||
);
|
||||
if (ownershipPreflight.ok) {
|
||||
const confirmedOwnership = projectSafeStatusPayload(await readResponsePayload(ownershipPreflight));
|
||||
if (!confirmedOwnership) {
|
||||
sendPublishSourceFailure(
|
||||
res,
|
||||
502,
|
||||
'PROJECT_OWNERSHIP_UNCONFIRMED',
|
||||
'这个作品的归属暂时无法确认,请稍后重试。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!expectsExistingMetadata) {
|
||||
sendPublishSourceFailure(
|
||||
res,
|
||||
409,
|
||||
'PROJECT_METADATA_CONFLICT',
|
||||
'作品状态已变化,本次未提交版本;请重新打开发布窗口确认现有资料。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
existingMetadataPreserved = true;
|
||||
publishedMetadataPreserved = (confirmedOwnership.project as Record<string, unknown>).status === 'published';
|
||||
} else if (ownershipPreflight.status === 404) {
|
||||
await ownershipPreflight.body?.cancel().catch(() => undefined);
|
||||
if (expectsExistingMetadata) {
|
||||
sendPublishSourceFailure(
|
||||
res,
|
||||
409,
|
||||
'PROJECT_METADATA_CONFLICT',
|
||||
'作品状态已变化,本次未提交版本;请重新打开发布窗口确认现有资料。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await sendPublishSourceUpstreamError(
|
||||
res,
|
||||
ownershipPreflight,
|
||||
'PROJECT_OWNERSHIP_UNCONFIRMED',
|
||||
'这个作品的归属暂时无法确认,请稍后重试。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (projectCover && !existingMetadataPreserved) {
|
||||
// The cover contract has no delete or atomic project attachment, so uploading
|
||||
// before a conflicting create could leave an unreferenced private object.
|
||||
sendPublishSourceFailure(
|
||||
res,
|
||||
503,
|
||||
'WORKS_SQUARE_UNAVAILABLE',
|
||||
'发布服务暂时无法安全保存封面,请稍后重试。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const createResponse = existingMetadataPreserved ? null : await proxyAwareFetch(createWorksUrl('/api/projects').toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
@@ -1017,7 +1155,7 @@ async function handlePublishProjectSource(
|
||||
},
|
||||
body: JSON.stringify(projectMetadata),
|
||||
});
|
||||
if (!createResponse.ok && createResponse.status !== 409) {
|
||||
if (createResponse && !createResponse.ok && createResponse.status !== 409) {
|
||||
await sendPublishSourceUpstreamError(
|
||||
res,
|
||||
createResponse,
|
||||
@@ -1026,9 +1164,9 @@ async function handlePublishProjectSource(
|
||||
);
|
||||
return;
|
||||
}
|
||||
await createResponse.body?.cancel().catch(() => undefined);
|
||||
await createResponse?.body?.cancel().catch(() => undefined);
|
||||
|
||||
if (createResponse.status === 409) {
|
||||
if (createResponse?.status === 409) {
|
||||
const ownershipResponse = await proxyAwareFetch(
|
||||
createWorksUrl(`/api/projects/mine/${encodeURIComponent(appId)}/status`).toString(),
|
||||
{
|
||||
@@ -1045,7 +1183,23 @@ async function handlePublishProjectSource(
|
||||
);
|
||||
return;
|
||||
}
|
||||
await ownershipResponse.body?.cancel().catch(() => undefined);
|
||||
const ownershipPayload = projectSafeStatusPayload(await readResponsePayload(ownershipResponse));
|
||||
if (!ownershipPayload) {
|
||||
sendPublishSourceFailure(
|
||||
res,
|
||||
502,
|
||||
'PROJECT_OWNERSHIP_UNCONFIRMED',
|
||||
'这个作品的归属暂时无法确认,请稍后重试。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
sendPublishSourceFailure(
|
||||
res,
|
||||
409,
|
||||
'PROJECT_METADATA_CONFLICT',
|
||||
'作品状态已变化,本次未提交版本;请重新打开发布窗口确认现有资料。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadResponse = await uploadSourceProjectVersion({
|
||||
@@ -1102,6 +1256,7 @@ async function handlePublishProjectSource(
|
||||
package: rendererPackageSummary,
|
||||
upload: uploadPayload,
|
||||
...(bindingWarning ? { binding_warning: bindingWarning } : {}),
|
||||
...(publishedMetadataPreserved ? { metadata_disposition: PUBLISHED_METADATA_PRESERVED } : {}),
|
||||
});
|
||||
} finally {
|
||||
await prepared?.dispose().catch(() => undefined);
|
||||
|
||||
Reference in New Issue
Block a user