/** * Welcome to Cloudflare Workers! This is your first worker. * * - Run `npm run dev` in your terminal to start a development server * - Open a browser tab at http://localhost:8787/ to see your worker in action * - Run `npm run deploy` to publish your worker * * Learn more at https://developers.cloudflare.com/workers/ */ export interface Env { // Example binding to KV. Learn more at https://developers.cloudflare.com/workers/runtime-apis/kv/ // MY_KV_NAMESPACE: KVNamespace; // // Example binding to Durable Object. Learn more at https://developers.cloudflare.com/workers/runtime-apis/durable-objects/ // MY_DURABLE_OBJECT: DurableObjectNamespace; // // Example binding to R2. Learn more at https://developers.cloudflare.com/workers/runtime-apis/r2/ bucket: R2Bucket; // // Example binding to a Service. Learn more at https://developers.cloudflare.com/workers/runtime-apis/service-bindings/ // MY_SERVICE: Fetcher; // // Example binding to a Queue. Learn more at https://developers.cloudflare.com/queues/javascript-apis/ // MY_QUEUE: Queue; // Variables defined in the "Environment Variables" section of the Wrangler CLI or dashboard USERNAME: string; PASSWORD: string; } async function* listAll(bucket: R2Bucket, prefix: string, isRecursive: boolean = false) { let cursor: string | undefined = undefined; do { var r2_objects = await bucket.list({ prefix: prefix, delimiter: isRecursive ? undefined : '/', cursor: cursor, include: ['httpMetadata', 'customMetadata'], }); for (let object of r2_objects.objects) { yield object; } if (r2_objects.truncated) { cursor = r2_objects.cursor; } } while (r2_objects.truncated); } type DavProperties = { creationdate: string | undefined; displayname: string | undefined; getcontentlanguage: string | undefined; getcontentlength: string | undefined; getcontenttype: string | undefined; getetag: string | undefined; getlastmodified: string | undefined; resourcetype: string; }; function fromR2Object(object: R2Object | null | undefined): DavProperties { if (object === null || object === undefined) { return { creationdate: new Date().toUTCString(), displayname: undefined, getcontentlanguage: undefined, getcontentlength: '0', getcontenttype: undefined, getetag: undefined, getlastmodified: new Date().toUTCString(), resourcetype: '', }; } return { creationdate: object.uploaded.toUTCString(), displayname: object.httpMetadata?.contentDisposition, getcontentlanguage: object.httpMetadata?.contentLanguage, getcontentlength: object.size.toString(), getcontenttype: object.httpMetadata?.contentType, getetag: object.etag, getlastmodified: object.uploaded.toUTCString(), resourcetype: object.customMetadata?.resourcetype ?? '', }; } function make_resource_path(request: Request): string { let path = new URL(request.url).pathname.slice(1); path = path.endsWith('/') ? path.slice(0, -1) : path; return path; } async function handle_head(request: Request, bucket: R2Bucket): Promise { let response = await handle_get(request, bucket); return new Response(null, { status: response.status, statusText: response.statusText, headers: response.headers, }); } async function handle_get(request: Request, bucket: R2Bucket): Promise { let resource_path = make_resource_path(request); if (request.url.endsWith('/')) { let page = ''; if (resource_path !== '') page += `..
`; for await (const object of listAll(bucket, resource_path)) { if (object.key === resource_path) { continue; } let href = `/${object.key + (object.customMetadata?.resourcetype === '' ? '/' : '')}`; page += `${object.httpMetadata?.contentDisposition ?? object.key}
`; } return new Response(page, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }); } else { let object = await bucket.get(resource_path, { onlyIf: request.headers, range: request.headers, }); let isR2ObjectBody = (object: R2Object | R2ObjectBody): object is R2ObjectBody => { return 'body' in object; }; if (object === null) { return new Response('Not Found', { status: 404 }); } else if (!isR2ObjectBody(object)) { return new Response('Precondition Failed', { status: 412 }); } else { return new Response(object.body, { status: object.range ? 206 : 200, headers: { 'Content-Type': object.httpMetadata?.contentType ?? 'application/octet-stream', // TODO: Content-Length, Content-Range ...(object.httpMetadata?.contentDisposition ? { 'Content-Disposition': object.httpMetadata.contentDisposition, } : {}), ...(object.httpMetadata?.contentEncoding ? { 'Content-Encoding': object.httpMetadata.contentEncoding, } : {}), ...(object.httpMetadata?.contentLanguage ? { 'Content-Language': object.httpMetadata.contentLanguage, } : {}), ...(object.httpMetadata?.cacheControl ? { 'Cache-Control': object.httpMetadata.cacheControl, } : {}), ...(object.httpMetadata?.cacheExpiry ? { 'Cache-Expiry': object.httpMetadata.cacheExpiry.toISOString(), } : {}), }, }); } } } async function handle_put(request: Request, bucket: R2Bucket): Promise { if (request.url.endsWith('/')) { return new Response('Method Not Allowed', { status: 405 }); } let resource_path = make_resource_path(request); // Check if the parent directory exists let dirpath = resource_path.split('/').slice(0, -1).join('/'); if (dirpath !== '') { let dir = await bucket.head(dirpath); if (!(dir && dir.customMetadata?.resourcetype === '')) { return new Response('Conflict', { status: 409 }); } } let body = await request.arrayBuffer(); await bucket.put(resource_path, body, { onlyIf: request.headers, httpMetadata: request.headers, }); return new Response('', { status: 201 }); } async function handle_delete(request: Request, bucket: R2Bucket): Promise { let resource_path = make_resource_path(request); if (resource_path === '') { let r2_objects, cursor: string | undefined = undefined; do { r2_objects = await bucket.list({ cursor: cursor }); let keys = r2_objects.objects.map((object) => object.key); if (keys.length > 0) { await bucket.delete(keys); } if (r2_objects.truncated) { cursor = r2_objects.cursor; } } while (r2_objects.truncated); return new Response(null, { status: 204 }); } let resource = await bucket.head(resource_path); if (resource === null) { return new Response('Not Found', { status: 404 }); } await bucket.delete(resource_path); if (resource.customMetadata?.resourcetype !== '') { return new Response(null, { status: 204 }); } let r2_objects, cursor: string | undefined = undefined; do { r2_objects = await bucket.list({ prefix: resource_path + '/', cursor: cursor, }); let keys = r2_objects.objects.map((object) => object.key); if (keys.length > 0) { await bucket.delete(keys); } if (r2_objects.truncated) { cursor = r2_objects.cursor; } } while (r2_objects.truncated); return new Response(null, { status: 204 }); } async function handle_mkcol(request: Request, bucket: R2Bucket): Promise { if (request.body) { return new Response('Unsupported Media Type', { status: 415 }); } let resource_path = make_resource_path(request); // Check if the resource already exists let resource = await bucket.head(resource_path); if (resource !== null) { return new Response('Method Not Allowed', { status: 405 }); } // Check if the parent directory exists let parent_dir = resource_path.split('/').slice(0, -1).join('/'); if (parent_dir !== '' && !(await bucket.head(parent_dir))) { return new Response('Conflict', { status: 409 }); } await bucket.put(resource_path, new Uint8Array(), { httpMetadata: request.headers, customMetadata: { resourcetype: '' }, }); return new Response('', { status: 201 }); } function generate_propfind_response(object: R2Object | null): string { if (object === null) { return ` / ${Object.entries(fromR2Object(null)) .filter(([_, value]) => value !== undefined) .map(([key, value]) => `<${key}>${value}`) .join('\n ')} HTTP/1.1 200 OK `; } let href = `/${object.key + (object.customMetadata?.resourcetype === '' ? '/' : '')}`; return ` ${href} ${Object.entries(fromR2Object(object)) .filter(([_, value]) => value !== undefined) .map(([key, value]) => `<${key}>${value}`) .join('\n ')} HTTP/1.1 200 OK `; } async function handle_propfind(request: Request, bucket: R2Bucket): Promise { let resource_path = make_resource_path(request); let is_collection: boolean; let page = ` `; if (resource_path === '') { page += generate_propfind_response(null); is_collection = true; } else { let object = await bucket.head(resource_path); if (object === null) { return new Response('Not Found', { status: 404 }); } is_collection = object.customMetadata?.resourcetype === ''; page += generate_propfind_response(object); } if (is_collection) { let depth = request.headers.get('Depth') ?? 'infinity'; switch (depth) { case '0': break; case '1': { let prefix = resource_path === '' ? resource_path : resource_path + '/'; for await (let object of listAll(bucket, prefix)) { page += generate_propfind_response(object); } } break; case 'infinity': { let prefix = resource_path === '' ? resource_path : resource_path + '/'; for await (let object of listAll(bucket, prefix, true)) { page += generate_propfind_response(object); } } break; default: { return new Response('Forbidden', { status: 403 }); } } } page += '\n\n'; return new Response(page, { status: 207, headers: { 'Content-Type': 'text/xml', }, }); } async function handle_copy(request: Request, bucket: R2Bucket): Promise { let resource_path = make_resource_path(request); let dont_overwrite = request.headers.get('Overwrite') === 'F'; let destination_header = request.headers.get('Destination'); if (destination_header === null) { return new Response('Bad Request', { status: 400 }); } let destination = new URL(destination_header).pathname.slice(1); destination = destination.endsWith('/') ? destination.slice(0, -1) : destination; // Check if the parent directory exists let destination_parent = destination .split('/') .slice(0, destination.endsWith('/') ? -2 : -1) .join('/'); if (destination_parent !== '' && !(await bucket.head(destination_parent))) { return new Response('Conflict', { status: 409 }); } // Check if the destination already exists let destination_exists = await bucket.head(destination); if (dont_overwrite && destination_exists) { return new Response('Precondition Failed', { status: 412 }); } let resource = await bucket.head(resource_path); if (resource === null) { return new Response('Not Found', { status: 404 }); } let is_dir = resource?.customMetadata?.resourcetype === ''; if (is_dir) { let depth = request.headers.get('Depth') ?? 'infinity'; switch (depth) { case 'infinity': { let prefix = resource_path + '/'; const copy = async (object: R2Object) => { let target = destination + '/' + object.key.slice(prefix.length); target = target.endsWith('/') ? target.slice(0, -1) : target; let src = await bucket.get(object.key); if (src !== null) { await bucket.put(target, src.body, { httpMetadata: object.httpMetadata, customMetadata: object.customMetadata, }); } }; let promise_array = [copy(resource)]; for await (let object of listAll(bucket, prefix, true)) { promise_array.push(copy(object)); } await Promise.all(promise_array); if (destination_exists) { return new Response(null, { status: 204 }); } else { return new Response('', { status: 201 }); } } case '0': { let object = await bucket.get(resource.key); if (object === null) { return new Response('Not Found', { status: 404 }); } await bucket.put(destination, object.body, { httpMetadata: object.httpMetadata, customMetadata: object.customMetadata, }); if (destination_exists) { return new Response(null, { status: 204 }); } else { return new Response('', { status: 201 }); } } default: { return new Response('Bad Request', { status: 400 }); } } } else { let src = await bucket.get(resource.key); if (src === null) { return new Response('Not Found', { status: 404 }); } await bucket.put(destination, src.body, { httpMetadata: src.httpMetadata, customMetadata: src.customMetadata, }); if (destination_exists) { return new Response(null, { status: 204 }); } else { return new Response('', { status: 201 }); } } } async function handle_move(request: Request, bucket: R2Bucket): Promise { let resource_path = make_resource_path(request); let overwrite = request.headers.get('Overwrite') === 'T'; let destination_header = request.headers.get('Destination'); if (destination_header === null) { return new Response('Bad Request', { status: 400 }); } let destination = new URL(destination_header).pathname.slice(1); destination = destination.endsWith('/') ? destination.slice(0, -1) : destination; // Check if the parent directory exists let destination_parent = destination .split('/') .slice(0, destination.endsWith('/') ? -2 : -1) .join('/'); if (destination_parent !== '' && !(await bucket.head(destination_parent))) { return new Response('Conflict', { status: 409 }); } // Check if the destination already exists let destination_exists = await bucket.head(destination); if (!overwrite && destination_exists) { return new Response('Precondition Failed', { status: 412 }); } let resource = await bucket.head(resource_path); if (resource === null) { return new Response('Not Found', { status: 404 }); } if (resource.key === destination) { return new Response('Bad Request', { status: 400 }); } if (destination_exists) { // Delete the destination first await handle_delete(new Request(new URL(destination_header), request), bucket); } let is_dir = resource?.customMetadata?.resourcetype === ''; if (is_dir) { let depth = request.headers.get('Depth') ?? 'infinity'; switch (depth) { case 'infinity': { let prefix = resource_path + '/'; const copy = async (object: R2Object) => { let target = destination + '/' + object.key.slice(prefix.length); target = target.endsWith('/') ? target.slice(0, -1) : target; let src = await bucket.get(object.key); if (src !== null) { await bucket.put(target, src.body, { httpMetadata: object.httpMetadata, customMetadata: object.customMetadata, }); await bucket.delete(object.key); } }; let promise_array = [copy(resource)]; for await (let object of listAll(bucket, prefix, true)) { promise_array.push(copy(object)); } await Promise.all(promise_array); if (destination_exists) { return new Response(null, { status: 204 }); } else { return new Response('', { status: 201 }); } } case '0': { let object = await bucket.get(resource.key); if (object === null) { return new Response('Not Found', { status: 404 }); } await bucket.put(destination, object.body, { httpMetadata: object.httpMetadata, customMetadata: object.customMetadata, }); await bucket.delete(resource.key); if (destination_exists) { return new Response(null, { status: 204 }); } else { return new Response('', { status: 201 }); } } default: { return new Response('Bad Request', { status: 400 }); } } } else { let src = await bucket.get(resource.key); if (src === null) { return new Response('Not Found', { status: 404 }); } await bucket.put(destination, src.body, { httpMetadata: src.httpMetadata, customMetadata: src.customMetadata, }); await bucket.delete(resource.key); if (destination_exists) { return new Response(null, { status: 204 }); } else { return new Response('', { status: 201 }); } } } const DAV_CLASS = '1'; const SUPPORT_METHODS = ['OPTIONS', 'PROPFIND', 'MKCOL', 'GET', 'HEAD', 'PUT', 'COPY', 'MOVE']; async function dispatch_handler(request: Request, bucket: R2Bucket): Promise { switch (request.method) { case 'OPTIONS': { return new Response(null, { status: 204, headers: { Allow: SUPPORT_METHODS.join(', '), DAV: DAV_CLASS, }, }); } case 'HEAD': { return await handle_head(request, bucket); } case 'GET': { return await handle_get(request, bucket); } case 'PUT': { return await handle_put(request, bucket); } case 'DELETE': { return await handle_delete(request, bucket); } case 'MKCOL': { return await handle_mkcol(request, bucket); } case 'PROPFIND': { return await handle_propfind(request, bucket); } case 'COPY': { return await handle_copy(request, bucket); } case 'MOVE': { return await handle_move(request, bucket); } default: { return new Response('Method Not Allowed', { status: 405, headers: { Allow: SUPPORT_METHODS.join(', '), DAV: DAV_CLASS, }, }); } } } export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { const { bucket } = env; if (request.headers.get('Authorization') !== `Basic ${btoa(`${env.USERNAME}:${env.PASSWORD}`)}`) { return new Response('Unauthorized', { status: 401, headers: { 'WWW-Authenticate': 'Basic realm="webdav"', }, }); } let response: Response = await dispatch_handler(request, bucket); // Set CORS headers response.headers.set('Access-Control-Allow-Origin', request.headers.get('Origin') ?? '*'); response.headers.set('Access-Control-Allow-Methods', SUPPORT_METHODS.join(', ')); response.headers.set( 'Access-Control-Allow-Headers', ['authorization', 'content-type', 'depth', 'overwrite', 'destination', 'range'].join(', '), ); response.headers.set( 'Access-Control-Expose-Headers', ['content-type', 'content-length', 'dav', 'etag', 'last-modified', 'location', 'date', 'content-range'].join(', '), ); response.headers.set('Access-Control-Allow-Credentials', 'false'); response.headers.set('Access-Control-Max-Age', '86400'); return response; }, };