475 lines
20 KiB
JavaScript
475 lines
20 KiB
JavaScript
|
|
// The Pithos control server: one JSON API + the static UI. Every host (web
|
||
|
|
// console, desktop window, Theseus tab) runs exactly this and points a view
|
||
|
|
// at it, so features are written once.
|
||
|
|
//
|
||
|
|
// Auth: a session secret. Local hosts pass it in the first URL (?t=...);
|
||
|
|
// the web console can instead accept a password. Either way it is exchanged
|
||
|
|
// for an HttpOnly SameSite=Strict cookie, and mutating requests also need the
|
||
|
|
// x-pithos header so a cross-site form can never drive the API.
|
||
|
|
|
||
|
|
import http from 'node:http';
|
||
|
|
import fs from 'node:fs';
|
||
|
|
import path from 'node:path';
|
||
|
|
import crypto from 'node:crypto';
|
||
|
|
import { fileURLToPath } from 'node:url';
|
||
|
|
|
||
|
|
import { resolveConfigPath, resolveBinary } from './paths.js';
|
||
|
|
import { readConfig, writeConfig, ensureConfig, EDITABLE } from './config.js';
|
||
|
|
import { Daemon } from './daemon.js';
|
||
|
|
import { makeCli, CliError } from './cli.js';
|
||
|
|
import { LoginSession } from './login.js';
|
||
|
|
import { S3Client, S3Error, publicReadPolicy, readBody } from './s3.js';
|
||
|
|
import * as admin from './admin.js';
|
||
|
|
import { installS3d, S3D_VERSION, assetForPlatform } from './binary.js';
|
||
|
|
|
||
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||
|
|
const UI_DIR = path.join(HERE, '..', 'ui');
|
||
|
|
const MIME = {
|
||
|
|
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
|
||
|
|
'.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.json': 'application/json',
|
||
|
|
'.woff2': 'font/woff2',
|
||
|
|
};
|
||
|
|
|
||
|
|
class HttpError extends Error {
|
||
|
|
constructor(status, message) { super(message); this.status = status; }
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function createPithos(opts = {}) {
|
||
|
|
const {
|
||
|
|
host = '127.0.0.1',
|
||
|
|
port = 0,
|
||
|
|
configFile: configOpt,
|
||
|
|
binary: binaryOpt,
|
||
|
|
binDir, // where installS3d puts the pinned release
|
||
|
|
password, // web console: optional login password
|
||
|
|
allowedHosts = [], // extra Host header values (reverse proxy names)
|
||
|
|
hostName = 'web', // 'web' | 'desktop' | 'theseus' — UI tweaks only
|
||
|
|
openExternal, // (url) => void, for hosts that can open the system browser
|
||
|
|
} = opts;
|
||
|
|
|
||
|
|
const configFile = resolveConfigPath(configOpt);
|
||
|
|
const daemon = new Daemon({ configFile, binary: resolveBinary(binaryOpt, binDir) });
|
||
|
|
const cli = makeCli(daemon);
|
||
|
|
const login = new LoginSession(daemon, { registration: () => cli.registration() });
|
||
|
|
const secret = crypto.randomBytes(24).toString('base64url');
|
||
|
|
const sessions = new Set();
|
||
|
|
const subscribers = new Set();
|
||
|
|
let versionCache = null;
|
||
|
|
|
||
|
|
const broadcast = (event, data) => {
|
||
|
|
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||
|
|
for (const res of subscribers) res.write(msg);
|
||
|
|
};
|
||
|
|
daemon.on('status', (s) => {
|
||
|
|
// A registration done outside Pithos (s3d login by hand) shows up on the next start.
|
||
|
|
if (s.state === 'starting' && !regCache?.registered) regCache = null;
|
||
|
|
broadcast('status', s);
|
||
|
|
});
|
||
|
|
daemon.on('log', (l) => broadcast('log', l));
|
||
|
|
login.on('state', (s) => {
|
||
|
|
if (s.state === 'done' || s.state === 'already') regCache = { registered: true, indexerUrl: s.indexerUrl || regCache?.indexerUrl };
|
||
|
|
broadcast('login', s);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Registration costs an s3d process run, so it is cached and only
|
||
|
|
// recomputed when something that could change it happens.
|
||
|
|
let regCache = null;
|
||
|
|
async function registration() {
|
||
|
|
if (!regCache && daemon.binary) regCache = await cli.registration().catch(() => null);
|
||
|
|
return regCache;
|
||
|
|
}
|
||
|
|
|
||
|
|
function cfg() { return readConfig(configFile); }
|
||
|
|
|
||
|
|
async function version() {
|
||
|
|
if (!daemon.binary) return null;
|
||
|
|
if (versionCache?.binary === daemon.binary) return versionCache.info;
|
||
|
|
try {
|
||
|
|
const info = await cli.version();
|
||
|
|
versionCache = { binary: daemon.binary, info };
|
||
|
|
return info;
|
||
|
|
} catch (e) {
|
||
|
|
return { version: null, error: e.message };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// An s3d we did not start (a service, or one run by hand) that answers on
|
||
|
|
// the configured admin address.
|
||
|
|
async function probeExternal(c) {
|
||
|
|
if (daemon.child || !c.adminPassword) return false;
|
||
|
|
try { await admin.uploadStats(c); return true; } catch { return false; }
|
||
|
|
}
|
||
|
|
|
||
|
|
async function s3For(user) {
|
||
|
|
const keys = await cli.listKeys(user);
|
||
|
|
if (!keys.length) throw new HttpError(409, `user "${user}" has no access keys yet - create one first`);
|
||
|
|
const c = cfg();
|
||
|
|
return new S3Client({ endpoint: `http://${c.apiAddress}`, accessKeyId: keys[0].accessKeyId, secretKey: keys[0].secretKey });
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- routing ---------------------------------------------------------
|
||
|
|
|
||
|
|
const routes = [];
|
||
|
|
const route = (method, pattern, handler) => {
|
||
|
|
const keys = [];
|
||
|
|
const re = new RegExp('^' + pattern.replace(/:(\w+)/g, (_, k) => { keys.push(k); return '([^/]+)'; }) + '$');
|
||
|
|
routes.push({ method, re, keys, handler });
|
||
|
|
};
|
||
|
|
|
||
|
|
route('GET', '/api/status', async () => {
|
||
|
|
const c = cfg();
|
||
|
|
const v = await version();
|
||
|
|
return {
|
||
|
|
host: hostName,
|
||
|
|
daemon: daemon.status(),
|
||
|
|
external: await probeExternal(c),
|
||
|
|
s3d: { ...v, pinned: S3D_VERSION, installable: !!assetForPlatform(), outdated: !!(v?.version && cmpVersion(v.version, S3D_VERSION) < 0) },
|
||
|
|
config: { file: c.file, exists: c.exists, apiAddress: c.apiAddress, adminAddress: c.adminAddress, directory: c.directory, https: c.data.apiHTTPSAddress || null },
|
||
|
|
login: login.snapshot(),
|
||
|
|
registration: await registration(),
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
route('POST', '/api/daemon/start', async () => {
|
||
|
|
ensureConfig(configFile);
|
||
|
|
return daemon.start();
|
||
|
|
});
|
||
|
|
route('POST', '/api/daemon/stop', () => daemon.stop());
|
||
|
|
route('POST', '/api/daemon/restart', async () => {
|
||
|
|
await daemon.stop();
|
||
|
|
ensureConfig(configFile);
|
||
|
|
return daemon.start();
|
||
|
|
});
|
||
|
|
route('GET', '/api/logs', (req, url) => daemon.logsSince(Number(url.searchParams.get('since') || 0)));
|
||
|
|
|
||
|
|
route('POST', '/api/binary/install', async () => {
|
||
|
|
if (daemon.child) throw new HttpError(409, 'stop s3d before replacing its binary');
|
||
|
|
if (!binDir) throw new HttpError(400, 'this host has no writable bin folder');
|
||
|
|
const target = await installS3d(binDir, (p) => broadcast('install', p));
|
||
|
|
daemon.binary = target;
|
||
|
|
versionCache = null;
|
||
|
|
regCache = null;
|
||
|
|
return { binary: target, version: await version() };
|
||
|
|
});
|
||
|
|
|
||
|
|
route('GET', '/api/config', () => {
|
||
|
|
const c = cfg();
|
||
|
|
const values = {};
|
||
|
|
for (const k of EDITABLE) values[k] = getIn(c.data, k.split('.')) ?? null;
|
||
|
|
return { file: c.file, exists: c.exists, values };
|
||
|
|
});
|
||
|
|
route('PUT', '/api/config', async (req) => {
|
||
|
|
const patch = await jsonBody(req);
|
||
|
|
const c = writeConfig(configFile, patch);
|
||
|
|
if ('directory' in patch) regCache = null;
|
||
|
|
return { file: c.file, restartNeeded: !!daemon.child };
|
||
|
|
});
|
||
|
|
|
||
|
|
// Login (indexer registration)
|
||
|
|
route('GET', '/api/login', () => login.snapshot());
|
||
|
|
route('POST', '/api/login', async (req) => {
|
||
|
|
const { indexerUrl, phrase } = await jsonBody(req);
|
||
|
|
if (phrase && phrase.trim().split(/\s+/).length !== 12) throw new HttpError(400, 'a recovery phrase is 12 words');
|
||
|
|
if (indexerUrl && !/^https?:\/\/[^\s]+$/.test(indexerUrl)) throw new HttpError(400, 'indexer URL must be http(s)');
|
||
|
|
ensureConfig(configFile);
|
||
|
|
return login.start({ indexerUrl: indexerUrl?.trim(), phrase: phrase?.trim().toLowerCase().split(/\s+/).join(' ') });
|
||
|
|
});
|
||
|
|
route('POST', '/api/login/confirm', () => { login.confirmGenerated(); return login.snapshot(); });
|
||
|
|
route('POST', '/api/login/cancel', () => { login.cancel(); return login.snapshot(); });
|
||
|
|
route('POST', '/api/open-external', async (req) => {
|
||
|
|
const { url } = await jsonBody(req);
|
||
|
|
if (!openExternal) throw new HttpError(400, 'not supported by this host');
|
||
|
|
if (!/^https:\/\//.test(url)) throw new HttpError(400, 'only https links');
|
||
|
|
openExternal(url);
|
||
|
|
return { ok: true };
|
||
|
|
});
|
||
|
|
|
||
|
|
// Users & keys
|
||
|
|
route('GET', '/api/users', async () => {
|
||
|
|
const [users, keys] = await Promise.all([cli.listUsers(), cli.listKeys()]);
|
||
|
|
return users.map((name) => ({ name, keys: keys.filter((k) => k.user === name).map((k) => k.accessKeyId) }));
|
||
|
|
});
|
||
|
|
route('POST', '/api/users', async (req) => {
|
||
|
|
const { name, withKey = true } = await jsonBody(req);
|
||
|
|
await cli.createUser(name);
|
||
|
|
const key = withKey ? await cli.createKey(name) : null;
|
||
|
|
return { name, key };
|
||
|
|
});
|
||
|
|
route('DELETE', '/api/users/:name', async (req, url, p) => { await cli.deleteUser(p.name); return { ok: true }; });
|
||
|
|
route('GET', '/api/keys', (req, url) => cli.listKeys(url.searchParams.get('user') || undefined));
|
||
|
|
route('POST', '/api/keys', async (req) => {
|
||
|
|
const { user, accessKey, secretKey } = await jsonBody(req);
|
||
|
|
return cli.createKey(user, { accessKey, secretKey });
|
||
|
|
});
|
||
|
|
route('DELETE', '/api/keys/:id', async (req, url, p) => { await cli.deleteKey(p.id); return { ok: true }; });
|
||
|
|
|
||
|
|
// Upload pipeline
|
||
|
|
route('GET', '/api/stats', () => admin.uploadStats(cfg()));
|
||
|
|
route('POST', '/api/flush', async () => { await admin.flush(cfg()); return { ok: true }; });
|
||
|
|
|
||
|
|
// Buckets & objects, acting as one s3d user.
|
||
|
|
route('GET', '/api/s3/:user/buckets', async (req, url, p) => (await s3For(p.user)).listBuckets());
|
||
|
|
route('POST', '/api/s3/:user/buckets', async (req, url, p) => {
|
||
|
|
const { name } = await jsonBody(req);
|
||
|
|
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(name || '')) throw new HttpError(400, 'bucket names are 3-63 chars: lowercase letters, digits, dots, hyphens');
|
||
|
|
await (await s3For(p.user)).createBucket(name);
|
||
|
|
return { name };
|
||
|
|
});
|
||
|
|
route('DELETE', '/api/s3/:user/buckets/:bucket', async (req, url, p) => {
|
||
|
|
await (await s3For(p.user)).deleteBucket(p.bucket);
|
||
|
|
return { ok: true };
|
||
|
|
});
|
||
|
|
route('GET', '/api/s3/:user/buckets/:bucket/objects', async (req, url, p) => {
|
||
|
|
const q = url.searchParams;
|
||
|
|
return (await s3For(p.user)).listObjects(p.bucket, { prefix: q.get('prefix') || '', token: q.get('token') || undefined });
|
||
|
|
});
|
||
|
|
route('PUT', '/api/s3/:user/buckets/:bucket/object', async (req, url, p) => {
|
||
|
|
const key = requireKey(url);
|
||
|
|
const len = req.headers['content-length'];
|
||
|
|
if (len == null) throw new HttpError(411, 'Content-Length required');
|
||
|
|
if (Number(len) > 5 * 1024 ** 3) throw new HttpError(413, 'single uploads are limited to 5 GiB');
|
||
|
|
await (await s3For(p.user)).putObject(p.bucket, key, req, { contentType: req.headers['content-type'], contentLength: len });
|
||
|
|
return { key };
|
||
|
|
});
|
||
|
|
route('GET', '/api/s3/:user/buckets/:bucket/object', async (req, url, p, res) => {
|
||
|
|
const key = requireKey(url);
|
||
|
|
const up = await (await s3For(p.user)).getObject(p.bucket, key, { range: req.headers.range });
|
||
|
|
if (up.statusCode >= 300 && up.statusCode !== 304) {
|
||
|
|
throw new HttpError(up.statusCode, (await readBody(up)).match(/<Message>(.*?)<\/Message>/)?.[1] || `HTTP ${up.statusCode}`);
|
||
|
|
}
|
||
|
|
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'] = `${url.searchParams.get('inline') ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(name)}`;
|
||
|
|
headers['x-content-type-options'] = 'nosniff';
|
||
|
|
// Objects are user content: never let them run script on this origin.
|
||
|
|
headers['content-security-policy'] = "sandbox; default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'";
|
||
|
|
res.writeHead(up.statusCode, headers);
|
||
|
|
up.pipe(res);
|
||
|
|
return STREAMED;
|
||
|
|
});
|
||
|
|
route('DELETE', '/api/s3/:user/buckets/:bucket/object', async (req, url, p) => {
|
||
|
|
await (await s3For(p.user)).deleteObject(p.bucket, requireKey(url));
|
||
|
|
return { ok: true };
|
||
|
|
});
|
||
|
|
route('DELETE', '/api/s3/:user/buckets/:bucket/prefix', async (req, url, p) => {
|
||
|
|
const prefix = url.searchParams.get('prefix');
|
||
|
|
if (!prefix) throw new HttpError(400, 'prefix required');
|
||
|
|
return { deleted: await (await s3For(p.user)).deletePrefix(p.bucket, prefix) };
|
||
|
|
});
|
||
|
|
route('GET', '/api/s3/:user/buckets/:bucket/presign', async (req, url, p) => {
|
||
|
|
const q = url.searchParams;
|
||
|
|
const client = await s3For(p.user);
|
||
|
|
return { url: client.presign(p.bucket, requireKey(url), Number(q.get('expires') || 3600), q.get('endpoint') || undefined) };
|
||
|
|
});
|
||
|
|
route('GET', '/api/s3/:user/buckets/:bucket/policy', async (req, url, p) => {
|
||
|
|
const policy = await (await s3For(p.user)).getPolicy(p.bucket);
|
||
|
|
return { policy, public: describePolicy(policy) };
|
||
|
|
});
|
||
|
|
route('PUT', '/api/s3/:user/buckets/:bucket/policy', async (req, url, p) => {
|
||
|
|
const { mode } = await jsonBody(req); // 'private' | 'read' | 'read-list'
|
||
|
|
const client = await s3For(p.user);
|
||
|
|
if (mode === 'private') { await client.deletePolicy(p.bucket).catch((e) => { if (e.status !== 404) throw e; }); }
|
||
|
|
else if (mode === 'read' || mode === 'read-list') await client.putPolicy(p.bucket, publicReadPolicy(p.bucket, { list: mode === 'read-list' }));
|
||
|
|
else throw new HttpError(400, 'mode must be private, read or read-list');
|
||
|
|
return { ok: true };
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---- server ------------------------------------------------------------
|
||
|
|
|
||
|
|
const STREAMED = Symbol('streamed');
|
||
|
|
let boundPort = port;
|
||
|
|
|
||
|
|
function hostAllowed(h) {
|
||
|
|
if (!h) return false;
|
||
|
|
const ok = [`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`, ...allowedHosts];
|
||
|
|
return ok.includes(h.toLowerCase());
|
||
|
|
}
|
||
|
|
|
||
|
|
function cookieSession(req) {
|
||
|
|
const m = /(?:^|;\s*)pithos=([^;]+)/.exec(req.headers.cookie || '');
|
||
|
|
return m && sessions.has(m[1]) ? m[1] : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function issueSession(res) {
|
||
|
|
const id = crypto.randomBytes(24).toString('base64url');
|
||
|
|
sessions.add(id);
|
||
|
|
res.setHeader('set-cookie', `pithos=${id}; HttpOnly; SameSite=Strict; Path=/`);
|
||
|
|
return id;
|
||
|
|
}
|
||
|
|
|
||
|
|
const server = http.createServer(async (req, res) => {
|
||
|
|
// DNS-rebinding guard: a page on evil.example resolving to 127.0.0.1 still
|
||
|
|
// sends its own Host header.
|
||
|
|
if (!hostAllowed(req.headers.host)) { res.writeHead(421); return res.end('misdirected request'); }
|
||
|
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
||
|
|
res.setHeader('x-frame-options', 'DENY');
|
||
|
|
res.setHeader('referrer-policy', 'no-referrer');
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Session bootstrap: ?t=<secret> on any page swaps the secret for a cookie.
|
||
|
|
const t = url.searchParams.get('t');
|
||
|
|
if (t && safeEqual(t, secret) && req.method === 'GET' && !url.pathname.startsWith('/api/')) {
|
||
|
|
issueSession(res);
|
||
|
|
res.writeHead(302, { location: url.pathname });
|
||
|
|
return res.end();
|
||
|
|
}
|
||
|
|
|
||
|
|
if (url.pathname === '/api/session') {
|
||
|
|
if (req.method === 'GET') return sendJson(res, 200, { authenticated: !!cookieSession(req), passwordLogin: !!password, host: hostName });
|
||
|
|
if (req.method === 'POST') {
|
||
|
|
requireCsrf(req);
|
||
|
|
const body = await jsonBody(req);
|
||
|
|
if (!password || !safeEqual(String(body.password || ''), password)) {
|
||
|
|
await new Promise((r) => setTimeout(r, 750));
|
||
|
|
throw new HttpError(401, 'wrong password');
|
||
|
|
}
|
||
|
|
issueSession(res);
|
||
|
|
return sendJson(res, 200, { authenticated: true });
|
||
|
|
}
|
||
|
|
if (req.method === 'DELETE') {
|
||
|
|
const s = cookieSession(req);
|
||
|
|
if (s) sessions.delete(s);
|
||
|
|
res.setHeader('set-cookie', 'pithos=; Max-Age=0; Path=/');
|
||
|
|
return sendJson(res, 200, { authenticated: false });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (url.pathname.startsWith('/api/')) {
|
||
|
|
if (!cookieSession(req)) throw new HttpError(401, 'not signed in');
|
||
|
|
if (req.method !== 'GET') requireCsrf(req);
|
||
|
|
|
||
|
|
if (url.pathname === '/api/events') {
|
||
|
|
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
|
||
|
|
res.write(`event: status\ndata: ${JSON.stringify(daemon.status())}\n\n`);
|
||
|
|
res.write(`event: login\ndata: ${JSON.stringify(login.snapshot())}\n\n`);
|
||
|
|
subscribers.add(res);
|
||
|
|
const ping = setInterval(() => res.write(': ping\n\n'), 25_000);
|
||
|
|
req.on('close', () => { clearInterval(ping); subscribers.delete(res); });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const r of routes) {
|
||
|
|
if (r.method !== req.method) continue;
|
||
|
|
const m = r.re.exec(url.pathname);
|
||
|
|
if (!m) continue;
|
||
|
|
const params = Object.fromEntries(r.keys.map((k, i) => [k, decodeURIComponent(m[i + 1])]));
|
||
|
|
const out = await r.handler(req, url, params, res);
|
||
|
|
if (out === STREAMED) return;
|
||
|
|
return sendJson(res, 200, out ?? { ok: true });
|
||
|
|
}
|
||
|
|
throw new HttpError(404, 'no such endpoint');
|
||
|
|
}
|
||
|
|
|
||
|
|
return serveStatic(req, res, url);
|
||
|
|
} catch (err) {
|
||
|
|
const status = err instanceof HttpError ? err.status
|
||
|
|
: err instanceof S3Error ? (err.status >= 400 && err.status < 600 ? err.status : 502)
|
||
|
|
: err instanceof CliError ? 400
|
||
|
|
: 500;
|
||
|
|
if (status === 500) console.error('[pithos]', err);
|
||
|
|
if (res.headersSent) return res.destroy();
|
||
|
|
sendJson(res, status, { error: err.message, code: err.code });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
await new Promise((resolve, reject) => {
|
||
|
|
server.once('error', reject);
|
||
|
|
server.listen(port, host, resolve);
|
||
|
|
});
|
||
|
|
boundPort = server.address().port;
|
||
|
|
const displayHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
|
||
|
|
const baseUrl = `http://${displayHost.includes(':') ? `[${displayHost}]` : displayHost}:${boundPort}/`;
|
||
|
|
|
||
|
|
return {
|
||
|
|
server,
|
||
|
|
daemon,
|
||
|
|
url: baseUrl,
|
||
|
|
// Opening this URL signs the view in.
|
||
|
|
authUrl: `${baseUrl}?t=${secret}`,
|
||
|
|
async close() {
|
||
|
|
login.cancel();
|
||
|
|
for (const s of subscribers) s.end();
|
||
|
|
await daemon.stop();
|
||
|
|
await new Promise((r) => server.close(r));
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- helpers -------------------------------------------------------------
|
||
|
|
|
||
|
|
function sendJson(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 requireCsrf(req) {
|
||
|
|
if (req.headers['x-pithos'] !== '1') throw new HttpError(403, 'missing x-pithos header');
|
||
|
|
}
|
||
|
|
|
||
|
|
function requireKey(url) {
|
||
|
|
const key = url.searchParams.get('key');
|
||
|
|
if (!key) throw new HttpError(400, 'key required');
|
||
|
|
return key;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function jsonBody(req) {
|
||
|
|
const chunks = [];
|
||
|
|
let size = 0;
|
||
|
|
for await (const c of req) {
|
||
|
|
size += c.length;
|
||
|
|
if (size > 1 << 20) throw new HttpError(413, 'body too large');
|
||
|
|
chunks.push(c);
|
||
|
|
}
|
||
|
|
if (!chunks.length) return {};
|
||
|
|
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw new HttpError(400, 'invalid JSON'); }
|
||
|
|
}
|
||
|
|
|
||
|
|
function safeEqual(a, b) {
|
||
|
|
const x = Buffer.from(String(a));
|
||
|
|
const y = Buffer.from(String(b));
|
||
|
|
return x.length === y.length && crypto.timingSafeEqual(x, y);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getIn(obj, keys) {
|
||
|
|
return keys.reduce((o, k) => (o == null ? undefined : o[k]), obj);
|
||
|
|
}
|
||
|
|
|
||
|
|
function cmpVersion(a, b) {
|
||
|
|
const pa = a.replace(/^v/, '').split(/[.-]/).map((n) => parseInt(n, 10) || 0);
|
||
|
|
const pb = b.replace(/^v/, '').split(/[.-]/).map((n) => parseInt(n, 10) || 0);
|
||
|
|
for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
function describePolicy(policy) {
|
||
|
|
if (!policy) return 'private';
|
||
|
|
const actions = new Set();
|
||
|
|
for (const s of [].concat(policy.Statement || [])) {
|
||
|
|
if (s.Effect !== 'Allow') continue;
|
||
|
|
if (s.Principal !== '*' && s.Principal?.AWS !== '*') continue;
|
||
|
|
for (const a of [].concat(s.Action || [])) actions.add(a);
|
||
|
|
}
|
||
|
|
if (actions.has('s3:ListBucket')) return 'read-list';
|
||
|
|
if (actions.has('s3:GetObject')) return 'read';
|
||
|
|
return 'custom';
|
||
|
|
}
|
||
|
|
|
||
|
|
function serveStatic(req, res, url) {
|
||
|
|
if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405); return res.end(); }
|
||
|
|
let rel = decodeURIComponent(url.pathname);
|
||
|
|
if (rel === '/' || !path.extname(rel)) rel = '/index.html';
|
||
|
|
const file = path.join(UI_DIR, path.normalize(rel));
|
||
|
|
if (!file.startsWith(UI_DIR + path.sep)) { res.writeHead(403); return res.end(); }
|
||
|
|
fs.readFile(file, (err, data) => {
|
||
|
|
if (err) { res.writeHead(404); return res.end('not found'); }
|
||
|
|
res.writeHead(200, {
|
||
|
|
'content-type': MIME[path.extname(file)] || 'application/octet-stream',
|
||
|
|
'cache-control': 'no-cache',
|
||
|
|
'content-security-policy': "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'",
|
||
|
|
});
|
||
|
|
res.end(req.method === 'HEAD' ? undefined : data);
|
||
|
|
});
|
||
|
|
}
|