cat / nz
clone

push a directory, get a url: sh + one Cloudflare Worker + KV

main
nz / worker.js
worker.js 8.3K
1// nz: push a tar, get a url. One KV entry per site: the value is the tar,2// the metadata is { exp, pw }. KV expiration is the garbage collector.3//4// POST / body=tar -> new site, 201 "https://<id>.DOMAIN/\n<info>"5// PUT /<id> body=tar -> replace content (empty body keeps it)6// headers authorization: Bearer TOKEN7// nz-ttl: 30m|12h|14d|2w|never (omit to keep / default)8// nz-password: <pw> | nz-password; (empty removes)910const ID = /^[a-z0-9]{5}$/, ABC = 'abcdefghijklmnopqrstuvwxyz0123456789', DAY = 8640011const UNITS = { m: 60, h: 3600, d: DAY, w: 7 * DAY }12const MIME = {13 html: 'text/html; charset=utf-8', css: 'text/css; charset=utf-8', js: 'text/javascript; charset=utf-8',14 mjs: 'text/javascript; charset=utf-8', json: 'application/json', txt: 'text/plain; charset=utf-8',15 md: 'text/markdown; charset=utf-8', csv: 'text/csv; charset=utf-8', xml: 'application/xml',16 svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',17 webp: 'image/webp', avif: 'image/avif', ico: 'image/x-icon', pdf: 'application/pdf',18 woff2: 'font/woff2', woff: 'font/woff', wasm: 'application/wasm', mp4: 'video/mp4', webm: 'video/webm',19}20const enc = new TextEncoder(), dec = new TextDecoder()21const now = () => Math.floor(Date.now() / 1000)22const text = (body, status = 200) => new Response(body + '\n', { status, headers: { 'content-type': 'text/plain; charset=utf-8' } })23const fail = (status, msg) => { throw text(msg, status) }2425export default {26 async fetch(req, env) {27 const url = new URL(req.url), sub = '.' + env.DOMAIN28 try {29 if (url.host === env.DOMAIN) return await api(req, env, url)30 if (url.host.endsWith(sub)) return await site(req, env, url, url.host.slice(0, -sub.length))31 return text('not found', 404)32 } catch (e) {33 if (e instanceof Response) return e34 throw e35 }36 },37}3839// ---- api (apex host, bearer token) ----4041async function api(req, env, url) {42 if (!same(req.headers.get('authorization') ?? '', `Bearer ${env.TOKEN}`)) fail(401, 'unauthorized')43 const [, id, rest] = url.pathname.split('/')44 if (req.method === 'POST' && !id) return save(req, env, url, await newId(env), null)45 if (req.method === 'PUT' && ID.test(id) && rest === undefined) {46 const old = await env.SITES.getWithMetadata(id, 'arrayBuffer')47 return old.value ? save(req, env, url, id, old) : fail(404, `${id} not found (expired?)`)48 }49 fail(404, 'not found')50}5152async function save(req, env, url, id, old) {53 let tar = await req.arrayBuffer()54 if (!tar.byteLength && old) tar = old.value55 if (tar.byteLength > 25 << 20) fail(413, 'too large: 25 MiB max')56 if (!untar(tar).size) fail(400, 'body must be a ustar archive with at least one file')5758 const h = req.headers, meta = { ...old?.metadata }59 if (h.has('nz-ttl') || !old) meta.exp = expiry(h.get('nz-ttl') ?? env.DEFAULT_TTL ?? '14d')60 if (h.has('nz-password')) meta.pw = h.get('nz-password') ? await hash(h.get('nz-password')) : undefined61 if (meta.exp) meta.exp = Math.max(meta.exp, now() + 60) // KV refuses anything sooner6263 await env.SITES.put(id, tar, { metadata: meta, ...(meta.exp && { expiration: meta.exp }) })64 const info = [meta.exp ? `expires ${new Date(meta.exp * 1000).toISOString()}` : 'never expires']65 if (meta.pw) info.push('password protected')66 return text(`${url.protocol}//${id}.${env.DOMAIN}/\n${info.join(', ')}`, old ? 200 : 201)67}6869function expiry(ttl) {70 if (ttl === 'never' || ttl === '0') return null71 const m = /^(\d+)([mhdw])$/.exec(ttl) ?? fail(400, 'ttl must look like 30m, 12h, 14d, 2w or never')72 return now() + m[1] * UNITS[m[2]]73}7475async function newId(env) {76 for (;;) {77 const id = [...crypto.getRandomValues(new Uint8Array(5))].map(b => ABC[b % 36]).join('')78 if (!await env.SITES.get(id, 'stream')) return id79 }80}8182// ---- sites (<id>.DOMAIN) ----8384async function site(req, env, url, id) {85 if (!ID.test(id)) fail(404, 'not found')86 const { value, metadata: meta } = await env.SITES.getWithMetadata(id, 'arrayBuffer')87 if (!value) fail(404, 'not found')88 if (meta?.pw && req.method === 'POST') return unlock(req, env, url, id, meta.pw)89 if (meta?.pw && !await unlocked(req, env, id, meta.pw)) return lock(401)90 if (req.method !== 'GET' && req.method !== 'HEAD') fail(405, 'method not allowed')9192 const files = untar(value)93 let path = url.pathname.slice(1)94 try { path = decodeURIComponent(path) } catch { fail(400, 'bad path') }95 if (path === '' || path.endsWith('/')) path += 'index.html'96 else if (!files.has(path) && files.has(path + '/index.html')) return Response.redirect(new URL(url.pathname + '/' + url.search, url), 308)9798 const body = files.get(path) ?? fail(404, 'not found')99 return new Response(req.method === 'HEAD' ? null : body, {100 headers: {101 'content-type': MIME[path.split('.').pop().toLowerCase()] ?? 'application/octet-stream',102 'cache-control': meta?.pw ? 'private, no-store' : 'no-cache',103 'x-content-type-options': 'nosniff',104 },105 })106}107108// ustar: 512-byte headers, name at 0 (prefix at 345), octal size at 124, type at 156.109// Dot-segments (.git, ._AppleDouble) are dropped.110function untar(buf) {111 const b = new Uint8Array(buf), files = new Map()112 const str = (at, len) => dec.decode(b.subarray(at, at + len)).replace(/\0[^]*$/, '')113 for (let at = 0; at + 512 <= b.length && b[at];) {114 const size = parseInt(str(at + 124, 12).trim() || '0', 8), type = str(at + 156, 1)115 const name = [str(at + 345, 155), str(at, 100)].filter(Boolean).join('/').replace(/^(\.\/)+/, '')116 if (!(size >= 0) || at + 512 + size > b.length) fail(400, 'truncated or invalid tar')117 if ((type === '0' || type === '') && !name.split('/').some(s => s.startsWith('.')))118 files.set(name, b.subarray(at + 512, at + 512 + size))119 at += 512 + Math.ceil(size / 512) * 512120 }121 return files122}123124// ---- passwords: pbkdf2 verifier in metadata, signed host-only cookie ----125// The cookie signs the verifier, so changing the password logs everyone out.126127const hex = buf => [...new Uint8Array(buf)].map(x => x.toString(16).padStart(2, '0')).join('')128const unhex = s => Uint8Array.from(s.match(/../g), x => parseInt(x, 16))129const same = (a, b) => a.length === b.length && crypto.subtle.timingSafeEqual(enc.encode(a), enc.encode(b))130131async function pbkdf2(pw, salt) {132 const key = await crypto.subtle.importKey('raw', enc.encode(pw), 'PBKDF2', false, ['deriveBits'])133 return hex(await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 100000 }, key, 256))134}135async function hash(pw) {136 const salt = crypto.getRandomValues(new Uint8Array(16))137 return hex(salt) + '.' + await pbkdf2(pw, salt)138}139async function sign(env, msg) {140 const key = await crypto.subtle.importKey('raw', enc.encode(env.TOKEN), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])141 return hex(await crypto.subtle.sign('HMAC', key, enc.encode(msg)))142}143144async function unlocked(req, env, id, pw) {145 const [exp, sig] = (/(?:^|;\s*)__Host-nz=(\d+)\.([0-9a-f]+)/.exec(req.headers.get('cookie')) ?? []).slice(1)146 return exp > now() && same(sig, await sign(env, `${id}.${exp}.${pw}`))147}148149async function unlock(req, env, url, id, pw) {150 const key = `${req.headers.get('cf-connecting-ip')}:${id}`151 if (env.UNLOCK && !(await env.UNLOCK.limit({ key })).success) fail(429, 'too many tries, wait a minute')152 const given = (await req.formData().catch(() => null))?.get('password')153 const [salt, want] = pw.split('.')154 if (typeof given !== 'string' || !same(await pbkdf2(given, unhex(salt)), want)) return lock(401, 'wrong password')155 const exp = now() + 7 * DAY156 return new Response(null, {157 status: 303,158 headers: {159 location: url.pathname + url.search,160 'set-cookie': `__Host-nz=${exp}.${await sign(env, `${id}.${exp}.${pw}`)}; Max-Age=${7 * DAY}; Path=/; HttpOnly; Secure; SameSite=Lax`,161 },162 })163}164165const lock = (status, msg = '') => new Response(`<!doctype html>166<meta charset=utf-8><meta name=viewport content="width=device-width"><meta name=robots content=noindex>167<title>locked</title>168<style>body{font:16px system-ui;display:grid;place-items:center;height:90vh;margin:0}input,button{font:inherit;padding:.4em .6em}p{color:#b00}</style>169<form method=post><p>${msg}</p><input type=password name=password autofocus required placeholder=password> <button>open</button></form>170`, { status, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } })