From 4dc729e460ba2613a2f97e99eb3fa94ab7c2f127 Mon Sep 17 00:00:00 2001 From: Mark Pashmfouroush Date: Fri, 10 Mar 2023 22:09:52 +0000 Subject: [PATCH] init --- .denoflare | 13 ++++ .vscode/settings.json | 4 ++ README.md | 1 + deps.ts | 2 + listing.ts | 47 +++++++++++++++ worker.ts | 137 ++++++++++++++++++++++++++++++++++++++++++ worker_env.d.ts | 6 ++ 7 files changed, 210 insertions(+) create mode 100644 .denoflare create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 deps.ts create mode 100644 listing.ts create mode 100644 worker.ts create mode 100644 worker_env.d.ts diff --git a/.denoflare b/.denoflare new file mode 100644 index 0000000..1bef189 --- /dev/null +++ b/.denoflare @@ -0,0 +1,13 @@ +{ + "$schema": "https://raw.githubusercontent.com/skymethod/denoflare/v0.5.11/common/config.schema.json", + "scripts": { + "noql-apt": { + "path": "worker.ts", + "localPort": 3030, + "bindings": { + "allowCorsOrigins": { "value": "apt.noql.net" } + }, + "customDomains": [ "apt.noql.net" ] + } + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be67fe4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "deno.enable": true, + "deno.unstable": true +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..c18929d --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# apt-cf-worker diff --git a/deps.ts b/deps.ts new file mode 100644 index 0000000..2cef1d4 --- /dev/null +++ b/deps.ts @@ -0,0 +1,2 @@ +export type { IncomingRequestCf, R2Bucket, R2Object, R2Objects, R2ObjectBody, R2ListOptions } from 'https://raw.githubusercontent.com/skymethod/denoflare/v0.5.11/common/cloudflare_workers_types.d.ts'; +export { encodeXml } from 'https://raw.githubusercontent.com/skymethod/denoflare/v0.5.11/common/xml_util.ts'; diff --git a/listing.ts b/listing.ts new file mode 100644 index 0000000..e80a709 --- /dev/null +++ b/listing.ts @@ -0,0 +1,47 @@ +import { encodeXml, R2Objects } from './deps.ts'; + +export function computeDirectoryListingHtml(objects: R2Objects, opts: { prefix: string, cursor?: string }): string { + const { prefix, cursor } = opts; + const lines = ['', '', '', '', '', '']; + + lines.push('
'); + lines.push(`
${computeBreadcrumbs(prefix)}
`); + lines.push('
 
'); + lines.push(`
Name
Size (bytes)
`); + if (objects.delimitedPrefixes.length > 0) { + for (const delimitedPrefix of objects.delimitedPrefixes) { + lines.push(`${encodeXml(delimitedPrefix.substring(prefix.length))}`); + } + lines.push('
 
'); + } + for (const obj of objects.objects) { + lines.push(`${encodeXml(obj.key.substring(prefix.length))}
${obj.size.toLocaleString()}
`); + } + if (cursor) { + lines.push('
 
'); + lines.push(``); + } + lines.push('
'); + + lines.push('', ''); + return lines.join('\n'); +} + +const STYLE = ` +body { margin: 3rem; font-family: sans-serif; } +a { text-decoration: none; text-underline-offset: 0.2rem; } +a:hover { text-decoration: underline; } +.ralign { text-align: right; } +#contents { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem 1.5rem; white-space: nowrap; } +#contents .full { grid-column: 1 / span 2; } + +@media (prefers-color-scheme: dark) { + body {background: #151920; color: #f5f5f5; } + a { color: #3bb13b; } +} +`; + +function computeBreadcrumbs(prefix: string): string { + const tokens = ('/' + prefix).split('/').filter((v, i) => i === 0 || v !== ''); + return tokens.map((v, i) => `${i === 0 ? '' : ` ⟩ `}${i === tokens.length - 1 ? (i === 0 ? 'root' : encodeXml(v)) : `${i === 0 ? 'root' : encodeXml(v)}`}`).join(''); +} diff --git a/worker.ts b/worker.ts new file mode 100644 index 0000000..894249a --- /dev/null +++ b/worker.ts @@ -0,0 +1,137 @@ +import { IncomingRequestCf, R2Object, R2ObjectBody, R2ListOptions } from './deps.ts'; +import { computeDirectoryListingHtml } from './listing.ts'; +import { WorkerEnv } from './worker_env.d.ts'; + +export default { + async fetch(request: IncomingRequestCf, env: WorkerEnv): Promise { + try { + return await computeResponse(request, env); + } catch (e) { + if (typeof e === 'object' && tryParseMessageCode(e.message) === 10039) { // The requested range is not satisfiable (10039) + return new Response(e.message, { status: 416 }); + } + return new Response(`${e.stack || e}`, { status: 500 }); + } + } +}; + +declare global { + interface ResponseInit { + // non-standard cloudflare property, defaults to 'auto' + encodeBody?: 'auto' | 'manual'; + } +} + +const DIR_LIST_LIMIT = 1000; + +function tryParseMessageCode(message: unknown): number | undefined { + // The requested range is not satisfiable (10039) + const m = /^.*?\((\d+)\)$/.exec(typeof message === 'string' ? message : ''); + return m ? parseInt(m[1]) : undefined; +} + +async function computeResponse(request: IncomingRequestCf, env: WorkerEnv): Promise { + const bucket = env.bucket; + const allowCorsOrigins = stringSetFromCsv(env.allowCorsOrigins); + + const { method, url, headers } = request; + + if (method !== 'GET' && method !== 'HEAD') { + return new Response(`Method '${method}' not allowed`, { status: 405 }); + } + + const { pathname, searchParams } = new URL(url); + let key = pathname.substring(1); // strip leading slash + key = decodeURIComponent(key); + + // special handling for robots.txt + if (key === 'robots.txt') { + return new Response(method === 'GET' ? 'User-agent: *\nDisallow: /' : undefined, { headers: { 'content-type': 'text/plain; charset=utf-8' } }); + } + + let obj: R2Object | null = null; + const getOrHead: (key: string) => Promise = (key) => { + return method === 'GET' ? bucket.get(key) : bucket.head(key); + }; + + + // first, try to request the object at the given key + obj = key === '' ? null : await getOrHead(key); + if (obj) { + const accessControlAllowOrigin = computeAccessControlAllowOrigin(obj, headers.get('origin') ?? undefined, allowCorsOrigins); + return computeObjResponse(obj, 200, accessControlAllowOrigin); + } + + // R2 object not found, try listing a directory + let prefix = pathname.substring(1); + let redirect = false; + if (prefix !== '' && !prefix.endsWith('/')) { + prefix += '/'; + redirect = true; + } + + const options: R2ListOptions = { delimiter: '/', limit: DIR_LIST_LIMIT, prefix: prefix === '' ? undefined : prefix, cursor: searchParams.get('cursor') || undefined }; + const objects = await bucket.list(options); + if (objects.delimitedPrefixes.length > 0 || objects.objects.length > 0) { + const { cursor } = objects; + return redirect ? temporaryRedirect({ location: '/' + prefix }) : new Response(computeDirectoryListingHtml(objects, { prefix, cursor }), { headers: { 'content-type': 'text/html; charset=utf-8' } }); + } + + return notFound(method); +} + +function stringSetFromCsv(value: string | undefined) { + return new Set((value ?? '').split(',').map(v => v.trim()).filter(v => v !== '')); +} + +function notFound(method: string): Response { + return new Response(method === 'HEAD' ? undefined : 'not found', { status: 404, headers: { 'content-type': 'text/plain; charset=utf-8' } }); +} + +function temporaryRedirect(opts: { location: string }): Response { + const { location } = opts; + return new Response(undefined, { status: 307, headers: { 'location': location } }); +} + +function isR2ObjectBody(obj: R2Object): obj is R2ObjectBody { + return 'body' in obj; +} + +function computeObjResponse(obj: R2Object, status: number, accessControlAllowOrigin?: string): Response { + let body: ReadableStream | undefined; + if (isR2ObjectBody(obj)) { + body = obj.body; + } + + const headers = new Headers(); + // writes content-type, content-encoding, content-disposition, i.e. the values from obj.httpMetadata + obj.writeHttpMetadata(headers); + + // obj.size represents the full size, but seems to be clamped by the cf frontend down to the actual number of bytes in the partial response + // exactly what we want in a content-length header + headers.set('content-length', String(obj.size)); + + if (accessControlAllowOrigin) headers.set('access-control-allow-origin', accessControlAllowOrigin); + + // non-standard cloudflare ResponseInit property indicating the response is already encoded + // required to prevent the cf frontend from double-encoding it, or serving it encoded without a content-encoding header + const encodeBody = headers.has('content-encoding') ? 'manual' : undefined; + return new Response(body, { status, headers, encodeBody }); +} + +function computeAccessControlAllowOrigin(obj: R2Object, requestOrigin: string | undefined, allowCorsOrigins: Set): string | undefined { + // is request origin allowed? + if (allowCorsOrigins.size === 0) { + return undefined; + } + + if (allowCorsOrigins.has('*')) { + return '*'; + } + + if (requestOrigin && allowCorsOrigins.has(requestOrigin)) { + return requestOrigin; + } + + return undefined; +} diff --git a/worker_env.d.ts b/worker_env.d.ts new file mode 100644 index 0000000..450eee4 --- /dev/null +++ b/worker_env.d.ts @@ -0,0 +1,6 @@ +import { R2Bucket } from './deps.ts'; + +export interface WorkerEnv { + readonly bucket: R2Bucket; + readonly allowCorsOrigins?: string; +}