feat: migrate to Cloudflare Workers with icon and version upload
Replace Express/SQLite and web/ with frontend/, worker/, and wrangler deploy. Add optional app icon upload, date-based app_version, and build-app workflow input. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
15
worker/migrations/0001_init.sql
Normal file
15
worker/migrations/0001_init.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS builds (
|
||||
id TEXT PRIMARY KEY,
|
||||
app_name TEXT NOT NULL,
|
||||
app_name_en TEXT NOT NULL DEFAULT '',
|
||||
app_identifier TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
workflow_run_id INTEGER,
|
||||
windows_url TEXT,
|
||||
android_url TEXT,
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_builds_created_at ON builds(created_at DESC);
|
||||
1
worker/migrations/0002_add_version.sql
Normal file
1
worker/migrations/0002_add_version.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE builds ADD COLUMN app_version TEXT NOT NULL DEFAULT '1.0.0';
|
||||
13
worker/package.json
Normal file
13
worker/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "web2app-worker",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2",
|
||||
"nanoid": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20241127.0"
|
||||
}
|
||||
}
|
||||
128
worker/src/db/builds.ts
Normal file
128
worker/src/db/builds.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { Env } from "../env";
|
||||
|
||||
export type BuildStatus =
|
||||
| "pending"
|
||||
| "queued"
|
||||
| "in_progress"
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
export interface BuildRecord {
|
||||
id: string;
|
||||
app_name: string;
|
||||
app_name_en: string;
|
||||
app_identifier: string;
|
||||
app_version: string;
|
||||
status: BuildStatus;
|
||||
workflow_run_id: number | null;
|
||||
windows_url: string | null;
|
||||
android_url: string | null;
|
||||
error: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export async function insertBuild(
|
||||
env: Env,
|
||||
record: {
|
||||
id: string;
|
||||
appName: string;
|
||||
appNameEn: string;
|
||||
appIdentifier: string;
|
||||
appVersion: string;
|
||||
},
|
||||
): Promise<BuildRecord> {
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO builds (id, app_name, app_name_en, app_identifier, app_version, status)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending')`,
|
||||
)
|
||||
.bind(
|
||||
record.id,
|
||||
record.appName,
|
||||
record.appNameEn,
|
||||
record.appIdentifier,
|
||||
record.appVersion,
|
||||
)
|
||||
.run();
|
||||
|
||||
const row = await getBuild(env, record.id);
|
||||
if (!row) throw new Error("Failed to insert build");
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function getBuild(
|
||||
env: Env,
|
||||
id: string,
|
||||
): Promise<BuildRecord | null> {
|
||||
const row = await env.DB.prepare("SELECT * FROM builds WHERE id = ?")
|
||||
.bind(id)
|
||||
.first<BuildRecord>();
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function listBuilds(
|
||||
env: Env,
|
||||
limit = 20,
|
||||
): Promise<BuildRecord[]> {
|
||||
const { results } = await env.DB.prepare(
|
||||
"SELECT * FROM builds ORDER BY created_at DESC LIMIT ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.all<BuildRecord>();
|
||||
return results ?? [];
|
||||
}
|
||||
|
||||
export async function updateBuild(
|
||||
env: Env,
|
||||
id: string,
|
||||
patch: Partial<
|
||||
Pick<
|
||||
BuildRecord,
|
||||
| "status"
|
||||
| "workflow_run_id"
|
||||
| "windows_url"
|
||||
| "android_url"
|
||||
| "error"
|
||||
>
|
||||
>,
|
||||
): Promise<BuildRecord | null> {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value !== undefined) {
|
||||
fields.push(`${key} = ?`);
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length === 0) return getBuild(env, id);
|
||||
|
||||
fields.push("updated_at = datetime('now')");
|
||||
values.push(id);
|
||||
|
||||
await env.DB.prepare(
|
||||
`UPDATE builds SET ${fields.join(", ")} WHERE id = ?`,
|
||||
)
|
||||
.bind(...values)
|
||||
.run();
|
||||
|
||||
return getBuild(env, id);
|
||||
}
|
||||
|
||||
export function toPublicBuild(record: BuildRecord) {
|
||||
return {
|
||||
id: record.id,
|
||||
appName: record.app_name,
|
||||
appNameEn: record.app_name_en || record.app_name,
|
||||
appIdentifier: record.app_identifier,
|
||||
appVersion: record.app_version || "1.0.0",
|
||||
status: record.status,
|
||||
workflowRunId: record.workflow_run_id,
|
||||
windowsUrl: record.windows_url,
|
||||
androidUrl: record.android_url,
|
||||
error: record.error,
|
||||
createdAt: record.created_at,
|
||||
updatedAt: record.updated_at,
|
||||
};
|
||||
}
|
||||
9
worker/src/env.d.ts
vendored
Normal file
9
worker/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface Env {
|
||||
DB: D1Database;
|
||||
ASSETS: Fetcher;
|
||||
GITHUB_TOKEN: string;
|
||||
GITHUB_OWNER: string;
|
||||
GITHUB_REPO: string;
|
||||
DEFAULT_BRANCH?: string;
|
||||
MAX_UPLOAD_MB?: string;
|
||||
}
|
||||
19
worker/src/index.ts
Normal file
19
worker/src/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Env } from "./env";
|
||||
import { handleBuildsRequest } from "./routes/builds";
|
||||
import { jsonResponse } from "./lib/response";
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === "/api/health") {
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/builds")) {
|
||||
return handleBuildsRequest(request, env, url);
|
||||
}
|
||||
|
||||
return env.ASSETS.fetch(request);
|
||||
},
|
||||
};
|
||||
17
worker/src/lib/response.ts
Normal file
17
worker/src/lib/response.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function jsonResponse(
|
||||
data: unknown,
|
||||
status = 200,
|
||||
headers?: Record<string, string>,
|
||||
): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function errorResponse(message: string, status: number): Response {
|
||||
return jsonResponse({ error: message }, status);
|
||||
}
|
||||
226
worker/src/routes/builds.ts
Normal file
226
worker/src/routes/builds.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import type { Env } from "../env";
|
||||
import {
|
||||
getBuild,
|
||||
insertBuild,
|
||||
listBuilds,
|
||||
toPublicBuild,
|
||||
updateBuild,
|
||||
type BuildRecord,
|
||||
} from "../db/builds";
|
||||
import { errorResponse, jsonResponse } from "../lib/response";
|
||||
import {
|
||||
getActionsRunUrl,
|
||||
getReleaseAssets,
|
||||
getWorkflowRun,
|
||||
triggerBuildWorkflow,
|
||||
uploadBuildFile,
|
||||
uploadSiteZip,
|
||||
} from "../services/github";
|
||||
import { IconValidationError, resolveIconUpload } from "../services/icon";
|
||||
import {
|
||||
validateAppVersion,
|
||||
VersionValidationError,
|
||||
} from "../services/version";
|
||||
import {
|
||||
normalizeAppIdentifier,
|
||||
slugifyIdentifier,
|
||||
validateChineseAppName,
|
||||
validateEnglishAppName,
|
||||
validateZipBuffer,
|
||||
ZipValidationError,
|
||||
} from "../services/zip";
|
||||
|
||||
function maxUploadBytes(env: Env): number {
|
||||
const mb = Number(env.MAX_UPLOAD_MB ?? "50");
|
||||
return mb * 1024 * 1024;
|
||||
}
|
||||
|
||||
export async function handleBuildsRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
url: URL,
|
||||
): Promise<Response> {
|
||||
const path = url.pathname;
|
||||
|
||||
if (path === "/api/builds" && request.method === "GET") {
|
||||
const builds = (await listBuilds(env)).map(toPublicBuild);
|
||||
return jsonResponse({ builds });
|
||||
}
|
||||
|
||||
const match = path.match(/^\/api\/builds\/([^/]+)$/);
|
||||
if (match) {
|
||||
const id = match[1];
|
||||
|
||||
if (request.method === "GET") {
|
||||
const record = await getBuild(env, id);
|
||||
if (!record) {
|
||||
return errorResponse("Build not found", 404);
|
||||
}
|
||||
|
||||
const refreshed = await refreshBuildStatus(env, record);
|
||||
return jsonResponse({
|
||||
...toPublicBuild(refreshed),
|
||||
actionsUrl: getActionsRunUrl(env, refreshed.workflow_run_id),
|
||||
});
|
||||
}
|
||||
|
||||
return errorResponse("Method not allowed", 405);
|
||||
}
|
||||
|
||||
if (path === "/api/builds" && request.method === "POST") {
|
||||
return createBuild(request, env);
|
||||
}
|
||||
|
||||
return errorResponse("Not found", 404);
|
||||
}
|
||||
|
||||
async function createBuild(request: Request, env: Env): Promise<Response> {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const appNameZh = validateChineseAppName(
|
||||
String(formData.get("appNameZh") ?? formData.get("appName") ?? ""),
|
||||
);
|
||||
const appNameEn = validateEnglishAppName(
|
||||
String(formData.get("appNameEn") ?? ""),
|
||||
);
|
||||
const identifierInput = String(formData.get("identifier") ?? "").trim();
|
||||
const appVersion = validateAppVersion(
|
||||
String(formData.get("appVersion") ?? ""),
|
||||
);
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return errorResponse("file is required", 400);
|
||||
}
|
||||
|
||||
if (!file.name.toLowerCase().endsWith(".zip")) {
|
||||
return errorResponse("Only .zip files are supported", 400);
|
||||
}
|
||||
|
||||
const buffer = new Uint8Array(await file.arrayBuffer());
|
||||
const { normalizedBuffer } = validateZipBuffer(buffer, maxUploadBytes(env));
|
||||
const jobId = nanoid(10);
|
||||
const slug = slugifyIdentifier(appNameEn) || jobId.toLowerCase();
|
||||
const appIdentifier = normalizeAppIdentifier(
|
||||
identifierInput || `com.web2app.${slug}`,
|
||||
);
|
||||
|
||||
await insertBuild(env, {
|
||||
id: jobId,
|
||||
appName: appNameZh,
|
||||
appNameEn,
|
||||
appIdentifier,
|
||||
appVersion,
|
||||
});
|
||||
|
||||
await uploadSiteZip(env, jobId, normalizedBuffer);
|
||||
await uploadBuildFile(
|
||||
env,
|
||||
jobId,
|
||||
"version.txt",
|
||||
new TextEncoder().encode(appVersion),
|
||||
);
|
||||
|
||||
const iconFile = formData.get("icon");
|
||||
if (iconFile instanceof File && iconFile.size > 0) {
|
||||
const iconBuffer = new Uint8Array(await iconFile.arrayBuffer());
|
||||
const { repoPath } = resolveIconUpload(iconFile, iconBuffer);
|
||||
await uploadBuildFile(env, jobId, repoPath, iconBuffer);
|
||||
}
|
||||
|
||||
await updateBuild(env, jobId, { status: "queued" });
|
||||
|
||||
const workflowRunId = await triggerBuildWorkflow(env, {
|
||||
jobId,
|
||||
appName: appNameZh,
|
||||
appNameEn,
|
||||
appIdentifier,
|
||||
});
|
||||
|
||||
await updateBuild(env, jobId, {
|
||||
workflow_run_id: workflowRunId,
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
return jsonResponse(
|
||||
{
|
||||
id: jobId,
|
||||
status: "in_progress",
|
||||
workflowRunId,
|
||||
},
|
||||
201,
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ZipValidationError ||
|
||||
error instanceof VersionValidationError ||
|
||||
error instanceof IconValidationError
|
||||
) {
|
||||
return errorResponse(error.message, 400);
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
return errorResponse(
|
||||
error instanceof Error ? error.message : "Failed to create build",
|
||||
500,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshBuildStatus(
|
||||
env: Env,
|
||||
record: BuildRecord,
|
||||
): Promise<BuildRecord> {
|
||||
if (!record.workflow_run_id) return record;
|
||||
if (record.status === "completed" || record.status === "failed") {
|
||||
return record;
|
||||
}
|
||||
|
||||
try {
|
||||
const run = await getWorkflowRun(env, record.workflow_run_id);
|
||||
|
||||
if (run.status === "queued") {
|
||||
return (await updateBuild(env, record.id, { status: "queued" })) ?? record;
|
||||
}
|
||||
|
||||
if (run.status === "in_progress") {
|
||||
return (
|
||||
(await updateBuild(env, record.id, { status: "in_progress" })) ?? record
|
||||
);
|
||||
}
|
||||
|
||||
if (run.status === "completed") {
|
||||
if (run.conclusion === "success") {
|
||||
const assets = await getReleaseAssets(env, record.id);
|
||||
return (
|
||||
(await updateBuild(env, record.id, {
|
||||
status: "completed",
|
||||
windows_url: assets.windowsUrl,
|
||||
android_url: assets.androidUrl,
|
||||
error:
|
||||
assets.windowsUrl && assets.androidUrl
|
||||
? null
|
||||
: "Build finished but release assets were not found yet",
|
||||
})) ?? record
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
(await updateBuild(env, record.id, {
|
||||
status: "failed",
|
||||
error: `Workflow failed with conclusion: ${run.conclusion ?? "unknown"}`,
|
||||
})) ?? record
|
||||
);
|
||||
}
|
||||
|
||||
return record;
|
||||
} catch (error) {
|
||||
return (
|
||||
(await updateBuild(env, record.id, {
|
||||
status: "failed",
|
||||
error: error instanceof Error ? error.message : "Status refresh failed",
|
||||
})) ?? record
|
||||
);
|
||||
}
|
||||
}
|
||||
252
worker/src/services/github.ts
Normal file
252
worker/src/services/github.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import type { Env } from "../env";
|
||||
|
||||
function getGitHubConfig(env: Env) {
|
||||
return {
|
||||
owner: (env.GITHUB_OWNER ?? "").trim(),
|
||||
repo: (env.GITHUB_REPO ?? "").trim(),
|
||||
token: (env.GITHUB_TOKEN ?? "").trim(),
|
||||
branch: (env.DEFAULT_BRANCH ?? "main").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function assertGitHubConfig(env: Env) {
|
||||
const { owner, repo, token } = getGitHubConfig(env);
|
||||
if (!owner || !repo || !token) {
|
||||
throw new Error(
|
||||
"Missing GITHUB_OWNER, GITHUB_REPO, or GITHUB_TOKEN configuration",
|
||||
);
|
||||
}
|
||||
return { owner, repo, token };
|
||||
}
|
||||
|
||||
async function githubFetch(
|
||||
env: Env,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const { token } = assertGitHubConfig(env);
|
||||
return fetch(`https://api.github.com${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "web2app-worker",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export async function uploadBuildFile(
|
||||
env: Env,
|
||||
jobId: string,
|
||||
relativePath: string,
|
||||
fileBuffer: Uint8Array,
|
||||
): Promise<void> {
|
||||
const { owner, repo } = assertGitHubConfig(env);
|
||||
const path = `builds/${jobId}/${relativePath}`;
|
||||
const content = bytesToBase64(fileBuffer);
|
||||
|
||||
const put = async (sha?: string) => {
|
||||
const body: Record<string, string> = {
|
||||
message: sha
|
||||
? `chore: update ${relativePath} for build ${jobId}`
|
||||
: `chore: upload ${relativePath} for build ${jobId}`,
|
||||
content,
|
||||
};
|
||||
if (sha) body.sha = sha;
|
||||
|
||||
return githubFetch(env, `/repos/${owner}/${repo}/contents/${path}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
};
|
||||
|
||||
let response = await put();
|
||||
if (response.status === 422) {
|
||||
const getResponse = await githubFetch(
|
||||
env,
|
||||
`/repos/${owner}/${repo}/contents/${path}`,
|
||||
);
|
||||
if (!getResponse.ok) {
|
||||
const text = await getResponse.text();
|
||||
throw new Error(`GitHub get content failed (${getResponse.status}): ${text}`);
|
||||
}
|
||||
|
||||
const existing = (await getResponse.json()) as {
|
||||
sha?: string;
|
||||
type?: string;
|
||||
};
|
||||
if (!existing.sha || existing.type !== "file") {
|
||||
throw new Error(`Unexpected content at ${path}`);
|
||||
}
|
||||
|
||||
response = await put(existing.sha);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`GitHub upload failed (${response.status}): ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadSiteZip(
|
||||
env: Env,
|
||||
jobId: string,
|
||||
zipBuffer: Uint8Array,
|
||||
): Promise<void> {
|
||||
return uploadBuildFile(env, jobId, "site.zip", zipBuffer);
|
||||
}
|
||||
|
||||
export async function triggerBuildWorkflow(
|
||||
env: Env,
|
||||
input: {
|
||||
jobId: string;
|
||||
appName: string;
|
||||
appNameEn: string;
|
||||
appIdentifier: string;
|
||||
},
|
||||
): Promise<number> {
|
||||
const { owner, repo, branch } = getGitHubConfig(env);
|
||||
assertGitHubConfig(env);
|
||||
|
||||
const dispatchResponse = await githubFetch(
|
||||
env,
|
||||
`/repos/${owner}/${repo}/actions/workflows/build-app.yml/dispatches`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ref: branch,
|
||||
inputs: {
|
||||
job_id: input.jobId,
|
||||
app_name: input.appName,
|
||||
app_name_en: input.appNameEn,
|
||||
app_identifier: input.appIdentifier,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!dispatchResponse.ok) {
|
||||
const text = await dispatchResponse.text();
|
||||
throw new Error(
|
||||
`Workflow dispatch failed (${dispatchResponse.status}): ${text}`,
|
||||
);
|
||||
}
|
||||
|
||||
await sleep(3000);
|
||||
|
||||
const since = new Date(Date.now() - 120_000).toISOString();
|
||||
const runsResponse = await githubFetch(
|
||||
env,
|
||||
`/repos/${owner}/${repo}/actions/workflows/build-app.yml/runs?event=workflow_dispatch&per_page=10`,
|
||||
);
|
||||
|
||||
if (!runsResponse.ok) {
|
||||
const text = await runsResponse.text();
|
||||
throw new Error(`Failed to list workflow runs: ${text}`);
|
||||
}
|
||||
|
||||
const runsData = (await runsResponse.json()) as {
|
||||
workflow_runs: Array<{ id: number; created_at: string }>;
|
||||
};
|
||||
|
||||
const run = runsData.workflow_runs
|
||||
.filter((item) => item.created_at >= since)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
)[0];
|
||||
|
||||
if (!run) {
|
||||
throw new Error("Failed to locate workflow run after dispatch");
|
||||
}
|
||||
|
||||
return run.id;
|
||||
}
|
||||
|
||||
export async function getWorkflowRun(env: Env, runId: number) {
|
||||
const { owner, repo } = assertGitHubConfig(env);
|
||||
const response = await githubFetch(
|
||||
env,
|
||||
`/repos/${owner}/${repo}/actions/runs/${runId}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Failed to get workflow run: ${text}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as {
|
||||
status: string;
|
||||
conclusion: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getReleaseAssets(
|
||||
env: Env,
|
||||
jobId: string,
|
||||
): Promise<{
|
||||
windowsUrl: string | null;
|
||||
androidUrl: string | null;
|
||||
}> {
|
||||
const { owner, repo } = assertGitHubConfig(env);
|
||||
const tag = `build-${jobId}`;
|
||||
|
||||
const response = await githubFetch(
|
||||
env,
|
||||
`/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { windowsUrl: null, androidUrl: null };
|
||||
}
|
||||
|
||||
const release = (await response.json()) as {
|
||||
assets: Array<{ name: string; browser_download_url: string }>;
|
||||
};
|
||||
|
||||
let windowsUrl: string | null = null;
|
||||
let androidUrl: string | null = null;
|
||||
|
||||
for (const asset of release.assets) {
|
||||
const name = asset.name.toLowerCase();
|
||||
if (
|
||||
!windowsUrl &&
|
||||
(name.endsWith(".exe") ||
|
||||
name.endsWith(".msi") ||
|
||||
name.includes("windows"))
|
||||
) {
|
||||
windowsUrl = asset.browser_download_url;
|
||||
}
|
||||
if (
|
||||
!androidUrl &&
|
||||
(name.endsWith(".apk") || name.includes("android"))
|
||||
) {
|
||||
androidUrl = asset.browser_download_url;
|
||||
}
|
||||
}
|
||||
|
||||
return { windowsUrl, androidUrl };
|
||||
}
|
||||
|
||||
export function getActionsRunUrl(env: Env, runId: number | null): string | null {
|
||||
const { owner, repo } = getGitHubConfig(env);
|
||||
if (!runId || !owner || !repo) return null;
|
||||
return `https://github.com/${owner}/${repo}/actions/runs/${runId}`;
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
50
worker/src/services/icon.ts
Normal file
50
worker/src/services/icon.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export class IconValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "IconValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_ICON_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
const ALLOWED_TYPES = new Set([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/x-icon",
|
||||
"image/vnd.microsoft.icon",
|
||||
]);
|
||||
|
||||
const ALLOWED_EXT = new Set([".png", ".jpg", ".jpeg", ".ico"]);
|
||||
|
||||
export function resolveIconUpload(
|
||||
file: File,
|
||||
buffer: Uint8Array,
|
||||
): { repoPath: string; fileName: string } {
|
||||
if (buffer.length > MAX_ICON_BYTES) {
|
||||
throw new IconValidationError("图标文件不能超过 2MB");
|
||||
}
|
||||
|
||||
const ext = pathExt(file.name).toLowerCase();
|
||||
if (!ALLOWED_EXT.has(ext)) {
|
||||
throw new IconValidationError("图标仅支持 PNG、JPG、ICO 格式");
|
||||
}
|
||||
|
||||
const type = file.type.toLowerCase();
|
||||
if (type && !ALLOWED_TYPES.has(type) && type !== "application/octet-stream") {
|
||||
throw new IconValidationError("不支持的图标 MIME 类型");
|
||||
}
|
||||
|
||||
if (ext === ".ico") {
|
||||
return { repoPath: "favicon.ico", fileName: "favicon.ico" };
|
||||
}
|
||||
if (ext === ".jpg" || ext === ".jpeg") {
|
||||
return { repoPath: "logo.jpg", fileName: "logo.jpg" };
|
||||
}
|
||||
|
||||
return { repoPath: "logo.png", fileName: "logo.png" };
|
||||
}
|
||||
|
||||
function pathExt(name: string): string {
|
||||
const i = name.lastIndexOf(".");
|
||||
return i >= 0 ? name.slice(i) : "";
|
||||
}
|
||||
25
worker/src/services/version.ts
Normal file
25
worker/src/services/version.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export class VersionValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "VersionValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前日期版本,格式如 2026.5.29(月、日不补零) */
|
||||
export function getDefaultAppVersion(date = new Date()): string {
|
||||
return `${date.getFullYear()}.${date.getMonth() + 1}.${date.getDate()}`;
|
||||
}
|
||||
|
||||
export function validateAppVersion(input: string): string {
|
||||
const value = input.trim() || getDefaultAppVersion();
|
||||
if (!/^\d{4}\.\d{1,2}\.\d{1,2}$/.test(value)) {
|
||||
throw new VersionValidationError(
|
||||
"版本号格式应为 YYYY.M.D,例如 2026.5.29",
|
||||
);
|
||||
}
|
||||
const [, month, day] = value.split(".").map(Number);
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) {
|
||||
throw new VersionValidationError("版本号中的月或日无效");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
218
worker/src/services/zip.ts
Normal file
218
worker/src/services/zip.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { unzipSync, zipSync } from "fflate";
|
||||
|
||||
export class ZipValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ZipValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export function validateZipBuffer(
|
||||
buffer: Uint8Array,
|
||||
maxBytes: number,
|
||||
): { normalizedBuffer: Uint8Array } {
|
||||
if (buffer.length > maxBytes) {
|
||||
throw new ZipValidationError(
|
||||
`Zip file exceeds ${Math.floor(maxBytes / (1024 * 1024))}MB limit`,
|
||||
);
|
||||
}
|
||||
|
||||
let files: Record<string, Uint8Array>;
|
||||
try {
|
||||
files = unzipSync(buffer);
|
||||
} catch {
|
||||
throw new ZipValidationError("Invalid zip archive");
|
||||
}
|
||||
|
||||
const entries = Object.keys(files).filter(
|
||||
(key) => !key.endsWith("/") && files[key].length > 0,
|
||||
);
|
||||
if (entries.length === 0) {
|
||||
throw new ZipValidationError("Zip archive is empty");
|
||||
}
|
||||
|
||||
const indexEntry = entries.find((entry) => {
|
||||
const normalized = entry.replace(/\\/g, "/");
|
||||
if (normalized === "index.html") return true;
|
||||
const parts = normalized.split("/");
|
||||
return parts.length === 2 && parts[1] === "index.html";
|
||||
});
|
||||
|
||||
if (!indexEntry) {
|
||||
throw new ZipValidationError(
|
||||
"Zip must contain index.html at root or in a single top-level folder",
|
||||
);
|
||||
}
|
||||
|
||||
const normalized = normalizeZipFiles(files);
|
||||
return { normalizedBuffer: zipSync(normalized) };
|
||||
}
|
||||
|
||||
function normalizeZipFiles(
|
||||
files: Record<string, Uint8Array>,
|
||||
): Record<string, Uint8Array> {
|
||||
const map: Record<string, Uint8Array> = {};
|
||||
for (const [key, data] of Object.entries(files)) {
|
||||
const path = key.replace(/\\/g, "/").replace(/^\.\//, "");
|
||||
if (path.endsWith("/")) continue;
|
||||
map[path] = data;
|
||||
}
|
||||
|
||||
if (map["index.html"]) {
|
||||
return map;
|
||||
}
|
||||
|
||||
const keys = Object.keys(map);
|
||||
const indexKey = keys.find((k) => {
|
||||
const parts = k.split("/");
|
||||
return parts.length === 2 && parts[1] === "index.html";
|
||||
});
|
||||
|
||||
if (!indexKey) {
|
||||
throw new ZipValidationError(
|
||||
"index.html must be at zip root or inside exactly one folder",
|
||||
);
|
||||
}
|
||||
|
||||
const folder = indexKey.split("/")[0];
|
||||
const topDirs = new Set(
|
||||
keys.map((k) => k.split("/")[0]).filter(Boolean),
|
||||
);
|
||||
|
||||
if (topDirs.size !== 1 || !topDirs.has(folder)) {
|
||||
throw new ZipValidationError(
|
||||
"index.html must be at zip root or inside exactly one folder",
|
||||
);
|
||||
}
|
||||
|
||||
const flat: Record<string, Uint8Array> = {};
|
||||
const prefix = `${folder}/`;
|
||||
for (const [key, data] of Object.entries(map)) {
|
||||
if (!key.startsWith(prefix)) {
|
||||
throw new ZipValidationError(
|
||||
"index.html must be at zip root or inside exactly one folder",
|
||||
);
|
||||
}
|
||||
const rel = key.slice(prefix.length);
|
||||
if (!rel) continue;
|
||||
flat[rel] = data;
|
||||
}
|
||||
|
||||
if (!flat["index.html"]) {
|
||||
throw new ZipValidationError(
|
||||
"index.html must be at zip root or inside exactly one folder",
|
||||
);
|
||||
}
|
||||
|
||||
return flat;
|
||||
}
|
||||
|
||||
const JAVA_RESERVED_SEGMENTS = new Set([
|
||||
"abstract",
|
||||
"assert",
|
||||
"boolean",
|
||||
"break",
|
||||
"byte",
|
||||
"case",
|
||||
"catch",
|
||||
"char",
|
||||
"class",
|
||||
"const",
|
||||
"continue",
|
||||
"default",
|
||||
"do",
|
||||
"double",
|
||||
"else",
|
||||
"enum",
|
||||
"extends",
|
||||
"final",
|
||||
"finally",
|
||||
"float",
|
||||
"for",
|
||||
"goto",
|
||||
"if",
|
||||
"implements",
|
||||
"import",
|
||||
"instanceof",
|
||||
"int",
|
||||
"interface",
|
||||
"long",
|
||||
"native",
|
||||
"new",
|
||||
"package",
|
||||
"private",
|
||||
"protected",
|
||||
"public",
|
||||
"return",
|
||||
"short",
|
||||
"static",
|
||||
"strictfp",
|
||||
"super",
|
||||
"switch",
|
||||
"synchronized",
|
||||
"this",
|
||||
"throw",
|
||||
"throws",
|
||||
"transient",
|
||||
"try",
|
||||
"void",
|
||||
"volatile",
|
||||
"while",
|
||||
]);
|
||||
|
||||
function sanitizeSegment(segment: string): string {
|
||||
let value = segment.toLowerCase().replace(/[^a-z0-9_]/g, "");
|
||||
if (!value) return "app";
|
||||
if (/^\d/.test(value)) value = `app${value}`;
|
||||
if (JAVA_RESERVED_SEGMENTS.has(value)) value = `${value}app`;
|
||||
return value.slice(0, 48);
|
||||
}
|
||||
|
||||
export function validateEnglishAppName(input: string): string {
|
||||
const value = input.trim();
|
||||
if (!value) {
|
||||
throw new ZipValidationError("应用英文名不能为空");
|
||||
}
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9 _.-]*$/.test(value)) {
|
||||
throw new ZipValidationError(
|
||||
"应用英文名需以字母开头,仅支持英文字母、数字、空格、下划线和连字符",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function validateChineseAppName(input: string): string {
|
||||
const value = input.trim();
|
||||
if (!value) {
|
||||
throw new ZipValidationError("应用中文名不能为空");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function slugifyIdentifier(input: string): string {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ".")
|
||||
.replace(/^\.+|\.+$/g, "")
|
||||
.split(".")
|
||||
.map(sanitizeSegment)
|
||||
.filter(Boolean)
|
||||
.join(".")
|
||||
.slice(0, 120);
|
||||
}
|
||||
|
||||
export function normalizeAppIdentifier(identifier: string): string {
|
||||
const parts = identifier
|
||||
.trim()
|
||||
.split(".")
|
||||
.map(sanitizeSegment)
|
||||
.filter(Boolean);
|
||||
|
||||
if (parts.length < 2) {
|
||||
throw new ZipValidationError(
|
||||
"Bundle ID 至少需要两段,例如 com.example.myapp",
|
||||
);
|
||||
}
|
||||
|
||||
return parts.join(".");
|
||||
}
|
||||
14
worker/tsconfig.json
Normal file
14
worker/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user