Skip to content

Commit 0f144aa

Browse files
committed
Persist Cloudflare WordPress media in R2
1 parent b0fcead commit 0f144aa

4 files changed

Lines changed: 279 additions & 22 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
export const R2_UPLOAD_OBJECT_PREFIX = "sites/default/uploads/objects"
2+
export const MAX_UPLOAD_FILES = 5000
3+
export const MAX_UPLOAD_FILE_BYTES = 16 * 1024 * 1024
4+
export const MAX_UPLOAD_TOTAL_BYTES = 64 * 1024 * 1024
5+
6+
export interface UploadFileMetadata {
7+
path: string
8+
size: number
9+
}
10+
11+
export interface UploadManifestFile extends UploadFileMetadata {
12+
objectKey: string
13+
sha256: string
14+
}
15+
16+
export function validateUploadMetadata(value: unknown): asserts value is UploadFileMetadata[] {
17+
if (!Array.isArray(value) || value.length > MAX_UPLOAD_FILES) throw new Error("Canonical upload file count is invalid.")
18+
let total = 0
19+
const paths = new Set<string>()
20+
for (const file of value) {
21+
if (!file || typeof file !== "object" || typeof file.path !== "string" || !isCanonicalUploadPath(file.path) || paths.has(file.path)
22+
|| !Number.isSafeInteger(file.size) || file.size < 0 || file.size > MAX_UPLOAD_FILE_BYTES) {
23+
throw new Error("Canonical upload metadata contains an invalid file.")
24+
}
25+
paths.add(file.path)
26+
total += file.size
27+
}
28+
if (total > MAX_UPLOAD_TOTAL_BYTES) throw new Error("Canonical upload files exceed their byte budget.")
29+
}
30+
31+
export function validateUploadManifestFiles(value: unknown): asserts value is UploadManifestFile[] {
32+
validateUploadMetadata(value)
33+
for (const file of value as UploadManifestFile[]) {
34+
if (!/^[a-f0-9]{64}$/.test(file.sha256) || file.objectKey !== `${R2_UPLOAD_OBJECT_PREFIX}/${file.sha256}`) {
35+
throw new Error("Canonical upload manifest contains an invalid file.")
36+
}
37+
}
38+
}
39+
40+
function isCanonicalUploadPath(path: string): boolean {
41+
return path.length > 0 && !path.startsWith("/") && !path.includes("\\") && !path.split("/").includes("..")
42+
}

packages/runtime-cloudflare/src/worker.ts

