补强静态发布安全边界
This commit is contained in:
@@ -3,6 +3,10 @@ import type { HostApiContext } from '../context';
|
||||
import { sendJson } from '../route-utils';
|
||||
import { hasRendererCapability } from '../renderer-capability';
|
||||
import { WORKS_SQUARE_CONFIG } from '../works-config';
|
||||
import {
|
||||
trustedWorksProjectPlayUrl,
|
||||
trustedWorksReleasePreviewUrl,
|
||||
} from '../works-play-url';
|
||||
import { getValidWorksSquareAccessToken } from '../../services/works-square-session';
|
||||
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
||||
import type { WorksSubmissionBindingRecord } from '../../../shared/works-submission-binding';
|
||||
@@ -80,46 +84,6 @@ function normalizedWorksBaseUrl(): URL {
|
||||
return new URL(`${base}/`);
|
||||
}
|
||||
|
||||
function isLoopbackHostname(hostname: string): boolean {
|
||||
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
return normalized === 'localhost'
|
||||
|| normalized === '::1'
|
||||
|| normalized === '0.0.0.0'
|
||||
|| normalized.startsWith('127.');
|
||||
}
|
||||
|
||||
function trustedLaunchUrl(value: string, worksBase: URL): string | null {
|
||||
try {
|
||||
const target = new URL(value, worksBase);
|
||||
const absoluteUrl = target.toString();
|
||||
if (
|
||||
absoluteUrl.length > 1_024
|
||||
|| worksBase.protocol !== 'https:'
|
||||
|| target.protocol !== 'https:'
|
||||
|| target.origin !== worksBase.origin
|
||||
|| Boolean(target.username || target.password)
|
||||
|| isLoopbackHostname(target.hostname)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return absoluteUrl;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function trustedReleasePreviewUrl(
|
||||
value: string,
|
||||
worksBase: URL,
|
||||
releaseId: string,
|
||||
): string | null {
|
||||
const trusted = trustedLaunchUrl(value, worksBase);
|
||||
if (!trusted) return null;
|
||||
const target = new URL(trusted);
|
||||
const expectedPrefix = `/previews/${encodeURIComponent(releaseId)}/`;
|
||||
return target.pathname.startsWith(expectedPrefix) ? trusted : null;
|
||||
}
|
||||
|
||||
async function requireManagedAccessToken(): Promise<string> {
|
||||
const accessToken = await getValidWorksSquareAccessToken();
|
||||
if (!accessToken) {
|
||||
@@ -294,7 +258,11 @@ async function resolveDevicePreview(
|
||||
const targetVersionMatches = remote.latestVersionId === deployment.version_id
|
||||
&& remote.latestVersionName === deployment.version_name;
|
||||
const launchUrl = remote.runtimeUrl
|
||||
? trustedLaunchUrl(remote.runtimeUrl, normalizedWorksBaseUrl())
|
||||
? trustedWorksProjectPlayUrl(
|
||||
remote.runtimeUrl,
|
||||
normalizedWorksBaseUrl(),
|
||||
deployment.app_id,
|
||||
)
|
||||
: null;
|
||||
|
||||
if (!targetVersionMatches) {
|
||||
@@ -334,7 +302,7 @@ async function resolveDevicePreview(
|
||||
remote.latestReleaseId,
|
||||
accessToken,
|
||||
);
|
||||
const previewUrl = trustedReleasePreviewUrl(
|
||||
const previewUrl = trustedWorksReleasePreviewUrl(
|
||||
rawPreviewUrl,
|
||||
normalizedWorksBaseUrl(),
|
||||
remote.latestReleaseId,
|
||||
|
||||
@@ -5,6 +5,8 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
import { hasRendererCapability } from '../renderer-capability';
|
||||
import { trustedWorksProjectPlayUrl } from '../works-play-url';
|
||||
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
||||
import { WORKS_SQUARE_CONFIG } from '../works-config';
|
||||
import {
|
||||
@@ -201,7 +203,6 @@ function projectSafeProject(value: unknown): Record<string, unknown> | null {
|
||||
'buddy_name',
|
||||
'buddy_sprite_url',
|
||||
'buddy_pose_url',
|
||||
'version_name',
|
||||
'testing_ask',
|
||||
'update_note',
|
||||
'remix_note',
|
||||
@@ -212,10 +213,45 @@ function projectSafeProject(value: unknown): Record<string, unknown> | null {
|
||||
const fieldValue = value[field];
|
||||
if (fieldValue === null || typeof fieldValue === 'string') projected[field] = fieldValue;
|
||||
}
|
||||
if (typeof value.playable === 'boolean') projected.playable = value.playable;
|
||||
|
||||
const versionName = readOptionalString(value.version_name);
|
||||
if (value.version_name !== undefined) projected.version_name = versionName ?? null;
|
||||
projected.playable = false;
|
||||
projected.play_url = null;
|
||||
projected.runtime_url = null;
|
||||
|
||||
if (value.playable === true && versionName) {
|
||||
const playUrlPresent = value.play_url !== undefined && value.play_url !== null;
|
||||
const primaryPlayUrl = readOptionalString(value.play_url);
|
||||
const fallbackRuntimeUrl = readOptionalString(value.runtime_url);
|
||||
const candidate = playUrlPresent ? primaryPlayUrl : fallbackRuntimeUrl;
|
||||
const trusted = candidate
|
||||
? trustedWorksProjectPlayUrl(
|
||||
candidate,
|
||||
new URL(`${normalizeWorksBase()}/`),
|
||||
appId,
|
||||
)
|
||||
: null;
|
||||
if (trusted) {
|
||||
projected.playable = true;
|
||||
if (playUrlPresent) projected.play_url = trusted;
|
||||
else projected.runtime_url = trusted;
|
||||
}
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
|
||||
function projectSafeProjectPage(value: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(value) || !Array.isArray(value.items)) return null;
|
||||
const items = value.items.map(projectSafeProject);
|
||||
if (items.some((item) => item === null)) return null;
|
||||
const nextCursor = readNullableStringField(value, 'next_cursor');
|
||||
if (nextCursor === undefined || typeof value.limit !== 'number' || !Number.isFinite(value.limit)) {
|
||||
return null;
|
||||
}
|
||||
return { items, next_cursor: nextCursor, limit: value.limit };
|
||||
}
|
||||
|
||||
function projectSafeVersion(value: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const id = readOptionalString(value.id);
|
||||
@@ -296,7 +332,12 @@ async function handleListProjects(res: ServerResponse, url: URL): Promise<void>
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, 200, { success: true, page: await readResponsePayload(response) });
|
||||
const page = projectSafeProjectPage(await readResponsePayload(response));
|
||||
if (!page) {
|
||||
sendJson(res, 502, { success: false, error: 'Works Square returned an invalid project list' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, { success: true, page });
|
||||
}
|
||||
|
||||
async function handleGetProject(res: ServerResponse, appId: string): Promise<void> {
|
||||
@@ -307,7 +348,12 @@ async function handleGetProject(res: ServerResponse, appId: string): Promise<voi
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, 200, { success: true, project: await readResponsePayload(response) });
|
||||
const project = projectSafeProject(await readResponsePayload(response));
|
||||
if (!project) {
|
||||
sendJson(res, 502, { success: false, error: 'Works Square returned an invalid project' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, { success: true, project });
|
||||
}
|
||||
|
||||
async function handleListAssets(res: ServerResponse, url: URL): Promise<void> {
|
||||
@@ -433,7 +479,12 @@ async function handleCreateProject(req: IncomingMessage, res: ServerResponse): P
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, response.status, { success: true, project: await readResponsePayload(response) });
|
||||
const project = projectSafeProject(await readResponsePayload(response));
|
||||
if (!project) {
|
||||
sendJson(res, 502, { success: false, error: 'Works Square returned an invalid project' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, response.status, { success: true, project });
|
||||
}
|
||||
|
||||
async function handleListMyProjects(
|
||||
@@ -457,7 +508,12 @@ async function handleListMyProjects(
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, response.status, { success: true, page: await readResponsePayload(response) });
|
||||
const page = projectSafeProjectPage(await readResponsePayload(response));
|
||||
if (!page) {
|
||||
sendJson(res, 502, { success: false, error: 'Works Square returned an invalid project list' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, response.status, { success: true, page });
|
||||
}
|
||||
|
||||
async function handleGetBillingTokenUsage(
|
||||
@@ -619,6 +675,10 @@ async function handleGetMyProjectStatus(
|
||||
const SOURCE_PUBLISH_CHANGE_LOG = '通过 Makelore 一键提交';
|
||||
const RETRYABLE_SOURCE_UPLOAD_STATUSES = new Set([408, 502, 503, 504]);
|
||||
const VERSION_FILE_MAX_BYTES = 64 * 1024;
|
||||
const LOCAL_PREVIEW_BINDING_WARNING = {
|
||||
code: 'LOCAL_PREVIEW_BINDING_SAVE_FAILED',
|
||||
message: '已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交。',
|
||||
} as const;
|
||||
|
||||
function createFallbackVersionName(now = new Date()): string {
|
||||
return `v${now.toISOString().replace(/\D/g, '').slice(0, 14)}`;
|
||||
@@ -841,6 +901,7 @@ async function handlePublishProjectSource(
|
||||
);
|
||||
return;
|
||||
}
|
||||
let bindingWarning: typeof LOCAL_PREVIEW_BINDING_WARNING | undefined;
|
||||
if (ctx.worksSubmissionBinding) {
|
||||
try {
|
||||
await ctx.worksSubmissionBinding.recordSubmitted(projectId, {
|
||||
@@ -852,13 +913,17 @@ async function handlePublishProjectSource(
|
||||
});
|
||||
} catch {
|
||||
logger.warn('[works] One-click submission succeeded, but local preview mapping could not be saved');
|
||||
bindingWarning = LOCAL_PREVIEW_BINDING_WARNING;
|
||||
}
|
||||
} else {
|
||||
bindingWarning = LOCAL_PREVIEW_BINDING_WARNING;
|
||||
}
|
||||
const { archivePath: _archivePath, ...rendererPackageSummary } = packageSummary;
|
||||
sendJson(res, uploadResponse.status, {
|
||||
success: true,
|
||||
package: rendererPackageSummary,
|
||||
upload: uploadPayload,
|
||||
...(bindingWarning ? { binding_warning: bindingWarning } : {}),
|
||||
});
|
||||
} finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => undefined);
|
||||
@@ -1005,6 +1070,15 @@ export async function handleWorksRoutes(
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/works/projects/publish-source' && req.method === 'POST') {
|
||||
if (!hasRendererCapability(req)) {
|
||||
sendJson(res, 403, {
|
||||
success: false,
|
||||
status: 403,
|
||||
code: 'RENDERER_CAPABILITY_REQUIRED',
|
||||
error: 'Renderer capability required',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
await handlePublishProjectSource(req, res, ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
62
electron/api/works-play-url.ts
Normal file
62
electron/api/works-play-url.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
const MAX_WORKS_PLAY_URL_LENGTH = 1_024;
|
||||
|
||||
function isLoopbackHostname(hostname: string): boolean {
|
||||
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
return normalized === 'localhost'
|
||||
|| normalized.endsWith('.localhost')
|
||||
|| normalized === '::'
|
||||
|| normalized === '::1'
|
||||
|| normalized.startsWith('::ffff:127.')
|
||||
|| normalized.startsWith('::ffff:7f00:')
|
||||
|| normalized === '0.0.0.0'
|
||||
|| normalized.startsWith('127.');
|
||||
}
|
||||
|
||||
function trustedWorksHttpsUrl(value: string, worksBase: URL): URL | null {
|
||||
try {
|
||||
const target = new URL(value, worksBase);
|
||||
if (
|
||||
target.toString().length > MAX_WORKS_PLAY_URL_LENGTH
|
||||
|| worksBase.protocol !== 'https:'
|
||||
|| target.protocol !== 'https:'
|
||||
|| target.origin !== worksBase.origin
|
||||
|| Boolean(target.username || target.password)
|
||||
|| isLoopbackHostname(target.hostname)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return target;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function trustedWorksProjectPlayUrl(
|
||||
value: string,
|
||||
worksBase: URL,
|
||||
appId: string,
|
||||
): string | null {
|
||||
const normalizedAppId = appId.trim();
|
||||
if (!normalizedAppId) return null;
|
||||
const target = trustedWorksHttpsUrl(value, worksBase);
|
||||
if (
|
||||
!target
|
||||
|| target.pathname !== `/apps/${encodeURIComponent(normalizedAppId)}/`
|
||||
|| target.search
|
||||
|| target.hash
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return target.toString();
|
||||
}
|
||||
|
||||
export function trustedWorksReleasePreviewUrl(
|
||||
value: string,
|
||||
worksBase: URL,
|
||||
releaseId: string,
|
||||
): string | null {
|
||||
const target = trustedWorksHttpsUrl(value, worksBase);
|
||||
if (!target) return null;
|
||||
const expectedPrefix = `/previews/${encodeURIComponent(releaseId)}/`;
|
||||
return target.pathname.startsWith(expectedPrefix) ? target.toString() : null;
|
||||
}
|
||||
@@ -139,7 +139,7 @@ async function readProductOverview(projectPath: string): Promise<string> {
|
||||
async function resolveProjectProgressIdentity(
|
||||
project: OpencodeProject,
|
||||
): Promise<ProjectProgressIdentity> {
|
||||
const binding = await readWorksSubmissionBinding(project.path);
|
||||
const binding = await readWorksSubmissionBinding(project.path, project.id);
|
||||
if (binding?.status === 'submitted' && binding.app_id) {
|
||||
return { projectType: 'server', projectKey: binding.app_id };
|
||||
}
|
||||
|
||||
@@ -47,15 +47,58 @@ function readString(value: unknown): string | null {
|
||||
function parseCurrentRecord(value: unknown): WorksSubmissionBindingRecord | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
record.schema_version !== WORKS_SUBMISSION_BINDING_SCHEMA_VERSION
|
||||
|| (record.status !== 'submitted' && record.status !== 'legacy_retired')
|
||||
) return null;
|
||||
if (record.schema_version !== WORKS_SUBMISSION_BINDING_SCHEMA_VERSION) return null;
|
||||
const projectId = readString(record.project_id);
|
||||
const requestedAt = readString(record.requested_at);
|
||||
const updatedAt = readString(record.updated_at);
|
||||
if (!projectId || !requestedAt || !updatedAt) return null;
|
||||
return record as WorksSubmissionBindingRecord;
|
||||
|
||||
if (record.status === 'submitted') {
|
||||
const appId = readString(record.app_id);
|
||||
const versionId = readString(record.version_id);
|
||||
const versionName = readString(record.version_name);
|
||||
const reviewStatus = record.review_status === undefined
|
||||
? null
|
||||
: readString(record.review_status);
|
||||
const zipSha256 = record.zip_sha256 === undefined
|
||||
? null
|
||||
: readString(record.zip_sha256);
|
||||
if (
|
||||
!appId
|
||||
|| !versionId
|
||||
|| !versionName
|
||||
|| (record.review_status !== undefined && !reviewStatus)
|
||||
|| (record.zip_sha256 !== undefined && (!zipSha256 || !/^[a-f0-9]{64}$/i.test(zipSha256)))
|
||||
) return null;
|
||||
return {
|
||||
schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION,
|
||||
project_id: projectId,
|
||||
status: 'submitted',
|
||||
requested_at: requestedAt,
|
||||
updated_at: updatedAt,
|
||||
app_id: appId,
|
||||
version_id: versionId,
|
||||
version_name: versionName,
|
||||
...(reviewStatus ? { review_status: reviewStatus } : {}),
|
||||
...(zipSha256 ? { zip_sha256: zipSha256.toLowerCase() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const message = readString(record.message);
|
||||
if (
|
||||
record.status !== 'legacy_retired'
|
||||
|| record.error_code !== 'LEGACY_AUTO_DEPLOY_RETIRED'
|
||||
|| !message
|
||||
) return null;
|
||||
return {
|
||||
schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION,
|
||||
project_id: projectId,
|
||||
status: 'legacy_retired',
|
||||
requested_at: requestedAt,
|
||||
updated_at: updatedAt,
|
||||
error_code: 'LEGACY_AUTO_DEPLOY_RETIRED',
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function parseLegacyRecord(value: unknown): LegacyBindingRecord | null {
|
||||
@@ -73,23 +116,25 @@ function parseLegacyRecord(value: unknown): LegacyBindingRecord | null {
|
||||
|
||||
function migrateLegacyRecord(record: LegacyBindingRecord): WorksSubmissionBindingRecord {
|
||||
const now = new Date().toISOString();
|
||||
if (
|
||||
record.status === 'submitted'
|
||||
&& readString(record.app_id)
|
||||
&& readString(record.version_id)
|
||||
&& readString(record.version_name)
|
||||
) {
|
||||
const appId = readString(record.app_id);
|
||||
const versionId = readString(record.version_id);
|
||||
const versionName = readString(record.version_name);
|
||||
if (record.status === 'submitted' && appId && versionId && versionName) {
|
||||
const reviewStatus = readString(record.review_status);
|
||||
const zipSha256 = readString(record.zip_sha256);
|
||||
return {
|
||||
schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION,
|
||||
project_id: record.project_id,
|
||||
status: 'submitted',
|
||||
requested_at: record.requested_at,
|
||||
updated_at: now,
|
||||
app_id: record.app_id,
|
||||
version_id: record.version_id,
|
||||
version_name: record.version_name,
|
||||
...(readString(record.review_status) ? { review_status: record.review_status } : {}),
|
||||
...(readString(record.zip_sha256) ? { zip_sha256: record.zip_sha256 } : {}),
|
||||
app_id: appId,
|
||||
version_id: versionId,
|
||||
version_name: versionName,
|
||||
...(reviewStatus ? { review_status: reviewStatus } : {}),
|
||||
...(zipSha256 && /^[a-f0-9]{64}$/i.test(zipSha256)
|
||||
? { zip_sha256: zipSha256.toLowerCase() }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,6 +165,7 @@ async function persistRecord(
|
||||
|
||||
export async function readWorksSubmissionBinding(
|
||||
projectPath: string,
|
||||
expectedProjectId?: string,
|
||||
): Promise<WorksSubmissionBindingRecord | null> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
@@ -132,9 +178,9 @@ export async function readWorksSubmissionBinding(
|
||||
}
|
||||
|
||||
const current = parseCurrentRecord(parsed);
|
||||
if (current) return current;
|
||||
if (current) return !expectedProjectId || current.project_id === expectedProjectId ? current : null;
|
||||
const legacy = parseLegacyRecord(parsed);
|
||||
if (!legacy) return null;
|
||||
if (!legacy || (expectedProjectId && legacy.project_id !== expectedProjectId)) return null;
|
||||
return persistRecord(projectPath, migrateLegacyRecord(legacy));
|
||||
}
|
||||
|
||||
@@ -162,13 +208,13 @@ export function createWorksSubmissionBindingStore(projectStore: ProjectStore) {
|
||||
|
||||
async function get(projectId: string): Promise<WorksSubmissionBindingRecord | null> {
|
||||
const project = (await projectStore.listProjects()).find((item) => item.id === projectId);
|
||||
return project ? readWorksSubmissionBinding(project.path) : null;
|
||||
return project ? readWorksSubmissionBinding(project.path, project.id) : null;
|
||||
}
|
||||
|
||||
async function start(): Promise<void> {
|
||||
for (const project of await projectStore.listProjects()) {
|
||||
try {
|
||||
await readWorksSubmissionBinding(project.path);
|
||||
await readWorksSubmissionBinding(project.path, project.id);
|
||||
} catch (error) {
|
||||
logger.warn(`[works-submission-binding] Failed to migrate ${project.id}`, error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user