First run now lands on a step-by-step guide instead of the overview: create a Sia Storage account (link out, Pithos never sees the password), install s3d, choose or generate the recovery phrase, connect, start the gateway and create the first access key, with Back/Next on every step. The connect step opens the indexer's approval page on its own and moves on as soon as the approval lands; where a browser blocks the pop-up it says so and makes the button the primary action. Hosts without node-pty (the Theseus add-on) now fetch the one platform package they need from the npm registry on first login, pinned to its published sha512 integrity, so the approval link can be read there too. The console-window login stays only as the fallback when that fails. A retry after a failed connect with an existing phrase goes back to the phrase step: the phrase is not kept, and a blank one would make s3d generate a new one.
505 lines
22 KiB
JavaScript
505 lines
22 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(),
|
|
// Hosts without node-pty (the Theseus add-on) fetch it here on first login.
|
|
ptyDir: binDir ? path.join(binDir, '..', 'pty') : null,
|
|
});
|
|
const secret = crypto.randomBytes(24).toString('base64url');
|
|
const sessions = new Set();
|
|
// One-time download links: id -> { path, exp }. Lets a host hand a file
|
|
// to its own download manager, which carries no session cookie.
|
|
const tickets = new Map();
|
|
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'); }
|
|
let url = new URL(req.url, `http://${req.headers.host}`);
|
|
let viaTicket = false;
|
|
const dl = /^\/dl\/([A-Za-z0-9_-]{20,})$/.exec(url.pathname);
|
|
if (dl && req.method === 'GET') {
|
|
const t = tickets.get(dl[1]);
|
|
tickets.delete(dl[1]);
|
|
if (!t || t.exp < Date.now()) { res.writeHead(410); return res.end('link expired'); }
|
|
url = new URL(t.path, url);
|
|
viaTicket = true;
|
|
}
|
|
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 (!viaTicket && !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}`,
|
|
// For hosts that call the API themselves (the Theseus sidebar bridge):
|
|
// a session cookie that never touches a browser.
|
|
internalSession() {
|
|
const id = crypto.randomBytes(24).toString('base64url');
|
|
sessions.add(id);
|
|
return `pithos=${id}`;
|
|
},
|
|
// A single-use, 60-second URL for one object download.
|
|
downloadUrl(apiPath) {
|
|
if (!/^\/api\/s3\/[^/]+\/buckets\/[^/]+\/object\?/.test(apiPath)) throw new Error('only object downloads');
|
|
const id = crypto.randomBytes(24).toString('base64url');
|
|
tickets.set(id, { path: apiPath, exp: Date.now() + 60_000 });
|
|
for (const [k, v] of tickets) if (v.exp < Date.now()) tickets.delete(k);
|
|
return `${baseUrl}dl/${id}`;
|
|
},
|
|
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);
|
|
});
|
|
}
|