|
| 1 | +export const STATIC_ARTIFACT_IMPORT_REQUEST_SCHEMA = "wp-codebox/cloudflare-static-artifact-import-request/v1" |
| 2 | +export const STATIC_ARTIFACT_IMPORT_RESULT_SCHEMA = "wp-codebox/cloudflare-static-artifact-import-result/v1" |
| 3 | +export const STATIC_ARTIFACT_SCHEMA = "blocks-engine/php-transformer/site-artifact/v1" |
| 4 | +export const R2_STATIC_ARTIFACT_PREFIX = "sites/default/import-artifacts" |
| 5 | +export const MAX_STATIC_ARTIFACT_REQUEST_BYTES = 16 * 1024 |
| 6 | +export const MAX_STATIC_ARTIFACT_BYTES = 4 * 1024 * 1024 |
| 7 | +export const MAX_STATIC_ARTIFACT_FILES = 500 |
| 8 | +export const MAX_STATIC_ARTIFACT_FILE_BYTES = 8 * 1024 * 1024 |
| 9 | +export const MAX_STATIC_ARTIFACT_DECODED_BYTES = 32 * 1024 * 1024 |
| 10 | + |
| 11 | +export interface StaticArtifactImport { |
| 12 | + idempotencyKey: string |
| 13 | + fingerprint: string |
| 14 | + artifactReference: { r2Key: string; sha256: string; size: number } |
| 15 | + artifact: Record<string, unknown> |
| 16 | + options: { slug: string; name: string; siteTitle: string } |
| 17 | +} |
| 18 | + |
| 19 | +export async function readStaticArtifactImport(request: Request, bucket: R2Bucket): Promise<StaticArtifactImport> { |
| 20 | + const declaredLength = request.headers.get("content-length") |
| 21 | + if (declaredLength && (!/^\d+$/.test(declaredLength) || Number(declaredLength) > MAX_STATIC_ARTIFACT_REQUEST_BYTES)) throw new StaticArtifactImportError("Static artifact import request exceeds its byte budget.", 413) |
| 22 | + const requestBytes = await readBoundedRequestBytes(request) |
| 23 | + let body: unknown |
| 24 | + try { |
| 25 | + body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(requestBytes)) |
| 26 | + } catch { |
| 27 | + throw new StaticArtifactImportError("Static artifact import request must be valid UTF-8 JSON.", 400) |
| 28 | + } |
| 29 | + if (!body || typeof body !== "object" || Array.isArray(body)) throw new StaticArtifactImportError("Static artifact import request must be an object.", 400) |
| 30 | + const candidate = body as Record<string, unknown> |
| 31 | + if (candidate.schema !== STATIC_ARTIFACT_IMPORT_REQUEST_SCHEMA || typeof candidate.idempotencyKey !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(candidate.idempotencyKey)) { |
| 32 | + throw new StaticArtifactImportError("Static artifact import request identity is invalid.", 400) |
| 33 | + } |
| 34 | + const reference = candidate.artifact |
| 35 | + if (!reference || typeof reference !== "object" || Array.isArray(reference)) throw new StaticArtifactImportError("Static artifact reference is required.", 400) |
| 36 | + const artifactReference = reference as Record<string, unknown> |
| 37 | + if (typeof artifactReference.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(artifactReference.sha256) |
| 38 | + || artifactReference.r2Key !== `${R2_STATIC_ARTIFACT_PREFIX}/${artifactReference.sha256}.json` |
| 39 | + || !Number.isSafeInteger(artifactReference.size) || (artifactReference.size as number) < 1 || (artifactReference.size as number) > MAX_STATIC_ARTIFACT_BYTES) { |
| 40 | + throw new StaticArtifactImportError("Static artifact reference is invalid or outside its byte budget.", 400) |
| 41 | + } |
| 42 | + const options = candidate.import |
| 43 | + if (!options || typeof options !== "object" || Array.isArray(options)) throw new StaticArtifactImportError("Static artifact import options are required.", 400) |
| 44 | + const importOptions = options as Record<string, unknown> |
| 45 | + const slug = typeof importOptions.slug === "string" ? importOptions.slug : "" |
| 46 | + const name = typeof importOptions.name === "string" ? importOptions.name.trim() : "" |
| 47 | + const siteTitle = typeof importOptions.siteTitle === "string" ? importOptions.siteTitle.trim() : "" |
| 48 | + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) || slug.length > 64 || !name || name.length > 120 || siteTitle.length > 120) throw new StaticArtifactImportError("Static artifact import options are invalid.", 400) |
| 49 | + |
| 50 | + const object = await bucket.get(artifactReference.r2Key as string) |
| 51 | + if (!object) throw new StaticArtifactImportError("Static artifact is unavailable.", 404) |
| 52 | + if (object.size !== artifactReference.size) throw new StaticArtifactImportError("Static artifact size does not match its reference.", 409) |
| 53 | + const bytes = new Uint8Array(await object.arrayBuffer()) |
| 54 | + if (await sha256Hex(bytes) !== artifactReference.sha256) throw new StaticArtifactImportError("Static artifact digest does not match its reference.", 409) |
| 55 | + let artifact: unknown |
| 56 | + try { |
| 57 | + artifact = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) |
| 58 | + } catch { |
| 59 | + throw new StaticArtifactImportError("Static artifact must be valid UTF-8 JSON.", 422) |
| 60 | + } |
| 61 | + await validateStaticArtifact(artifact) |
| 62 | + const fingerprint = await sha256Hex(new TextEncoder().encode(JSON.stringify({ sha256: artifactReference.sha256, slug, name, siteTitle }))) |
| 63 | + return { |
| 64 | + idempotencyKey: candidate.idempotencyKey, |
| 65 | + fingerprint, |
| 66 | + artifactReference: { r2Key: artifactReference.r2Key as string, sha256: artifactReference.sha256, size: artifactReference.size as number }, |
| 67 | + artifact: artifact as Record<string, unknown>, |
| 68 | + options: { slug, name, siteTitle }, |
| 69 | + } |
| 70 | +} |
| 71 | + |
| 72 | +export class StaticArtifactImportError extends Error { |
| 73 | + constructor(message: string, readonly status: number) { |
| 74 | + super(message) |
| 75 | + } |
| 76 | +} |
| 77 | + |
| 78 | +async function readBoundedRequestBytes(request: Request): Promise<Uint8Array> { |
| 79 | + if (!request.body) return new Uint8Array() |
| 80 | + const reader = request.body.getReader() |
| 81 | + const chunks: Uint8Array[] = [] |
| 82 | + let total = 0 |
| 83 | + while (true) { |
| 84 | + const { done, value } = await reader.read() |
| 85 | + if (done) break |
| 86 | + total += value.byteLength |
| 87 | + if (total > MAX_STATIC_ARTIFACT_REQUEST_BYTES) { |
| 88 | + await reader.cancel() |
| 89 | + throw new StaticArtifactImportError("Static artifact import request exceeds its byte budget.", 413) |
| 90 | + } |
| 91 | + chunks.push(value) |
| 92 | + } |
| 93 | + const bytes = new Uint8Array(total) |
| 94 | + let offset = 0 |
| 95 | + for (const chunk of chunks) { |
| 96 | + bytes.set(chunk, offset) |
| 97 | + offset += chunk.byteLength |
| 98 | + } |
| 99 | + return bytes |
| 100 | +} |
| 101 | + |
| 102 | +async function validateStaticArtifact(value: unknown): Promise<void> { |
| 103 | + if (!value || typeof value !== "object" || Array.isArray(value)) throw new StaticArtifactImportError("Static artifact must be an object.", 422) |
| 104 | + const artifact = value as Record<string, unknown> |
| 105 | + if (artifact.schema !== STATIC_ARTIFACT_SCHEMA || typeof artifact.root !== "string" || !isSafePath(artifact.root) |
| 106 | + || typeof artifact.entrypoint !== "string" || !isSafePath(artifact.entrypoint) || !Array.isArray(artifact.files) |
| 107 | + || artifact.files.length < 1 || artifact.files.length > MAX_STATIC_ARTIFACT_FILES) { |
| 108 | + throw new StaticArtifactImportError("Static artifact contract is invalid.", 422) |
| 109 | + } |
| 110 | + const paths = new Set<string>() |
| 111 | + let decodedBytes = 0 |
| 112 | + for (const file of artifact.files) { |
| 113 | + if (!file || typeof file !== "object" || Array.isArray(file)) throw new StaticArtifactImportError("Static artifact contains an invalid file.", 422) |
| 114 | + const candidate = file as Record<string, unknown> |
| 115 | + if (typeof candidate.path !== "string" || !isSafePath(candidate.path) || paths.has(candidate.path) || !(candidate.path === artifact.root || candidate.path.startsWith(`${artifact.root}/`))) { |
| 116 | + throw new StaticArtifactImportError("Static artifact contains an invalid file path.", 422) |
| 117 | + } |
| 118 | + paths.add(candidate.path) |
| 119 | + let bytes: Uint8Array |
| 120 | + if (typeof candidate.content === "string" && candidate.content_base64 === undefined) bytes = new TextEncoder().encode(candidate.content) |
| 121 | + else if (typeof candidate.content_base64 === "string" && candidate.content === undefined) bytes = decodeBase64(candidate.content_base64) |
| 122 | + else throw new StaticArtifactImportError("Static artifact file content is invalid.", 422) |
| 123 | + if (bytes.byteLength > MAX_STATIC_ARTIFACT_FILE_BYTES) throw new StaticArtifactImportError("Static artifact file exceeds its byte budget.", 413) |
| 124 | + decodedBytes += bytes.byteLength |
| 125 | + if (decodedBytes > MAX_STATIC_ARTIFACT_DECODED_BYTES) throw new StaticArtifactImportError("Static artifact exceeds its decoded byte budget.", 413) |
| 126 | + if (candidate.bytes !== undefined && candidate.bytes !== bytes.byteLength) throw new StaticArtifactImportError("Static artifact file size does not match its metadata.", 422) |
| 127 | + if (candidate.sha256 !== undefined && (typeof candidate.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(candidate.sha256) || await sha256Hex(bytes) !== candidate.sha256)) throw new StaticArtifactImportError("Static artifact file digest does not match its content.", 422) |
| 128 | + } |
| 129 | + if (!paths.has(artifact.entrypoint)) throw new StaticArtifactImportError("Static artifact entrypoint is unavailable.", 422) |
| 130 | +} |
| 131 | + |
| 132 | +function isSafePath(path: string): boolean { |
| 133 | + return path.length > 0 && !path.startsWith("/") && !path.includes("\\") && path.split("/").every((segment) => !!segment && segment !== "." && segment !== "..") |
| 134 | +} |
| 135 | + |
| 136 | +function decodeBase64(value: string): Uint8Array { |
| 137 | + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) throw new StaticArtifactImportError("Static artifact contains invalid base64 content.", 422) |
| 138 | + const decoded = atob(value) |
| 139 | + return Uint8Array.from(decoded, (character) => character.charCodeAt(0)) |
| 140 | +} |
| 141 | + |
| 142 | +async function sha256Hex(bytes: Uint8Array): Promise<string> { |
| 143 | + const digest = await crypto.subtle.digest("SHA-256", bytes as Uint8Array<ArrayBuffer>) |
| 144 | + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("") |
| 145 | +} |
0 commit comments