feat: require cover for first project submission
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
# Task: Add project cover upload to review dialog
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260817-project-cover-upload-a6a98e56
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260817-project-cover-upload-a6a98e56-project-cover-upload
|
||||
- Worktree: D:\Datas\OthersProjects\makelore-project-cover-upload-a6a98e56
|
||||
- Base commit: 7e8d9e38114158992c03e589de32535274796d04
|
||||
- Owner: codex
|
||||
- Status: Ready for integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Add a required project-cover picker and preview to the first-submission information dialog.
|
||||
- Validate and encode PNG/JPEG/WebP input in Renderer, then pass the existing bounded cover DTO through the Main-owned publish route.
|
||||
- Update Electron Main to call the new Works Square atomic metadata-plus-cover create contract before version upload.
|
||||
- Preserve version-only behavior and existing metadata/cover for draft and published Works projects.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Renderer may handle only the user-selected cover bytes and safe metadata; Works credentials, local project paths, archives, build output, and idempotency stay in Electron Main.
|
||||
- First submission requires one valid cover and must fail before source upload if the atomic create contract rejects it.
|
||||
- Do not restore a standalone publish/upload page or cloud-deploy workbench.
|
||||
- Do not use the old standalone cover-upload-plus-JSON-create sequence, which can leave orphaned objects or lose a 404-to-409 race.
|
||||
- Feature mode updates only this task record in canonical project memory; README stays synchronized with the user-visible behavior.
|
||||
|
||||
## Outcome
|
||||
|
||||
- The first-submission information dialog now requires a PNG, JPEG, or WebP cover no larger than 10 MiB and shows its preview, file name, and a reselect action.
|
||||
- Renderer validates MIME type, non-empty size, byte limit, and file signature before passing the existing bounded cover DTO to Electron Main.
|
||||
- Electron Main now sends `metadata` JSON and `cover` in one `POST /api/projects/with-cover` multipart request, retaining ownership of credentials, paths, archives, and version upload.
|
||||
- A create conflict stops before version upload; existing draft and published projects continue through the version-only path without replacing their cover.
|
||||
- README now describes the required first-submission cover and the multipart create flow.
|
||||
- The shared Electron E2E now covers opening the first-submission dialog, the missing-cover error, a valid PNG preview, the selected file name, and the reselect action.
|
||||
|
||||
## Verification
|
||||
|
||||
- Focused Vitest run for `project-publish-action`, `works-routes`, and `works-square` — 3 files, 76 tests passed.
|
||||
- `tsc --noEmit` — passed.
|
||||
- Focused ESLint on all changed TypeScript/TSX files — passed.
|
||||
- Vite production build — passed; only pre-existing dynamic-import and chunk-size warnings were emitted.
|
||||
- Focused Playwright run for `tests/e2e/project-configuration-skills.spec.ts` — 1 passed.
|
||||
- `git diff --check` — passed with only the repository's Windows LF-to-CRLF notices.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Integrate and deploy the matching Works Square server task `20260817-project-cover-upload-0cc3cb72` before distributing a client that calls the new route.
|
||||
- Non-blocking hardening: add direct Renderer/Main unit cases for empty or signature-mismatched files, and assess explicit Host JSON-body and cover-file-name length limits with the broader Host API boundary.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- Target canonical docs: `.project-docs/40-domain/business-rules.md`, `.project-docs/10-overview/current-state.md`, and `.project-docs/20-product/success-criteria.md`
|
||||
- Proposal: Replace the documented coverless-first-create limitation with the required cover picker and atomic metadata-plus-cover create flow; keep existing-project submissions version-only.
|
||||
- Evidence: `ProjectPublishAction.tsx`, `electron/api/routes/works.ts`, 76 focused unit tests, the focused Electron E2E, and the synchronized README contract description.
|
||||
- Future impact: Changes to the publish dialog or Electron Works route must retain the first-create cover validation and prevent version upload when metadata creation fails.
|
||||
- Semantic conflicts: Supersedes the current project-memory statements that first submissions are intentionally coverless; it does not restore the removed standalone publish page.
|
||||
- Human confirmation: Confirmed by the user's explicit request to add project-cover upload to the one-click review dialog.
|
||||
@@ -11,7 +11,7 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人
|
||||
|
||||
应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入浏览器授权,授权成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号的四模块开关并只向 Renderer 投影布尔策略;被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。
|
||||
|
||||
作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建项目可选择“小游戏”“小程序”或“自定义项目”:小游戏和小程序会创建完整的平台发布模板,项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。当前 Works 项目接口没有可验证的元数据 revision/条件写或封面回收合同,因此首次创建会原子保存文字资料但暂不上传封面,已有 draft/published 只提交新版本并沿用平台现有资料与封面;恢复封面或已有资料编辑前必须先补齐服务端原子合同。自定义项目只创建工作空间,不配置默认发布方式。项目成果预览 `/deliverables` 继续保留。
|
||||
作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建项目可选择“小游戏”“小程序”或“自定义项目”:小游戏和小程序会创建完整的平台发布模板,项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。自定义项目只创建工作空间,不配置默认发布方式。项目成果预览 `/deliverables` 继续保留。
|
||||
|
||||
## 当前产品状态
|
||||
|
||||
@@ -92,7 +92,7 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode
|
||||
- Renderer 不直接调用 Electron IPC 或本地运行时 HTTP 地址。
|
||||
- Electron Main 负责认证、秘密存储、运行时生命周期、代理、同步和系统集成。
|
||||
- Works Square 登录态按真实键盘、鼠标或触摸活动滑动续期;持续使用无需反复授权,连续 7 天未使用才清除会话并要求重新登录。刷新凭据只由 Electron Main 持有,并在系统提供受保护凭据存储时加密落盘;Renderer 仅保存短效公开会话状态(旧版升级迁移时仅暂存既有刷新凭据,Main 成功接管后立即删除)。
|
||||
- AI 编程发布只经过 Main-owned Host API:Renderer 仅提交本地项目标识和非敏感作品资料;Main 持有源码快照、本地 npm/Vite 构建、精确产物预检、双归档、Works Token、版本生成、幂等重试和安全状态投影。首次项目 create 原子写入文字资料且不上传封面;已有项目只提交版本,状态竞态会固定失败并要求重新确认,不执行无条件 metadata PATCH。项目的 Vite config/plugins 会以当前桌面用户权限执行,因此该链路只适用于用户信任的本地项目,不是 sandbox。
|
||||
- AI 编程发布只经过 Main-owned Host API:Renderer 仅提交本地项目标识、非敏感作品资料和有界封面 DTO;Main 持有源码快照、本地 npm/Vite 构建、精确产物预检、双归档、Works Token、版本生成、幂等重试和安全状态投影。首次项目 create 使用 `/api/projects/with-cover` multipart 原子写入资料与封面;已有项目只提交版本,状态竞态会固定失败并要求重新确认,不执行无条件 metadata PATCH 或封面替换。项目的 Vite config/plugins 会以当前桌面用户权限执行,因此该链路只适用于用户信任的本地项目,不是 sandbox。
|
||||
- AI 编程项目配置以项目内 `.niancode/project.json` 为准;项目文件和会话主数据保持本地,问答观察快照按个人资料同步规则单向上行。
|
||||
- AI 绘画 Renderer 只调用 Main-owned Host API;Main 负责 Works Square Token 刷新、Conversation 所属的服务端持久 Agent Session、单次 WebSocket ticket、双向命令/事件帧、断点续传与契约映射,并通过本机 Host API 的 SSE 投影同步任务状态。切换会话只重连对应流;注销或退出时关闭本地流并清除本机 Session-id 缓存,不删除服务端持久 Conversation Session。远端 Token 与 ticket 不进入 Renderer。
|
||||
- AI 绘画使用独立的云端 Workspace 边界,不回退到 AI 编程项目数据,也不向 Renderer 暴露 Provider、模型、Prompt、存储 URI 或远端登录 Token。
|
||||
|
||||
@@ -1020,9 +1020,22 @@ function readProjectCoverUpload(value: unknown): ProjectCoverUpload | 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;
|
||||
const validSignature = mimeType === 'image/png'
|
||||
? bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||
: mimeType === 'image/jpeg'
|
||||
? bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff
|
||||
: bytes.length >= 12 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP';
|
||||
if (!validSignature) return null;
|
||||
return { fileName, mimeType, bytes };
|
||||
}
|
||||
|
||||
function createProjectWithCoverForm(metadata: Record<string, unknown>, cover: ProjectCoverUpload): FormData {
|
||||
const form = new FormData();
|
||||
form.set('metadata', JSON.stringify(metadata));
|
||||
form.set('cover', new Blob([new Uint8Array(cover.bytes)], { type: cover.mimeType }), cover.fileName);
|
||||
return form;
|
||||
}
|
||||
|
||||
async function handlePublishProjectSource(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
@@ -1125,6 +1138,15 @@ async function handlePublishProjectSource(
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!projectCover) {
|
||||
sendPublishSourceFailure(
|
||||
res,
|
||||
400,
|
||||
'PROJECT_COVER_REQUIRED',
|
||||
'首次提交必须选择有效的 PNG、JPEG 或 WebP 项目封面。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await sendPublishSourceUpstreamError(
|
||||
res,
|
||||
@@ -1135,25 +1157,12 @@ async function handlePublishProjectSource(
|
||||
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(), {
|
||||
const createResponse = existingMetadataPreserved ? null : await proxyAwareFetch(createWorksUrl('/api/projects/with-cover').toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(projectMetadata),
|
||||
body: createProjectWithCoverForm(projectMetadata, projectCover!),
|
||||
});
|
||||
if (createResponse && !createResponse.ok && createResponse.status !== 409) {
|
||||
await sendPublishSourceUpstreamError(
|
||||
@@ -1167,32 +1176,6 @@ async function handlePublishProjectSource(
|
||||
await createResponse?.body?.cancel().catch(() => undefined);
|
||||
|
||||
if (createResponse?.status === 409) {
|
||||
const ownershipResponse = await proxyAwareFetch(
|
||||
createWorksUrl(`/api/projects/mine/${encodeURIComponent(appId)}/status`).toString(),
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
},
|
||||
);
|
||||
if (!ownershipResponse.ok) {
|
||||
await sendPublishSourceUpstreamError(
|
||||
res,
|
||||
ownershipResponse,
|
||||
'PROJECT_OWNERSHIP_UNCONFIRMED',
|
||||
'这个作品 ID 已被占用,请更换后重试。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const ownershipPayload = projectSafeStatusPayload(await readResponsePayload(ownershipResponse));
|
||||
if (!ownershipPayload) {
|
||||
sendPublishSourceFailure(
|
||||
res,
|
||||
502,
|
||||
'PROJECT_OWNERSHIP_UNCONFIRMED',
|
||||
'这个作品的归属暂时无法确认,请稍后重试。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
sendPublishSourceFailure(
|
||||
res,
|
||||
409,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import {
|
||||
fetchCurrentWorksProjectStatus,
|
||||
publishWorksProjectSource,
|
||||
type WorksProjectCoverUpload,
|
||||
type WorksProjectMetadataInput,
|
||||
} from '@/lib/works-square';
|
||||
import type { OpencodeProject } from '@/types/opencode';
|
||||
@@ -55,6 +56,30 @@ type ProjectPublishActionProps = {
|
||||
|
||||
const BUILD_POLL_INTERVAL_MS = 2_000;
|
||||
const BUILD_POLL_ATTEMPTS = 300;
|
||||
const MAX_PROJECT_COVER_BYTES = 10 * 1024 * 1024;
|
||||
const PROJECT_COVER_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
|
||||
function matchesCoverSignature(bytes: Uint8Array, mimeType: string): boolean {
|
||||
if (mimeType === 'image/png') {
|
||||
return bytes.length >= 8
|
||||
&& [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((value, index) => bytes[index] === value);
|
||||
}
|
||||
if (mimeType === 'image/jpeg') {
|
||||
return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
|
||||
}
|
||||
return bytes.length >= 12
|
||||
&& String.fromCharCode(...bytes.slice(0, 4)) === 'RIFF'
|
||||
&& String.fromCharCode(...bytes.slice(8, 12)) === 'WEBP';
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
const chunkSize = 0x8000;
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
|
||||
function delay(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
@@ -106,6 +131,8 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
|
||||
const [difficulty, setDifficulty] = useState('');
|
||||
const [checkingProjectStatus, setCheckingProjectStatus] = useState(false);
|
||||
const [existingProject, setExistingProject] = useState<ExistingProjectMetadata | null>(null);
|
||||
const [cover, setCover] = useState<WorksProjectCoverUpload | null>(null);
|
||||
const [coverPreview, setCoverPreview] = useState<string | null>(null);
|
||||
const pollGeneration = useRef(0);
|
||||
|
||||
const authUser = useAuthStore((state) => state.user);
|
||||
@@ -164,7 +191,10 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
|
||||
}
|
||||
}
|
||||
|
||||
async function submitProject(metadata: WorksProjectMetadataInput): Promise<void> {
|
||||
async function submitProject(
|
||||
metadata: WorksProjectMetadataInput,
|
||||
projectCover?: WorksProjectCoverUpload,
|
||||
): Promise<void> {
|
||||
pollGeneration.current += 1;
|
||||
const generation = pollGeneration.current;
|
||||
setFailure(null);
|
||||
@@ -175,6 +205,7 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
|
||||
const result = await publishWorksProjectSource({
|
||||
projectId: project.id,
|
||||
project: metadata,
|
||||
...(projectCover ? { cover: projectCover } : {}),
|
||||
});
|
||||
if (pollGeneration.current !== generation) return;
|
||||
setBindingWarning(result.bindingWarning?.message ?? null);
|
||||
@@ -191,6 +222,34 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCoverChange(file: File | undefined): Promise<void> {
|
||||
setCover(null);
|
||||
setCoverPreview(null);
|
||||
if (!file) return;
|
||||
const mimeType = file.type.toLowerCase();
|
||||
if (!PROJECT_COVER_MIME_TYPES.has(mimeType)) {
|
||||
setFormError('项目封面仅支持 PNG、JPEG 或 WebP 图片。');
|
||||
return;
|
||||
}
|
||||
if (file.size === 0) {
|
||||
setFormError('项目封面不能为空,请重新选择图片。');
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_PROJECT_COVER_BYTES) {
|
||||
setFormError('项目封面不能超过 10 MiB,请选择更小的图片。');
|
||||
return;
|
||||
}
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
if (!matchesCoverSignature(bytes, mimeType)) {
|
||||
setFormError('项目封面内容与图片格式不符,请重新选择 PNG、JPEG 或 WebP 图片。');
|
||||
return;
|
||||
}
|
||||
const dataBase64 = bytesToBase64(bytes);
|
||||
setCover({ fileName: file.name, mimeType, dataBase64 });
|
||||
setCoverPreview(`data:${mimeType};base64,${dataBase64}`);
|
||||
setFormError(null);
|
||||
}
|
||||
|
||||
async function openPublishDialog(): Promise<void> {
|
||||
setFailure(null);
|
||||
setCheckingProjectStatus(true);
|
||||
@@ -249,6 +308,10 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
|
||||
setFormError('请填写项目简介。');
|
||||
return;
|
||||
}
|
||||
if (!cover) {
|
||||
setFormError('请选择项目封面。首次提交必须上传 PNG、JPEG 或 WebP 图片。');
|
||||
return;
|
||||
}
|
||||
if (!trimmedCreatorName) {
|
||||
setFormError('请填写发布者姓名。');
|
||||
return;
|
||||
@@ -271,7 +334,7 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
|
||||
difficulty: difficulty.trim() || null,
|
||||
};
|
||||
setDialogOpen(false);
|
||||
await submitProject(metadata);
|
||||
await submitProject(metadata, cover);
|
||||
}
|
||||
|
||||
const locked = checkingProjectStatus
|
||||
@@ -326,7 +389,7 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
|
||||
<DialogDescription>
|
||||
{existingProject
|
||||
? '当前发布契约不支持安全修改已有作品资料。本次只会提交新的构建版本,现有名称、简介、作者信息和封面都会保持不变。'
|
||||
: '首次创建会保存这些作品信息并提交构建结果。当前发布契约暂不支持上传封面,因此本次作品将不设置封面。发布者年龄指作者本人年龄。'}
|
||||
: '首次创建会原子保存作品信息与项目封面,再提交构建结果。发布者年龄指作者本人年龄。'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -400,6 +463,40 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="project-publish-cover">项目封面 <span className="text-destructive">*</span></Label>
|
||||
<Input
|
||||
id="project-publish-cover"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="sr-only"
|
||||
onChange={(event) => void handleCoverChange(event.target.files?.[0])}
|
||||
/>
|
||||
<div className="flex min-w-0 items-center gap-3 rounded-xl border border-border/80 bg-muted/30 p-3">
|
||||
{coverPreview ? (
|
||||
<img
|
||||
src={coverPreview}
|
||||
alt="项目封面预览"
|
||||
className="h-20 w-20 shrink-0 rounded-xl object-cover outline outline-1 -outline-offset-1 outline-black/10"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-20 w-20 shrink-0 items-center justify-center rounded-xl bg-muted text-xs text-muted-foreground [box-shadow:inset_0_0_0_1px_rgba(0,0,0,0.1)]">
|
||||
暂无封面
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{cover?.fileName ?? '请选择一张图片'}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">PNG、JPEG 或 WebP,最大 10 MiB</p>
|
||||
<label
|
||||
htmlFor="project-publish-cover"
|
||||
className="mt-2 inline-flex min-h-10 cursor-pointer items-center rounded-md border border-input bg-background px-4 text-sm font-medium hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{cover ? '重新选择' : '选择封面'}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="project-publish-age-band">适龄范围(选填)</Label>
|
||||
@@ -424,7 +521,7 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
|
||||
</div>
|
||||
|
||||
<p className="rounded-xl bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
作品类型:{projectType}。名称、简介和作者信息会交给运营端统一审核;本次不上传封面。
|
||||
作品类型:{projectType}。名称、简介、作者信息和封面会交给运营端统一审核。
|
||||
</p>
|
||||
</>}
|
||||
|
||||
@@ -451,14 +548,14 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
|
||||
{phase === 'submitting'
|
||||
? existingProject
|
||||
? '作品资料与封面保持不变;Makelore 正在生成并提交新版本。'
|
||||
: 'Makelore 正在创建无封面作品、生成本次构建结果并提交,不需要准备 ZIP。'
|
||||
: 'Makelore 正在创建作品并上传封面、生成本次构建结果,不需要准备 ZIP。'
|
||||
: phase === 'polling'
|
||||
? existingProject
|
||||
? '新版本已上传,现有作品资料与封面保持不变,正在等待平台校验。'
|
||||
: '无封面作品与本次构建结果已提交,正在等待平台校验。'
|
||||
: '作品封面与本次构建结果已提交,正在等待平台校验。'
|
||||
: existingProject
|
||||
? '新版本已提交,现有作品资料与封面保持不变。'
|
||||
: '无封面作品已提交,等待运营审核。审核通过后会直接发布。'}
|
||||
: '作品与封面已提交,等待运营审核。审核通过后会直接发布。'}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -119,6 +119,44 @@ test.describe('Project configuration skills', () => {
|
||||
await expect(page.getByTestId('resource-card-publish')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '一键提交审核' })).toBeVisible();
|
||||
|
||||
await app.evaluate(({ ipcMain }) => {
|
||||
ipcMain.removeHandler('hostapi:fetch');
|
||||
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string }) => {
|
||||
if (/^\/api\/works\/projects\/mine\/[^/]+\/status$/.test(request.path ?? '')) {
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: {
|
||||
success: false,
|
||||
status: 404,
|
||||
code: 'PROJECT_NOT_FOUND',
|
||||
error: 'Project not found',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ok: false, error: { message: `Unexpected E2E Host API request: ${request.path ?? ''}` } };
|
||||
});
|
||||
});
|
||||
await page.getByRole('button', { name: '一键提交审核' }).click();
|
||||
const publishDialog = page.getByRole('dialog', { name: '完善作品信息后提交' });
|
||||
await expect(publishDialog).toBeVisible();
|
||||
await expect(publishDialog.getByLabel(/项目封面/)).toBeVisible();
|
||||
await publishDialog.getByLabel(/发布者姓名/).fill('E2E 作者');
|
||||
await publishDialog.getByLabel(/发布者年龄/).fill('12');
|
||||
await publishDialog.getByLabel(/项目简介/).fill('验证首次提审必须选择项目封面。');
|
||||
await publishDialog.getByRole('button', { name: '提交审核' }).click();
|
||||
await expect(publishDialog.getByRole('alert')).toContainText('首次提交必须上传');
|
||||
|
||||
const coverPath = path.join(process.cwd(), 'src/assets/agent-avatars/avatar-01.png');
|
||||
await publishDialog.getByLabel(/项目封面/).setInputFiles(coverPath);
|
||||
await expect(publishDialog.getByAltText('项目封面预览')).toBeVisible();
|
||||
await expect(publishDialog.getByText('avatar-01.png')).toBeVisible();
|
||||
await expect(publishDialog.getByText('重新选择')).toBeVisible();
|
||||
await publishDialog.getByRole('button', { name: '取消' }).click();
|
||||
|
||||
const agentCard = page.getByTestId('project-agent-e2e-partner');
|
||||
await expect(agentCard).toContainText('E2E伙伴');
|
||||
await agentCard.click();
|
||||
|
||||
@@ -63,7 +63,9 @@ async function advancePoll() {
|
||||
});
|
||||
}
|
||||
|
||||
async function submitProjectMetadata() {
|
||||
const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1]);
|
||||
|
||||
async function submitProjectMetadata({ includeCover = true }: { includeCover?: boolean } = {}) {
|
||||
fetchCurrentWorksProjectStatusMock.mockRejectedValueOnce(Object.assign(
|
||||
new Error('project missing'),
|
||||
{ statusCode: 404 },
|
||||
@@ -75,6 +77,17 @@ async function submitProjectMetadata() {
|
||||
fireEvent.change(screen.getByLabelText(/发布者姓名/), { target: { value: '小明' } });
|
||||
fireEvent.change(screen.getByLabelText(/发布者年龄/), { target: { value: '12' } });
|
||||
fireEvent.change(screen.getByLabelText(/项目简介/), { target: { value: '这是一个太空清洁小游戏。' } });
|
||||
if (includeCover) {
|
||||
fireEvent.change(screen.getByLabelText(/项目封面/), {
|
||||
target: { files: [new File([pngBytes], 'space-cover.png', { type: 'image/png' })] },
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByText('space-cover.png')).toBeVisible();
|
||||
expect(screen.getByAltText('项目封面预览')).toBeVisible();
|
||||
expect(screen.getByText('重新选择')).toBeVisible();
|
||||
}
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交审核' }));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
@@ -95,7 +108,7 @@ describe('ProjectPublishAction', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('creates a new project without a cover and locks after Builder succeeds', async () => {
|
||||
it('creates a new project with a cover DTO and locks after Builder succeeds', async () => {
|
||||
fetchCurrentWorksProjectStatusMock.mockResolvedValue(projectStatus('succeeded'));
|
||||
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
||||
|
||||
@@ -104,7 +117,7 @@ describe('ProjectPublishAction', () => {
|
||||
await flushSubmission();
|
||||
|
||||
expect(screen.getByTestId('project-publish-status')).toHaveTextContent(
|
||||
'无封面作品与本次构建结果已提交,正在等待平台校验。',
|
||||
'作品封面与本次构建结果已提交,正在等待平台校验。',
|
||||
);
|
||||
|
||||
expect(publishWorksProjectSourceMock).toHaveBeenCalledWith({
|
||||
@@ -121,9 +134,12 @@ describe('ProjectPublishAction', () => {
|
||||
age_band: null,
|
||||
difficulty: null,
|
||||
},
|
||||
cover: {
|
||||
fileName: 'space-cover.png',
|
||||
mimeType: 'image/png',
|
||||
dataBase64: 'iVBORw0KGgoB',
|
||||
},
|
||||
});
|
||||
expect(screen.queryByLabelText(/项目封面/)).not.toBeInTheDocument();
|
||||
expect(publishWorksProjectSourceMock.mock.calls[0]?.[0]).not.toHaveProperty('cover');
|
||||
expect(fetchCurrentWorksProjectStatusMock).toHaveBeenCalledOnce();
|
||||
|
||||
await advancePoll();
|
||||
@@ -133,7 +149,36 @@ describe('ProjectPublishAction', () => {
|
||||
);
|
||||
expect(fetchCurrentWorksProjectStatusMock).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByRole('button', { name: '已提交,等待运营审核' })).toBeDisabled();
|
||||
expect(screen.getByTestId('project-publish-status')).toHaveTextContent('无封面作品已提交');
|
||||
expect(screen.getByTestId('project-publish-status')).toHaveTextContent('作品与封面已提交');
|
||||
});
|
||||
|
||||
it('blocks first submission until a cover is selected', async () => {
|
||||
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
||||
|
||||
await submitProjectMetadata({ includeCover: false });
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('首次提交必须上传');
|
||||
expect(publishWorksProjectSourceMock).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('dialog')).toBeVisible();
|
||||
});
|
||||
|
||||
it('rejects unsupported and oversized cover files before confirmation', async () => {
|
||||
fetchCurrentWorksProjectStatusMock.mockRejectedValue(Object.assign(new Error('missing'), { statusCode: 404 }));
|
||||
render(<ProjectPublishAction project={project} projectType="mini_game" />);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
const input = screen.getByLabelText(/项目封面/);
|
||||
|
||||
fireEvent.change(input, { target: { files: [new File(['text'], 'cover.gif', { type: 'image/gif' })] } });
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('仅支持 PNG、JPEG 或 WebP');
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { files: [new File([new Uint8Array(10 * 1024 * 1024 + 1)], 'huge.png', { type: 'image/png' })] },
|
||||
});
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('不能超过 10 MiB');
|
||||
expect(publishWorksProjectSourceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a friendly Builder failure without raw details', async () => {
|
||||
|
||||
@@ -79,6 +79,14 @@ function createRendererRequest(method: string, body?: unknown): IncomingMessage
|
||||
});
|
||||
}
|
||||
|
||||
function validProjectCover(fileName = 'cover.png') {
|
||||
return {
|
||||
fileName,
|
||||
mimeType: 'image/png',
|
||||
dataBase64: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1]).toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
async function writePublishableProject(projectPath: string): Promise<void> {
|
||||
await mkdir(join(projectPath, 'src'), { recursive: true });
|
||||
await mkdir(join(projectPath, '.niancode'), { recursive: true });
|
||||
@@ -1320,7 +1328,7 @@ describe('works square host api routes', () => {
|
||||
prepareProjectReleaseMock.mockResolvedValueOnce(release);
|
||||
|
||||
const handled = await handleWorksRoutes(
|
||||
createRendererRequest('POST', { projectId: project.id, project: projectMetadata }),
|
||||
createRendererRequest('POST', { projectId: project.id, project: projectMetadata, cover: validProjectCover() }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
{
|
||||
@@ -1360,17 +1368,16 @@ describe('works square host api routes', () => {
|
||||
expect(response.json().package).not.toHaveProperty('archivePath');
|
||||
expect(getValidWorksSquareAccessTokenMock).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(fetchMock.mock.calls[1]).toEqual([
|
||||
'https://square.nianxx.cn/api/projects',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer main-owned-access-token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(projectMetadata),
|
||||
},
|
||||
]);
|
||||
const [createUrl, createInit] = fetchMock.mock.calls[1] as [string, RequestInit];
|
||||
expect(createUrl).toBe('https://square.nianxx.cn/api/projects/with-cover');
|
||||
expect(createInit.method).toBe('POST');
|
||||
expect(createInit.headers).toEqual({ Authorization: 'Bearer main-owned-access-token' });
|
||||
const createForm = createInit.body as FormData;
|
||||
expect(JSON.parse(String(createForm.get('metadata')))).toEqual(projectMetadata);
|
||||
const submittedCover = createForm.get('cover');
|
||||
expect(submittedCover).toBeInstanceOf(File);
|
||||
expect((submittedCover as File).name).toBe('cover.png');
|
||||
expect((submittedCover as File).type).toBe('image/png');
|
||||
const [, uploadInit] = fetchMock.mock.calls[2] as [string, RequestInit];
|
||||
expect(uploadInit.headers).toMatchObject({
|
||||
Authorization: 'Bearer main-owned-access-token',
|
||||
@@ -1404,7 +1411,7 @@ describe('works square host api routes', () => {
|
||||
expect(JSON.stringify(response.json())).not.toContain('private-source.zip');
|
||||
});
|
||||
|
||||
it('fails before upload or create when a 404 preflight cannot atomically attach the submitted cover', async () => {
|
||||
it('requires a cover after a 404 ownership preflight and stops before atomic create or version upload', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-cover-publish-'));
|
||||
await writePublishableProject(tempDir);
|
||||
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
|
||||
@@ -1420,18 +1427,13 @@ describe('works square host api routes', () => {
|
||||
age_band: '6-12岁',
|
||||
difficulty: '入门',
|
||||
};
|
||||
const cover = {
|
||||
fileName: 'cover.png',
|
||||
mimeType: 'image/png',
|
||||
dataBase64: Buffer.from('cover bytes').toString('base64'),
|
||||
};
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(new Response('{}', { status: 404 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
prepareProjectReleaseMock.mockResolvedValueOnce(preparedRelease(tempDir));
|
||||
|
||||
await handleWorksRoutes(
|
||||
createRendererRequest('POST', { projectId: project.id, project: projectMetadata, cover }),
|
||||
createRendererRequest('POST', { projectId: project.id, project: projectMetadata }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
{
|
||||
@@ -1443,13 +1445,13 @@ describe('works square host api routes', () => {
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
success: false,
|
||||
status: 503,
|
||||
code: 'WORKS_SQUARE_UNAVAILABLE',
|
||||
error: '发布服务暂时无法安全保存封面,请稍后重试。',
|
||||
status: 400,
|
||||
code: 'PROJECT_COVER_REQUIRED',
|
||||
error: '首次提交必须选择有效的 PNG、JPEG 或 WebP 项目封面。',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/api/projects/covers'))).toBe(false);
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/api/projects'))).toBe(false);
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/api/projects/with-cover'))).toBe(false);
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/versions/upload'))).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1465,11 +1467,7 @@ describe('works square host api routes', () => {
|
||||
creator_age: 12,
|
||||
status: 'published',
|
||||
};
|
||||
const cover = {
|
||||
fileName: 'replacement.png',
|
||||
mimeType: 'image/png',
|
||||
dataBase64: Buffer.from('unused replacement cover').toString('base64'),
|
||||
};
|
||||
const cover = validProjectCover('replacement.png');
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
project: {
|
||||
@@ -1533,11 +1531,7 @@ describe('works square host api routes', () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-unsafe-ownership-'));
|
||||
await writePublishableProject(tempDir);
|
||||
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
|
||||
const cover = {
|
||||
fileName: 'replacement.png',
|
||||
mimeType: 'image/png',
|
||||
dataBase64: Buffer.from('must not upload').toString('base64'),
|
||||
};
|
||||
const cover = validProjectCover('replacement.png');
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
project: {
|
||||
app_id: 'space-cleaner',
|
||||
@@ -1621,7 +1615,7 @@ describe('works square host api routes', () => {
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/versions/upload'))).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed when a 404 create conflict reveals published metadata that the form did not show', async () => {
|
||||
it('maps an atomic create race to metadata conflict without version upload', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-publish-race-'));
|
||||
await writePublishableProject(tempDir);
|
||||
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
|
||||
@@ -1630,26 +1624,15 @@ describe('works square host api routes', () => {
|
||||
title: 'Must not replace published title',
|
||||
summary: 'Must not replace published summary',
|
||||
};
|
||||
const ownedStatus = (status: 'published') => ({
|
||||
project: {
|
||||
app_id: 'space-cleaner',
|
||||
title: 'Existing title',
|
||||
summary: 'Existing summary',
|
||||
status,
|
||||
},
|
||||
latest_version: null,
|
||||
versions: [],
|
||||
});
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 404 }))
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 409 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(ownedStatus('published')), { status: 200 }));
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 409 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
prepareProjectReleaseMock.mockResolvedValueOnce(preparedRelease(tempDir));
|
||||
|
||||
await handleWorksRoutes(
|
||||
createRendererRequest('POST', { projectId: project.id, project: submittedMetadata }),
|
||||
createRendererRequest('POST', { projectId: project.id, project: submittedMetadata, cover: validProjectCover() }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
{
|
||||
@@ -1667,14 +1650,13 @@ describe('works square host api routes', () => {
|
||||
});
|
||||
expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([
|
||||
'https://square.nianxx.cn/api/projects/mine/space-cleaner/status',
|
||||
'https://square.nianxx.cn/api/projects',
|
||||
'https://square.nianxx.cn/api/projects/mine/space-cleaner/status',
|
||||
'https://square.nianxx.cn/api/projects/with-cover',
|
||||
]);
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/api/projects/covers'))).toBe(false);
|
||||
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'PATCH')).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed without PATCH or version upload when a create conflict reconfirms draft ownership', async () => {
|
||||
it('fails closed without PATCH or version upload when atomic create returns a conflict', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-draft-conflict-'));
|
||||
await writePublishableProject(tempDir);
|
||||
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
|
||||
@@ -1683,26 +1665,15 @@ describe('works square host api routes', () => {
|
||||
title: 'Updated title',
|
||||
summary: 'Updated summary',
|
||||
};
|
||||
const draftStatus = {
|
||||
project: {
|
||||
app_id: 'space-cleaner',
|
||||
title: 'Existing title',
|
||||
summary: 'Existing summary',
|
||||
status: 'draft',
|
||||
},
|
||||
latest_version: null,
|
||||
versions: [],
|
||||
};
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 404 }))
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 409 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(draftStatus), { status: 200 }));
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 409 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
prepareProjectReleaseMock.mockResolvedValueOnce(preparedRelease(tempDir));
|
||||
|
||||
await handleWorksRoutes(
|
||||
createRendererRequest('POST', { projectId: project.id, project: submittedMetadata }),
|
||||
createRendererRequest('POST', { projectId: project.id, project: submittedMetadata, cover: validProjectCover() }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
{
|
||||
@@ -1718,7 +1689,7 @@ describe('works square host api routes', () => {
|
||||
code: 'PROJECT_METADATA_CONFLICT',
|
||||
error: '作品状态已变化,本次未提交版本;请重新打开发布窗口确认现有资料。',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/api/projects/covers'))).toBe(false);
|
||||
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'PATCH')).toBe(false);
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/versions/upload'))).toBe(false);
|
||||
@@ -1800,6 +1771,7 @@ describe('works square host api routes', () => {
|
||||
title: '太空清洁队',
|
||||
summary: '收集漂浮垃圾的小游戏。',
|
||||
},
|
||||
cover: validProjectCover(),
|
||||
}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
@@ -1866,6 +1838,7 @@ describe('works square host api routes', () => {
|
||||
title: '太空清洁队',
|
||||
summary: '收集漂浮垃圾的小游戏。',
|
||||
},
|
||||
cover: validProjectCover(),
|
||||
}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
@@ -1914,6 +1887,7 @@ describe('works square host api routes', () => {
|
||||
title: '太空清洁队',
|
||||
summary: '收集漂浮垃圾的小游戏。',
|
||||
},
|
||||
cover: validProjectCover(),
|
||||
}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
@@ -1958,7 +1932,7 @@ describe('works square host api routes', () => {
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
const response = createResponse();
|
||||
await handleWorksRoutes(
|
||||
createRendererRequest('POST', { projectId: project.id, project: projectMetadata }),
|
||||
createRendererRequest('POST', { projectId: project.id, project: projectMetadata, cover: validProjectCover() }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
ctx,
|
||||
@@ -2005,6 +1979,7 @@ describe('works square host api routes', () => {
|
||||
title: '太空清洁队',
|
||||
summary: '收集漂浮垃圾的小游戏。',
|
||||
},
|
||||
cover: validProjectCover(),
|
||||
}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
@@ -2046,6 +2021,7 @@ describe('works square host api routes', () => {
|
||||
title: '太空清洁队',
|
||||
summary: '收集漂浮垃圾的小游戏。',
|
||||
},
|
||||
cover: validProjectCover(),
|
||||
}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
|
||||
Reference in New Issue
Block a user