// pithos-hosted: s3d on Silent Mode with each person's own Sia account. // // One HTTP listener (behind nginx/TLS) serves four kinds of traffic: // // /hosted/v1/... sign-in, sign-up gate, access rules, moving a data // folder in and out (Bearer session token) // /api/... the owner's own Pithos API, forwarded to their // per-account control server (Bearer session token) // /p///.. public files, /l/ share links (no auth) // anything else S3, path-style. Owner keys pass straight through to // their s3d (which checks the signature itself); keys // minted for other people are verified and limited here, // then re-signed with the owner's key. import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { pipeline } from 'node:stream/promises'; import { readConfig } from '../core/config.js'; import { objectPath } from '../core/s3.js'; import { createAuth, accountId, challengeText, verifyEd25519 } from './auth.js'; import { createGate, bnsMessage, depositMemo } from './gate.js'; import { createSupervisor, httpError } from './supervisor.js'; import { parseAuth, verify, SigError } from './sigv4.js'; import { validateRule, classify, decideGrant, publicRuleFor, live, forOwner } from './rules.js'; import { readJson, writeJson } from './store.js'; import { isTransferPath, transferTarget, hashFile } from '../core/hosted.js'; // Owner API calls the gateway forwards. Everything that would act on the // server itself (binary, s3d.yml, Sia login, account switching) is the // gateway's business, and object routes are refused so nothing can bypass // the client-side encryption. const FORWARD = [ ['GET', /^\/api\/status$/], ['GET', /^\/api\/logs$/], ['GET', /^\/api\/stats$/], ['POST', /^\/api\/flush$/], ['GET', /^\/api\/autoflush$/], ['POST', /^\/api\/daemon\/(start|restart)$/], ['GET', /^\/api\/users$/], ['POST', /^\/api\/users$/], ['DELETE', /^\/api\/users\/[^/]+$/], ['GET', /^\/api\/keys$/], ['POST', /^\/api\/keys$/], ['DELETE', /^\/api\/keys\/[^/]+$/], ['GET', /^\/api\/account$/], ['PUT', /^\/api\/account\/label$/], ['GET', /^\/api\/space$/], ]; const KEY_CHANGES = /^\/api\/(users|keys)/; export async function createHosted(cfg, log = console.log) { fs.mkdirSync(cfg.root, { recursive: true }); const auth = createAuth(cfg.root); const grantsFile = path.join(cfg.root, 'grants.json'); const linksFile = path.join(cfg.root, 'links.json'); const grants = readJson(grantsFile, {}); // accessKeyId -> { id, rule } const links = readJson(linksFile, {}); // token -> { id, rule } const gate = createGate(cfg.root, cfg.gate || {}); // Grant key ids are the gateway's: no account's s3d may route the same id. const sup = createSupervisor(cfg, log, { reservedKey: (akid) => /^PG/.test(akid) || Object.hasOwn(grants, akid) }); const rulesFile = (id) => path.join(sup.dir(id), 'rules.json'); const rulesOf = (id) => readJson(rulesFile(id), []); function saveRules(id, rules) { writeJson(rulesFile(id), rules); for (const [k, v] of Object.entries(grants)) if (v.id === id) delete grants[k]; for (const [k, v] of Object.entries(links)) if (v.id === id) delete links[k]; for (const r of rules) { if (r.type === 'grant') grants[r.accessKeyId] = { id, rule: r.id }; if (r.type === 'link') links[r.token] = { id, rule: r.id }; } writeJson(grantsFile, grants); writeJson(linksFile, links); } const ruleById = (id, ruleId) => rulesOf(id).find((r) => r.id === ruleId) || null; // ---- traffic meter ------------------------------------------------------- const month = () => new Date().toISOString().slice(0, 7); const usage = new Map(); // id -> { month, down, up } function usageOf(id) { let u = usage.get(id); if (!u) { u = readJson(path.join(sup.dir(id), 'usage.json'), null) || { month: month(), down: 0, up: 0 }; usage.set(id, u); } if (u.month !== month()) { u.month = month(); u.down = 0; u.up = 0; } return u; } const meterTimer = setInterval(() => { for (const [id, u] of usage) writeJson(path.join(sup.dir(id), 'usage.json'), u); }, 30_000); meterTimer.unref?.(); function checkCap(id) { if (cfg.downloadCapBytes && usageOf(id).down >= cfg.downloadCapBytes) { throw httpError(429, `this account has used its ${fmtBytes(cfg.downloadCapBytes)} of downloads this month`); } } // ---- helpers --------------------------------------------------------------- const clientIp = (req) => (cfg.trustProxy && String(req.headers['x-forwarded-for'] || '').split(',')[0].trim()) || req.socket.remoteAddress || ''; function bearer(req) { const m = /^Bearer\s+(\S+)$/.exec(req.headers.authorization || ''); const id = m && auth.lookup(m[1]); if (!id) throw httpError(401, 'sign in first'); return { id, token: m[1] }; } async function forwardApi(req, res, url, id) { const a = sup.account(id); if (url.pathname === '/api/status' && a.state !== 'active') { return json(res, 200, hostedStatus(a, { daemon: { state: 'stopped' }, registration: { registered: false } })); } const reqBody = req.method === 'GET' ? null : JSON.stringify(await jsonBody(req)); // A chosen access key id must not be one another account (or a grant) // routes: S3 traffic is sent to whichever s3d owns the id. if (req.method === 'POST' && url.pathname === '/api/keys') { const want = JSON.parse(reqBody).accessKey; if (want && sup.keyTaken(String(want), id)) throw httpError(409, 'that access key id is already in use; leave it empty to get a random one'); } const inst = await sup.ensure(id); const release = sup.hold(inst); try { const target = new URL(url.pathname + url.search, inst.pithos.url); const up = await new Promise((resolve, reject) => { const r = http.request(target, { method: req.method, headers: { cookie: inst.cookie, 'x-pithos': '1', 'content-type': 'application/json' }, }, resolve); r.on('error', reject); r.end(reqBody ?? undefined); }); const text = await readAll(up); if (req.method !== 'GET' && KEY_CHANGES.test(url.pathname)) await sup.indexKeys(inst); let body = text; if (up.statusCode === 200 && url.pathname === '/api/status') body = JSON.stringify(hostedStatus(a, JSON.parse(text))); if (up.statusCode === 200 && url.pathname === '/api/account') { const acc = JSON.parse(text); body = JSON.stringify({ indexerUrl: acc.indexerUrl, registered: acc.registered, label: acc.label, sia: acc.sia || null, archives: [], hosted: true }); } res.writeHead(up.statusCode, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); res.end(body); } finally { release(); } } // Server paths stay on the server. function hostedStatus(a, st) { return { ...st, host: 'hosted', config: st.config ? { apiAddress: null, adminAddress: null, file: null, directory: null, https: null, exists: true } : undefined, hosted: { id: a.id, name: a.gate?.type === 'bns' ? a.gate.name : null, state: a.state, publicUrl: cfg.publicUrl, usage: usageOf(a.id), downloadCapBytes: cfg.downloadCapBytes || null }, }; } // ---- data folder in and out ------------------------------------------------ const incomingDir = (id) => path.join(sup.dir(id), 'incoming'); const incomingManifest = (id) => path.join(sup.dir(id), 'incoming.json'); function safeRel(rel) { if (!isTransferPath(rel)) throw httpError(400, 'bad file path'); return rel; } // Bytes actually on disk under a folder: aborted uploads count too. function diskBytes(dir) { let n = 0; const rec = (d) => { let entries = []; try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; } for (const e of entries) { const p = path.join(d, e.name); if (e.isDirectory()) rec(p); else if (e.isFile()) { try { n += fs.statSync(p).size; } catch {} } } }; rec(dir); return n; } // One upload at a time per account, so the size limit cannot be raced. const receiving = new Set(); async function receiveFile(req, id, rel) { const a = sup.account(id); if (a.state !== 'empty') throw httpError(409, 'drives are already hosted here; move them back to your computer first'); const name = safeRel(rel); if (receiving.has(id)) throw httpError(409, 'send one file at a time'); receiving.add(id); const file = transferTarget(incomingDir(id), name); try { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.rmSync(file, { force: true }); const limit = cfg.maxDataBytes || 4 * 1024 ** 3; const room = limit - diskBytes(incomingDir(id)); const declared = Number(req.headers['content-length']); if (Number.isFinite(declared) && declared > room) throw httpError(413, 'the data folder is larger than this server accepts'); const hash = crypto.createHash('sha256'); let size = 0; req.on('data', (c) => { hash.update(c); size += c.length; if (size > room) req.destroy(httpError(413, 'the data folder is larger than this server accepts')); }); await pipeline(req, fs.createWriteStream(file)); const man = readJson(incomingManifest(id), {}); man[name] = { size, sha256: hash.digest('hex') }; writeJson(incomingManifest(id), man); return man[name]; } catch (e) { fs.rmSync(file, { force: true }); throw e; } finally { receiving.delete(id); } } async function commit(id, files) { const a = sup.account(id); if (a.state !== 'empty') throw httpError(409, 'drives are already hosted here'); const man = readJson(incomingManifest(id), {}); if (!Array.isArray(files) || !files.length) throw httpError(400, 'no files listed'); for (const f of files) { const got = Object.hasOwn(man, safeRel(f.path)) ? man[f.path] : null; if (!got || got.sha256 !== f.sha256 || got.size !== f.size) throw httpError(400, `${f.path} did not arrive intact; send it again`); } if (Object.keys(man).length !== files.length) throw httpError(400, 'the server has files the list does not mention; start the move again'); if (!fs.existsSync(path.join(incomingDir(id), 's3d.db'))) throw httpError(400, 'this is not an s3d data folder (no s3d.db)'); await sup.close(id, 'new data folder'); fs.rmSync(sup.dataDir(id), { recursive: true, force: true }); fs.renameSync(incomingDir(id), sup.dataDir(id)); fs.rmSync(incomingManifest(id), { force: true }); sup.setState(id, 'active'); // Anything wrong with the folder puts the account back to empty, so the // owner's computer (which asks the server's state) keeps its copy. const refuse = async (status, message) => { await sup.close(id, 'refused data folder'); fs.rmSync(sup.dataDir(id), { recursive: true, force: true }); fs.mkdirSync(sup.dataDir(id), { recursive: true }); sup.setState(id, 'empty'); throw httpError(status, message); }; let inst; try { inst = await sup.ensure(id); } catch (e) { return refuse(502, `s3d could not start on the moved folder: ${e.message}`); } const reg = await inst.cli.registration().catch(() => ({ registered: false })); if (!reg.registered) return refuse(422, 'the moved folder has no Sia connection'); const keys = await sup.indexKeys(inst); if (inst.conflicts?.length) return refuse(409, `these access keys are already used on this server: ${inst.conflicts.join(', ')}; create new keys on your computer and try again`); return { registered: true, indexerUrl: reg.indexerUrl || null, users: [...new Set(keys.map((k) => k.user))] }; } // Hashes stream from disk (buffered objects can be gigabytes) and only // paths the owner's Pithos will accept are listed. async function walk(base) { const out = []; const rec = async (d) => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); const rel = path.relative(base, p).split(path.sep).join('/'); if (e.isDirectory()) await rec(p); else if (e.isFile() && isTransferPath(rel)) out.push({ path: rel, ...(await hashFile(p)) }); } }; await rec(base); return out; } async function checkout(id, force) { const a = sup.account(id); if (a.state === 'moving-out') return { files: await walk(sup.dataDir(id)) }; if (a.state !== 'active') throw httpError(409, 'nothing is hosted here'); const inst = await sup.ensure(id); const flushed = await sup.flushQuietly(inst, 10 * 60_000); if (!flushed && !force) throw httpError(409, 'some uploads could not be sent to Sia yet; try again later, or move anyway and they will finish on your computer'); // State first: from now on no request can start s3d on this folder again. sup.setState(id, 'moving-out'); await sup.close(id, 'moving to the owner\'s computer'); return { files: await walk(sup.dataDir(id)) }; } function release(id) { const a = sup.account(id); if (a.state !== 'moving-out') throw httpError(409, 'start the move first'); fs.rmSync(sup.dataDir(id), { recursive: true, force: true }); fs.mkdirSync(sup.dataDir(id), { recursive: true }); saveRules(id, []); sup.setState(id, 'empty'); return { ok: true }; } // ---- S3 ---------------------------------------------------------------------- // For shared keys: "." and ".." segments are refused outright, so a key can // never be made to look inside its folder while naming something outside. function splitS3Path(rawPath) { const parts = rawPath.split('/').slice(1); const dec = (s) => { try { return decodeURIComponent(s); } catch { throw httpError(400, 'malformed path'); } }; const bucket = dec(parts[0] || ''); const keyParts = parts.slice(1).map(dec); const key = parts.length > 1 ? keyParts.join('/') : null; if (key != null && key.split('/').some((x) => x === '.' || x === '..')) throw httpError(400, 'paths with . or .. segments are not allowed'); return { bucket, key }; } async function s3(req, res, url) { const a = parseAuth(req, url); if (!a) return s3Error(res, 403, 'AccessDenied', 'sign requests with a key; public files are at /p///'); const g = Object.hasOwn(grants, a.akid) ? grants[a.akid] : null; const ownerId = g ? null : sup.ownerOfKey(a.akid); if (ownerId) { checkReadCap(req, ownerId); const inst = await sup.ensure(ownerId); return rawProxy(req, res, inst); } const rule = g && ruleById(g.id, g.rule); if (!rule) return s3Error(res, 403, 'InvalidAccessKeyId', 'The AWS Access Key Id you provided does not exist in our records.'); verify(req, url, a, rule.secretKey); const raw = req.url.split('?')[0]; const { bucket, key } = splitS3Path(raw); // The client's own signature parameters (presigned URLs) must not reach // s3d next to the owner signature the gateway adds. const query = Object.fromEntries([...url.searchParams].filter(([k]) => !/^x-amz-/i.test(k))); const { op } = classify(req.method, query, key, req.headers); const d = decideGrant(rule, bucket, op, key, query.prefix || ''); if (!d.allow) return s3Error(res, 403, 'AccessDenied', d.reason); const payload = req.headers['x-amz-content-sha256'] || 'UNSIGNED-PAYLOAD'; if (payload === 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD') { return s3Error(res, 501, 'NotImplemented', 'signed streaming uploads are not supported with shared keys; turn off payload signing in your S3 client'); } checkReadCap(req, g.id); const inst = await sup.ensure(g.id); const client = await sup.bucketClient(inst, bucket); if (!client) return s3Error(res, 404, 'NoSuchBucket', 'The specified bucket does not exist'); if (d.needsAbsent) { const head = await client.request('HEAD', objectPath(bucket, key)); head.resume(); if (head.statusCode === 200) return s3Error(res, 403, 'AccessDenied', 'this key may add files but not replace them'); } const pass = {}; for (const [k, v] of Object.entries(req.headers)) { if (/^(content-type|content-length|content-md5|content-encoding|content-disposition|cache-control|range|if-(match|none-match|modified-since|unmodified-since))$/.test(k) || (k.startsWith('x-amz-') && !/^x-amz-(date|content-sha256|security-token)$/.test(k))) pass[k] = v; } const release = sup.hold(inst); try { const up = await client.request(req.method, key == null ? objectPath(bucket) : objectPath(bucket, key), { query, headers: pass, body: req, payloadHash: payload }); relay(up, res, g.id, req.method); await done(res); } finally { release(); } } function checkReadCap(req, id) { if (req.method === 'GET') checkCap(id); } async function rawProxy(req, res, inst) { const c = readConfig(sup.configFile(inst.id)); const [host, port] = c.apiAddress.split(':'); const release = sup.hold(inst); const u = usageOf(inst.id); try { const up = await new Promise((resolve, reject) => { const headers = { ...req.headers }; delete headers['x-forwarded-for']; delete headers['x-real-ip']; delete headers.connection; const r = http.request({ host, port: Number(port), method: req.method, path: req.url, headers }, resolve); r.on('error', reject); req.on('data', (chunk) => { u.up += chunk.length; }); req.pipe(r); }); relay(up, res, inst.id, req.method); await done(res); } finally { release(); } } function relay(up, res, id, method) { const u = usageOf(id); res.writeHead(up.statusCode, up.headers); if (method === 'GET') up.on('data', (c) => { u.down += c.length; }); up.pipe(res); } // ---- public files and links ---------------------------------------------------- async function serveObject(req, res, id, bucket, key, { inline = true } = {}) { checkCap(id); const inst = await sup.ensure(id); const client = await sup.bucketClient(inst, bucket); if (!client) throw httpError(404, 'not found'); const release = sup.hold(inst); try { const up = await client.getObject(bucket, key, { range: req.headers.range }); if (up.statusCode >= 300 && up.statusCode !== 304) { up.resume(); throw httpError(up.statusCode === 403 ? 404 : up.statusCode, 'not found'); } const headers = {}; for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges', 'etag', 'last-modified']) if (up.headers[h]) headers[h] = up.headers[h]; const name = key.split('/').pop() || 'download'; headers['content-disposition'] = `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(name)}`; headers['x-content-type-options'] = 'nosniff'; headers['content-security-policy'] = "sandbox; default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'"; headers['cache-control'] = 'public, max-age=60'; const u = usageOf(id); res.writeHead(up.statusCode, headers); if (req.method === 'HEAD') { up.resume(); return res.end(); } up.on('data', (c) => { u.down += c.length; }); up.pipe(res); await done(res); } finally { release(); } } // /p///. Also reached as // navigate.st/s3/…, so every link and redirect here is relative. async function servePublic(req, res, url) { const m = /^\/p\/([^/]+)\/([^/]+)(\/?)(.*)$/.exec(url.pathname); if (!m) throw httpError(404, 'not found'); const [, who, bucket, slash] = m; let whoName = who; try { whoName = decodeURIComponent(who); } catch {} const id = /^[0-9a-f]{32}$/.test(who) ? who : sup.accountByName(whoName); if (!id) throw httpError(404, 'not found'); // The drive itself without its slash: add it, so relative links resolve. if (!slash && !m[4]) { res.writeHead(301, { location: `${encodeURIComponent(bucket)}/${url.search}` }); return res.end(); } let key; try { key = decodeURIComponent(m[3]); } catch { throw httpError(404, 'not found'); } if (key.split('/').some((x) => x === '.' || x === '..')) throw httpError(404, 'not found'); const rule = publicRuleFor(rulesOf(id), bucket, key); if (!rule) throw httpError(404, 'not found'); if (key === '' || key.endsWith('/')) { if (rule.rights !== 'read-list') throw httpError(404, 'not found'); const inst = await sup.ensure(id); const client = await sup.bucketClient(inst, bucket); if (!client) throw httpError(404, 'not found'); const page = await client.listObjects(bucket, { prefix: key, token: url.searchParams.get('token') || undefined }); const listing = { folders: page.folders.map((f) => f.slice(key.length)), files: page.objects.filter((o) => o.key !== key).map((o) => ({ name: o.key.slice(key.length), size: o.size, modified: o.modified })), next: page.truncated ? page.nextToken : null, }; // Browsers get a page with links and a player; tools get the JSON. if (/text\/html/.test(req.headers.accept || '') && !url.searchParams.has('json')) { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': "default-src 'self'; style-src 'unsafe-inline'; img-src 'self'; media-src 'self'; script-src 'unsafe-inline'; frame-ancestors 'none'", 'x-content-type-options': 'nosniff' }); return res.end(folderPage({ owner: whoName, bucket, key, listing, token: url.searchParams.get('token') })); } return json(res, 200, listing); } return serveObject(req, res, id, bucket, key); } async function serveLink(req, res, url) { const token = url.pathname.slice(3); const l = Object.hasOwn(links, token) ? links[token] : null; const rule = l && ruleById(l.id, l.rule); if (!rule || !live(rule)) throw httpError(410, 'this link has expired'); return serveObject(req, res, l.id, rule.bucket, rule.prefix, { inline: !url.searchParams.has('download') }); } // ---- routing ------------------------------------------------------------------- async function handle(req, res) { const url = new URL(req.url, cfg.publicUrl); const isS3 = /^AWS4-HMAC-SHA256 /.test(req.headers.authorization || '') || url.searchParams.has('X-Amz-Algorithm'); if (isS3) return s3(req, res, url); const p = url.pathname; if (p.startsWith('/p/') && (req.method === 'GET' || req.method === 'HEAD')) return servePublic(req, res, url); if (p.startsWith('/l/') && (req.method === 'GET' || req.method === 'HEAD')) return serveLink(req, res, url); if (p === '/hosted/v1/info' && req.method === 'GET') { return json(res, 200, { publicUrl: cfg.publicUrl, gate: gate.describe(), downloadCapBytes: cfg.downloadCapBytes || null, s3Endpoint: cfg.publicUrl }); } if (p === '/hosted/v1/challenge' && req.method === 'GET') { const nonce = auth.challenge(); return json(res, 200, { nonce, message: challengeText(cfg.publicUrl, nonce) }); } if (p === '/hosted/v1/session' && req.method === 'POST') { const body = await jsonBody(req); if (!auth.takeNonce(body.nonce)) throw httpError(401, 'the sign-in request expired; try again'); if (!verifyEd25519(body.pubkey, challengeText(cfg.publicUrl, body.nonce), body.signature)) throw httpError(401, 'the signature does not match'); const id = accountId(body.pubkey); let a = sup.account(id); if (!a) { if (!body.signup) { return json(res, 404, { error: 'no hosted account for this key yet', needsSignup: true, id, gate: gate.describe(), bnsMessage: bnsMessage(id), depositMemo: depositMemo(id) }); } const g = await gate.admit(body.signup, id, clientIp(req)); a = sup.createAccount({ id, pubkey: body.pubkey.toLowerCase(), gate: g }); log(`[${id.slice(0, 8)}] new account via ${g.type}`); } return json(res, 200, { token: auth.issue(id), id, state: a.state }); } if (p.startsWith('/hosted/v1/') || p.startsWith('/api/')) { const { id, token } = bearer(req); if (p.startsWith('/api/')) { if (!FORWARD.some(([m, re]) => m === req.method && re.test(p))) throw httpError(403, 'not available on a hosted account'); return forwardApi(req, res, url, id); } const a = sup.account(id); const q = url.searchParams; switch (`${req.method} ${p}`) { case 'GET /hosted/v1/me': return json(res, 200, { id, state: a.state, gate: { type: a.gate?.type, name: a.gate?.name || null }, createdAt: a.createdAt, usage: usageOf(id), downloadCapBytes: cfg.downloadCapBytes || null, publicUrl: cfg.publicUrl }); case 'DELETE /hosted/v1/session': auth.revoke(token); return json(res, 200, { ok: true }); case 'GET /hosted/v1/rules': return json(res, 200, forOwner(rulesOf(id))); case 'POST /hosted/v1/rules': { const inst = await sup.ensure(id); const rule = validateRule(await jsonBody(req), await sup.mapBuckets(inst)); const rules = rulesOf(id).filter((r) => live(r)); if (rules.length >= 500) throw httpError(400, 'too many rules on one account'); rules.push(rule); saveRules(id, rules); return json(res, 200, rule); } case 'DELETE /hosted/v1/rules': { const rules = rulesOf(id); const next = rules.filter((r) => r.id !== q.get('id')); if (next.length === rules.length) throw httpError(404, 'no such rule'); saveRules(id, next); return json(res, 200, { ok: true }); } case 'PUT /hosted/v1/data/file': return json(res, 200, await receiveFile(req, id, q.get('path'))); case 'POST /hosted/v1/data/commit': return json(res, 200, await commit(id, (await jsonBody(req)).files)); case 'POST /hosted/v1/data/abort': if (a.state !== 'empty') throw httpError(409, 'nothing to abort'); fs.rmSync(incomingDir(id), { recursive: true, force: true }); fs.rmSync(incomingManifest(id), { force: true }); return json(res, 200, { ok: true }); case 'POST /hosted/v1/data/checkout': return json(res, 200, await checkout(id, q.get('force') === '1')); case 'GET /hosted/v1/data/file': { if (a.state !== 'moving-out') throw httpError(409, 'start the move first'); const file = path.join(sup.dataDir(id), safeRel(q.get('path'))); if (!fs.existsSync(file)) throw httpError(404, 'no such file'); res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': fs.statSync(file).size }); await pipeline(fs.createReadStream(file), res); return; } case 'POST /hosted/v1/data/cancel-checkout': if (a.state !== 'moving-out') throw httpError(409, 'no move in progress'); sup.setState(id, 'active'); return json(res, 200, { ok: true }); case 'POST /hosted/v1/data/release': return json(res, 200, release(id)); default: throw httpError(404, 'no such endpoint'); } } throw httpError(404, 'not found'); } const server = http.createServer((req, res) => { handle(req, res).catch((err) => { const status = err instanceof SigError ? err.status : err.status || 500; if (status >= 500) log('[hosted]', err.stack || err.message); if (res.headersSent) return res.destroy(); const isS3 = /^AWS4-HMAC-SHA256 /.test(req.headers.authorization || ''); if (isS3) return s3Error(res, status, err.code || (status === 404 ? 'NoSuchKey' : 'InternalError'), err.message); json(res, status, { error: status >= 500 && !err.status ? 'internal error' : err.message }); }); }); server.requestTimeout = 0; // large uploads and downloads stream for a long time const [host, port] = cfg.listen.split(':'); await new Promise((resolve, reject) => { server.once('error', reject); server.listen(Number(port), host, resolve); }); log(`pithos-hosted on ${cfg.listen} for ${cfg.publicUrl}`); return { server, supervisor: sup, async close() { clearInterval(meterTimer); for (const [id, u] of usage) writeJson(path.join(sup.dir(id), 'usage.json'), u); await new Promise((r) => server.close(r)); await sup.closeAll(); }, }; } // A public folder as a page: breadcrumbs, folders, files (links are relative // to the folder, so the page works under /p/ and under navigate.st/s3/), // pictures shown small, music and video playable in place. const PAGE_ESC = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const fmtSize = (n) => { const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; let i = 0; n = Number(n) || 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return `${i ? n.toFixed(1) : n} ${u[i]}`; }; export function folderPage({ owner, bucket, key, listing, token }) { const parts = key.split('/').filter(Boolean); const up = (n) => '../'.repeat(n) || './'; const crumbs = [`${PAGE_ESC(bucket)}`, ...parts.map((p, i) => i === parts.length - 1 ? `${PAGE_ESC(p)}` : `${PAGE_ESC(p)}`)].join(' / '); const enc = (name) => name.split('/').map(encodeURIComponent).join('/'); const kind = (n) => (/\.(png|jpe?g|gif|webp|avif|svg)$/i.test(n) ? 'img' : /\.(mp3|m4a|aac|ogg|oga|opus|flac|wav)$/i.test(n) ? 'audio' : /\.(mp4|webm|mov|m4v)$/i.test(n) ? 'video' : ''); const rows = [ ...listing.folders.map((f) => `
  • 📁 ${PAGE_ESC(f.replace(/\/$/, ''))}
  • `), ...listing.files.map((f) => { const k = kind(f.name); const media = k === 'img' ? `` : k ? `` : ''; return `
  • ${media}${PAGE_ESC(f.name)}${fmtSize(f.size)}
  • `; }), ].join(''); const more = listing.next ? `

    More…

    ` : ''; return ` ${PAGE_ESC(parts.at(-1) || bucket)} · ${PAGE_ESC(owner)}
    ${PAGE_ESC(owner)} · public folder

    ${crumbs}

    ${rows ? `
      ${rows}
    ` : '

    This folder is empty.

    '}${more}
    Shared with Pithos on Silent Mode. JSON
    `; } function json(res, status, body) { const data = JSON.stringify(body); res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); res.end(data); } function s3Error(res, status, code, message) { const esc = (s) => String(s).replace(/[<>&'"]/g, (c) => `&#${c.charCodeAt(0)};`); res.writeHead(status, { 'content-type': 'application/xml' }); res.end(`${esc(code)}${esc(message)}`); } function readAll(stream) { return new Promise((resolve, reject) => { const parts = []; stream.on('data', (c) => parts.push(c)); stream.on('end', () => resolve(Buffer.concat(parts).toString('utf8'))); stream.on('error', reject); }); } async function jsonBody(req) { let size = 0; const parts = []; for await (const c of req) { size += c.length; if (size > 1024 * 1024) throw httpError(413, 'request too large'); parts.push(c); } try { return JSON.parse(Buffer.concat(parts).toString('utf8') || '{}'); } catch { throw httpError(400, 'invalid JSON'); } } function done(res) { return new Promise((resolve) => { if (res.writableFinished) return resolve(); res.on('finish', resolve); res.on('close', resolve); }); } function fmtBytes(n) { const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return `${n.toFixed(n < 10 && i ? 1 : 0)} ${u[i]}`; }