Skip to content

Commit

Permalink
init
Browse files Browse the repository at this point in the history
  • Loading branch information
markpash committed Mar 10, 2023
0 parents commit 4dc729e
Show file tree
Hide file tree
Showing 7 changed files with 210 additions and 0 deletions.
13 changes: 13 additions & 0 deletions .denoflare
Original file line number Diff line number Diff line change
@@ -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" ]
}
}
}
4 changes: 4 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"deno.enable": true,
"deno.unstable": true
}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# apt-cf-worker
2 changes: 2 additions & 0 deletions deps.ts
Original file line number Diff line number Diff line change
@@ -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';
47 changes: 47 additions & 0 deletions listing.ts
Original file line number Diff line number Diff line change
@@ -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 = ['<!DOCTYPE html>', '<html>', '<head>', '<style>', STYLE, '</style>', '</head>', '<body>'];

lines.push('<div id="contents">');
lines.push(`<div class="full">${computeBreadcrumbs(prefix)}</div>`);
lines.push('<div class="full">&nbsp;</div>');
lines.push(`<div>Name</div><div class="ralign">Size (bytes)</div>`);
if (objects.delimitedPrefixes.length > 0) {
for (const delimitedPrefix of objects.delimitedPrefixes) {
lines.push(`<a class="full" href="${encodeXml('/' + delimitedPrefix)}">${encodeXml(delimitedPrefix.substring(prefix.length))}</a>`);
}
lines.push('<div class="full">&nbsp;</div>');
}
for (const obj of objects.objects) {
lines.push(`<a href="${encodeXml('/' + obj.key)}">${encodeXml(obj.key.substring(prefix.length))}</a><div class="ralign">${obj.size.toLocaleString()}</div>`);
}
if (cursor) {
lines.push('<div class="full">&nbsp;</div>');
lines.push(`<div class="full"><a href="?cursor=${encodeXml(cursor)}">next ➜</a></div>`);
}
lines.push('</div>');

lines.push('</body>', '</html>');
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)) : `<a href="${tokens.slice(0, i + 1).join('/') + '/'}">${i === 0 ? 'root' : encodeXml(v)}</a>`}`).join('');
}
137 changes: 137 additions & 0 deletions worker.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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<Response> {
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<R2Object | null> = (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>): 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;
}
6 changes: 6 additions & 0 deletions worker_env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { R2Bucket } from './deps.ts';

export interface WorkerEnv {
readonly bucket: R2Bucket;
readonly allowCorsOrigins?: string;
}

0 comments on commit 4dc729e

Please sign in to comment.