Lines changed: 157 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { CLOUDFLARE_RUNTIME_HEALTH_MARKER, CLOUDFLARE_RUNTIME_HEALTH_SCHEMA, clo
99
import { leaseRetryDelayMs } from "./lease-retry.js"
1010
import { routeWorkerRequest } from "./request-routing.js"
1111
import { toFetchResponse, toPHPRequest } from "./request-translation.js"
12+
import { R2_UPLOAD_OBJECT_PREFIX, validateUploadManifestFiles, validateUploadMetadata } from "./upload-persistence.js"
1213
import { deriveWordPressAuthConstants, type WordPressAuthConstant } from "./wordpress-auth.js"
1314
import { isWordPressRuntimeFile, wordpressStaticArchivePath, wordpressStaticContentType } from "./wordpress-runtime-corpus.js"
1415
import { materializeWordPressRuntimeArtifact, type WordPressRuntimeArtifactManifest } from "./wordpress-runtime-artifact.js"
@@ -29,6 +30,7 @@ const MARKDOWN_DATABASE_INTEGRATION_REVISION = "2a8ee7f6a46e1d64b4606f1ee3c97e14
2930
const SITE_URL = "https://wp-codebox-runtime.invalid"
3031
const DATABASE_PATH = "/wordpress/wp-content/database/.ht.sqlite"
3132
const MARKDOWN_ROOT = "/wordpress/wp-content/markdown"
33+
const UPLOADS_ROOT = "/wordpress/wp-content/uploads"
3234
const MARKDOWN_INDEX_PATH = "/tmp/markdown-index.sqlite"
3335
const MARKDOWN_RESOLVED_INDEX_PATH = "/tmp/markdown-index-8133b4cf3c66.sqlite"
3436
const MARKDOWN_CHANGES_PATH = "/tmp/wp-codebox-canonical-changes.json"
@@ -87,6 +89,8 @@ export default {
8789
if (staticResponse) return staticResponse
8890
const route = routeWorkerRequest(request)
8991
const coordinator = env.WORDPRESS_STATE.getByName("default")
92+
const uploadResponse = await serveWordPressUpload(request, env.WORDPRESS_STATE_BUCKET, coordinator)
93+
if (uploadResponse) return uploadResponse
9094
if (route.kind === "operator-reset") return resetCanonicalWordPress(request, env, coordinator)
9195
if (route.kind === "operator-restore") return restoreCanonicalWordPress(request, env, coordinator)
9296
if (route.kind === "probe") {
@@ -109,6 +113,7 @@ interface MarkdownManifestFile {
109113

110114
interface MarkdownManifest extends MarkdownPointer {
111115
files: MarkdownManifestFile[]
116+
uploads?: MarkdownManifestFile[]
112117
}
113118

114119
interface RuntimeFile {
@@ -434,7 +439,8 @@ async function discardRuntime(runtime: Runtime): Promise<void> {
434439
}
435440

436441
async function bootRuntime(bucket: R2Bucket, pointer: MarkdownPointer, origin: string, authConstants: Record<WordPressAuthConstant, string>): Promise<Runtime> {
437-
return { ...await bootWordPressRuntime("do-not-attempt-installing", true, true, undefined, await readMarkdownRevision(bucket, pointer), new Uint8Array(markdownPrimaryBootstrapIndex), origin, authConstants, bucket, true), pointer }
442+
const revision = await readCanonicalRevision(bucket, pointer)
443+
return { ...await bootWordPressRuntime("do-not-attempt-installing", true, true, undefined, revision.markdown, new Uint8Array(markdownPrimaryBootstrapIndex), origin, authConstants, bucket, true, revision.uploads), pointer }
438444
}
439445

440446
async function bootstrapCanonicalRuntime(env: Env, coordinator: DurableObjectStub, requestUrl: string, lease: Lease): Promise<Runtime> {
@@ -492,7 +498,7 @@ async function canonicalWordPressAuthConstants(env: Env): Promise<Record<WordPre
492498
async function persistRuntime(bucket: R2Bucket, runtime: Runtime, changes: MarkdownChanges): Promise<MarkdownPointer> {
493499
validateMarkdownChanges(changes)
494500
const changedPaths = [...changes.created, ...changes.changed].sort((left, right) => left.localeCompare(right))
495-
return persistMarkdownRevision(bucket, collectRuntimeFiles(runtime.php, MARKDOWN_ROOT, changedPaths), runtime.pointer, changes)
501+
return persistMarkdownRevision(bucket, collectRuntimeFiles(runtime.php, MARKDOWN_ROOT, changedPaths), runtime.pointer, changes, await collectUploadFiles(runtime.php))
496502
}
497503

498504
function readCanonicalChanges(php: PHP): MarkdownChanges {
@@ -538,26 +544,38 @@ function validateMarkdownChanges(changes: MarkdownChanges): void {
538544
}
539545

540546
function isCanonicalRelativePath(path: string): boolean {
541-
return path.length > 0 && !path.startsWith("/") && !path.split("/").includes("..")
547+
return path.length > 0 && !path.startsWith("/") && !path.includes("\\") && !path.split("/").includes("..")
542548
}
543549

544-
async function readMarkdownRevision(bucket: R2Bucket, pointer: MarkdownPointer): Promise<RuntimeFile[]> {
550+
async function readCanonicalRevision(bucket: R2Bucket, pointer: MarkdownPointer): Promise<{ markdown: RuntimeFile[]; uploads: RuntimeFile[] }> {
545551
const manifestObject = await bucket.get(pointer.manifestKey)
546552
if (!manifestObject) throw new Error(`R2 Markdown manifest is missing: ${pointer.manifestKey}`)
547553
const manifest = await manifestObject.json<MarkdownManifest>()
548-
return Promise.all(manifest.files.map(async (file): Promise<RuntimeFile> => {
554+
validateUploadManifestFiles(manifest.uploads ?? [])
555+
return {
556+
markdown: await readManifestFiles(bucket, manifest.files, "Markdown"),
557+
uploads: await readManifestFiles(bucket, manifest.uploads ?? [], "upload"),
558+
}
559+
}
560+
561+
async function readManifestFiles(bucket: R2Bucket, files: MarkdownManifestFile[], label: string): Promise<RuntimeFile[]> {
562+
return Promise.all(files.map(async (file): Promise<RuntimeFile> => {
549563
const object = await bucket.get(file.objectKey)
550-
if (!object) throw new Error(`R2 Markdown object is missing: ${file.objectKey}`)
551-
return { path: file.path, bytes: new Uint8Array(await object.arrayBuffer()) }
564+
if (!object) throw new Error(`R2 ${label} object is missing: ${file.objectKey}`)
565+
const bytes = new Uint8Array(await object.arrayBuffer())
566+
if (bytes.byteLength !== file.size || await sha256Hex(bytes) !== file.sha256) throw new Error(`R2 ${label} object failed integrity validation: ${file.objectKey}`)
567+
return { path: file.path, bytes }
552568
}))
553569
}
554570

555-
async function persistMarkdownRevision(bucket: R2Bucket, files: RuntimeFile[], current?: MarkdownPointer, changes?: MarkdownChanges): Promise<MarkdownPointer> {
571+
async function persistMarkdownRevision(bucket: R2Bucket, files: RuntimeFile[], current?: MarkdownPointer, changes?: MarkdownChanges, uploads: RuntimeFile[] = []): Promise<MarkdownPointer> {
556572
const currentManifest = current ? await readMarkdownManifest(bucket, current) : null
557573
if (current && !currentManifest) throw new Error(`R2 Markdown manifest is missing: ${current.manifestKey}`)
574+
const uploadManifestFiles = await persistUploadObjects(bucket, uploads, currentManifest?.uploads ?? [])
575+
const uploadsUnchanged = JSON.stringify(currentManifest?.uploads ?? []) === JSON.stringify(uploadManifestFiles)
558576
if (current && currentManifest && changes) {
559577
validateMarkdownChanges(changes)
560-
if (!changes.created.length && !changes.changed.length && !changes.deleted.length) return current
578+
if (!changes.created.length && !changes.changed.length && !changes.deleted.length && uploadsUnchanged) return current
561579
const manifestFiles = new Map(currentManifest.files.map((file) => [file.path, file]))
562580
for (const path of changes.deleted) manifestFiles.delete(path)
563581
const filesByPath = new Map(files.map((file) => [file.path, file]))
@@ -569,7 +587,7 @@ async function persistMarkdownRevision(bucket: R2Bucket, files: RuntimeFile[], c
569587
await bucket.put(objectKey, file.bytes)
570588
manifestFiles.set(path, { path, objectKey, sha256, size: file.bytes.byteLength })
571589
}
572-
return persistMarkdownManifest(bucket, [...manifestFiles.values()].sort((left, right) => left.path.localeCompare(right.path)))
590+
return persistMarkdownManifest(bucket, [...manifestFiles.values()].sort((left, right) => left.path.localeCompare(right.path)), uploadManifestFiles)
573591
}
574592
const currentFiles = new Map(currentManifest?.files.map((file) => [file.path, file]) ?? [])
575593
const manifestFiles = await Promise.all(files.map(async (file): Promise<MarkdownManifestFile> => {
@@ -581,18 +599,40 @@ async function persistMarkdownRevision(bucket: R2Bucket, files: RuntimeFile[], c
581599
return { path: file.path, objectKey, sha256, size: file.bytes.byteLength }
582600
}))
583601
if (current && currentManifest) {
584-
if (JSON.stringify(currentManifest.files) === JSON.stringify(manifestFiles)) return current
602+
if (JSON.stringify(currentManifest.files) === JSON.stringify(manifestFiles) && uploadsUnchanged) return current
603+
}
604+
605+
return persistMarkdownManifest(bucket, manifestFiles, uploadManifestFiles)
606+
}
607+
608+
async function persistUploadObjects(bucket: R2Bucket, files: RuntimeFile[], current: MarkdownManifestFile[]): Promise<MarkdownManifestFile[]> {
609+
validateUploadFiles(files)
610+
const currentFiles = new Map(current.map((file) => [file.path, file]))
611+
const persisted: MarkdownManifestFile[] = []
612+
for (const file of files) {
613+
const sha256 = await sha256Hex(file.bytes)
614+
const existing = currentFiles.get(file.path)
615+
if (existing?.sha256 === sha256 && existing.size === file.bytes.byteLength) {
616+
persisted.push(existing)
617+
continue
618+
}
619+
const objectKey = `${R2_UPLOAD_OBJECT_PREFIX}/${sha256}`
620+
if (!await bucket.head(objectKey)) await bucket.put(objectKey, file.bytes)
621+
persisted.push({ path: file.path, objectKey, sha256, size: file.bytes.byteLength })
585622
}
623+
return persisted
624+
}
586625

587-
return persistMarkdownManifest(bucket, manifestFiles)
626+
function validateUploadFiles(files: RuntimeFile[]): void {
627+
validateUploadMetadata(files.map((file) => ({ path: file.path, size: file.bytes.byteLength })))
588628
}
589629

590-
async function persistMarkdownManifest(bucket: R2Bucket, files: MarkdownManifestFile[]): Promise<MarkdownPointer> {
630+
async function persistMarkdownManifest(bucket: R2Bucket, files: MarkdownManifestFile[], uploads: MarkdownManifestFile[] = []): Promise<MarkdownPointer> {
591631
const revision = crypto.randomUUID()
592632
const manifestKey = `${R2_MARKDOWN_REVISION_PREFIX}/${revision}.json`
593633
const persistedAt = new Date().toISOString()
594634
const pointer: MarkdownPointer = { revision, manifestKey, persistedAt }
595-
const manifest: MarkdownManifest = { ...pointer, files }
635+
const manifest: MarkdownManifest = { ...pointer, files, uploads }
596636
await bucket.put(manifestKey, JSON.stringify(manifest), {
597637
httpMetadata: { contentType: "application/json" },
598638
})
@@ -845,6 +885,7 @@ async function bootWordPressRuntime(
845885
authConstants: Partial<Record<WordPressAuthConstant, string>> = {},
846886
runtimeBucket?: R2Bucket,
847887
shouldPatchCanonicalRuntimePoliciesAtInit = false,
888+
uploadFiles?: RuntimeFile[],
848889
): Promise<{ php: PHP; requestHandler: PHPRequestHandler; wordpressVersion: string }> {
849890
const requestHandler = await bootWordPressAndRequestHandler({
850891
createPhpRuntime,
@@ -868,15 +909,16 @@ async function bootWordPressRuntime(
868909
// Browser cookies are carried by the Worker Fetch request; Playground's
869910
// internal store would overwrite that header after an isolate restart.
870911
cookieStore: false,
871-
hooks: streamWordPressFiles || databaseSeed || markdownFiles ? {
872-
beforeWordPressFiles: streamWordPressFiles || markdownFiles ? async (php: PHP) => {
912+
hooks: streamWordPressFiles || databaseSeed || markdownFiles || uploadFiles?.length ? {
913+
beforeWordPressFiles: streamWordPressFiles || markdownFiles || uploadFiles?.length ? async (php: PHP) => {
873914
if (streamWordPressFiles) await materializeWordPressServerFiles(php, runtimeBucket)
874915
if (markdownFiles) {
875916
await materializeMarkdownDatabaseIntegration(php)
876917
materializeCanonicalChangeAdapter(php)
877918
materializeRuntimeFiles(php, MARKDOWN_ROOT, markdownFiles)
878919
if (markdownIndexSeed) php.writeFile(MARKDOWN_RESOLVED_INDEX_PATH, markdownIndexSeed)
879920
}
921+
if (uploadFiles?.length) materializeRuntimeFiles(php, UPLOADS_ROOT, uploadFiles)
880922
if (shouldPatchCanonicalRuntimePoliciesAtInit) {
881923
patchCanonicalRuntimePoliciesAtInit(php)
882924
patchCanonicalThemeJsonCustomCss(php)
@@ -980,6 +1022,24 @@ function collectRuntimeFiles(php: PHP, root: string, paths?: string[]): RuntimeF
9801022
return files.sort((left, right) => left.path.localeCompare(right.path))
9811023
}
9821024

1025+
async function collectUploadFiles(php: PHP): Promise<RuntimeFile[]> {
1026+
if (!php.isDir(UPLOADS_ROOT)) return []
1027+
const output = (await php.run({ code: `<?php
1028+
$root = '${UPLOADS_ROOT}';
1029+
$files = array();
1030+
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::LEAVES_ONLY);
1031+
foreach ($iterator as $file) {
1032+
if (!$file->isFile()) continue;
1033+
$path = str_replace('\\\\', '/', $file->getPathname());
1034+
$files[] = array('path' => substr($path, strlen($root) + 1), 'size' => $file->getSize());
1035+
}
1036+
usort($files, static fn($left, $right) => strcmp($left['path'], $right['path']));
1037+
echo json_encode($files, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);` })).text.trim()
1038+
const metadata: unknown = JSON.parse(output)
1039+
validateUploadMetadata(metadata)
1040+
return metadata.map((file) => ({ path: file.path, bytes: php.readFileAsBuffer(`${UPLOADS_ROOT}/${file.path}`) }))
1041+
}
1042+
9831043
async function sha256Hex(bytes: Uint8Array): Promise<string> {
9841044
const digest = await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer)
9851045
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")
@@ -1018,6 +1078,87 @@ async function materializeWordPressServerFiles(php: PHP, bucket: R2Bucket | unde
10181078
return materializeWordPressRuntimeArtifact(php, bucket, wordpressRuntimeArtifactManifest as WordPressRuntimeArtifactManifest)
10191079
}
10201080

1081+
async function serveWordPressUpload(request: Request, bucket: R2Bucket, coordinator: DurableObjectStub): Promise<Response | null> {
1082+
if (request.method !== "GET" && request.method !== "HEAD") return null
1083+
const url = new URL(request.url)
1084+
if (!url.pathname.startsWith("/wp-content/uploads/")) return null
1085+
let path: string
1086+
try {
1087+
path = decodeURIComponent(url.pathname.slice("/wp-content/uploads/".length))
1088+
} catch {
1089+
return new Response("Invalid WordPress upload path.", { status: 400 })
1090+
}
1091+
if (!isCanonicalRelativePath(path)) return new Response("Invalid WordPress upload path.", { status: 400 })
1092+
const state = await coordinatorCall<CoordinatorState>(coordinator, request.url, "state")
1093+
if (!state.pointer) return null
1094+
const cache = typeof caches === "undefined" ? undefined : (caches as CacheStorage & { default?: Cache }).default
1095+
const cacheKey = wordPressUploadCacheKey(request, state.pointer)
1096+
if (request.method === "GET" && cache) {
1097+
try {
1098+
const cached = await cache.match(cacheKey)
1099+
if (cached) return cached
1100+
} catch {
1101+
// R2 remains authoritative when the edge cache is unavailable.
1102+
}
1103+
}
1104+
const manifest = await readMarkdownManifest(bucket, state.pointer)
1105+
validateUploadManifestFiles(manifest?.uploads ?? [])
1106+
const file = manifest?.uploads?.find((candidate) => candidate.path === path)
1107+
if (!file) return new Response("WordPress upload not found.", { status: 404 })
1108+
const object = await bucket.get(file.objectKey)
1109+
if (!object) throw new Error(`R2 upload object is missing: ${file.objectKey}`)
1110+
if (object.size !== file.size) throw new Error(`R2 upload object size is inconsistent: ${file.objectKey}`)
1111+
let body: Uint8Array | null = null
1112+
if (request.method === "GET") {
1113+
body = new Uint8Array(await object.arrayBuffer())
1114+
if (await sha256Hex(body) !== file.sha256) throw new Error(`R2 upload object failed integrity validation: ${file.objectKey}`)
1115+
}
1116+
const headers = new Headers({
1117+
"cache-control": "public, max-age=60",
1118+
"content-length": String(file.size),
1119+
"content-type": wordPressUploadContentType(path),
1120+
etag: `"${file.sha256}"`,
1121+
"x-wp-codebox-static": "r2-upload",
1122+
})
1123+
const response = new Response(body ? Uint8Array.from(body).buffer : null, { status: 200, headers })
1124+
if (request.method === "GET" && cache) {
1125+
try {
1126+
await cache.put(cacheKey, response.clone())
1127+
} catch {
1128+
// R2 remains authoritative when the edge cache is unavailable.
1129+
}
1130+
}
1131+
return response
1132+
}
1133+
1134+
function wordPressUploadCacheKey(request: Request, pointer: MarkdownPointer): Request {
1135+
const url = new URL(request.url)
1136+
url.searchParams.set("__wp_codebox_revision", pointer.revision)
1137+
return new Request(url, { method: "GET" })
1138+
}
1139+
1140+
function wordPressUploadContentType(path: string): string {
1141+
const extension = path.slice(path.lastIndexOf(".") + 1).toLowerCase()
1142+
return ({
1143+
avif: "image/avif",
1144+
gif: "image/gif",
1145+
jpeg: "image/jpeg",
1146+
jpg: "image/jpeg",
1147+
json: "application/json",
1148+
mp3: "audio/mpeg",
1149+
mp4: "video/mp4",
1150+
ogg: "audio/ogg",
1151+
pdf: "application/pdf",
1152+
png: "image/png",
1153+
svg: "image/svg+xml",
1154+
txt: "text/plain; charset=utf-8",
1155+
wav: "audio/wav",
1156+
webm: "video/webm",
1157+
webp: "image/webp",
1158+
xml: "application/xml",
1159+
} as Record<string, string>)[extension] ?? "application/octet-stream"
1160+
}
1161+
10211162
async function serveWordPressStaticAsset(request: Request): Promise<Response | null> {
10221163
if (request.method !== "GET" && request.method !== "HEAD") return null
10231164
const archivePath = wordpressStaticArchivePath(new URL(request.url).pathname)

0 commit comments

Comments
 (0)