@@ -9,6 +9,7 @@ import { CLOUDFLARE_RUNTIME_HEALTH_MARKER, CLOUDFLARE_RUNTIME_HEALTH_SCHEMA, clo
99import { leaseRetryDelayMs } from "./lease-retry.js"
1010import { routeWorkerRequest } from "./request-routing.js"
1111import { toFetchResponse , toPHPRequest } from "./request-translation.js"
12+ import { R2_UPLOAD_OBJECT_PREFIX , validateUploadManifestFiles , validateUploadMetadata } from "./upload-persistence.js"
1213import { deriveWordPressAuthConstants , type WordPressAuthConstant } from "./wordpress-auth.js"
1314import { isWordPressRuntimeFile , wordpressStaticArchivePath , wordpressStaticContentType } from "./wordpress-runtime-corpus.js"
1415import { materializeWordPressRuntimeArtifact , type WordPressRuntimeArtifactManifest } from "./wordpress-runtime-artifact.js"
@@ -29,6 +30,7 @@ const MARKDOWN_DATABASE_INTEGRATION_REVISION = "2a8ee7f6a46e1d64b4606f1ee3c97e14
2930const SITE_URL = "https://wp-codebox-runtime.invalid"
3031const DATABASE_PATH = "/wordpress/wp-content/database/.ht.sqlite"
3132const MARKDOWN_ROOT = "/wordpress/wp-content/markdown"
33+ const UPLOADS_ROOT = "/wordpress/wp-content/uploads"
3234const MARKDOWN_INDEX_PATH = "/tmp/markdown-index.sqlite"
3335const MARKDOWN_RESOLVED_INDEX_PATH = "/tmp/markdown-index-8133b4cf3c66.sqlite"
3436const 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
110114interface MarkdownManifest extends MarkdownPointer {
111115 files : MarkdownManifestFile [ ]
116+ uploads ?: MarkdownManifestFile [ ]
112117}
113118
114119interface RuntimeFile {
@@ -434,7 +439,8 @@ async function discardRuntime(runtime: Runtime): Promise<void> {
434439}
435440
436441async 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
440446async function bootstrapCanonicalRuntime ( env : Env , coordinator : DurableObjectStub , requestUrl : string , lease : Lease ) : Promise < Runtime > {
@@ -492,7 +498,7 @@ async function canonicalWordPressAuthConstants(env: Env): Promise<Record<WordPre
492498async 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
498504function readCanonicalChanges ( php : PHP ) : MarkdownChanges {
@@ -538,26 +544,38 @@ function validateMarkdownChanges(changes: MarkdownChanges): void {
538544}
539545
540546function 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+
9831043async 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+
10211162async 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