theseus/bundled-addons/pithos/core/s3.js
Local Dev 4ad95b3c7a Theseus: bundle the Pithos add-on
Ships Pithos (the s3d control panel) as a built-in extension: the dock
menu opens it in a tab served from a loopback port, and "Stop s3d"
shuts the gateway down. Generated by Pithos/scripts/build-theseus-addon.mjs.

yaml is vendored under vendor/yaml/lib rather than node_modules/ or
dist/, because Theseus git-ignores both and a clean-worktree release
build would otherwise ship the add-on without its only dependency.
2026-10-03 19:03:05 +02:00

221 lines
8.9 KiB
JavaScript

// A small S3 client for s3d: AWS SigV4 (path-style, us-east-1, UNSIGNED-PAYLOAD
// for bodies so uploads stream straight through) plus the handful of bucket
// and object calls the console needs.
import crypto from 'node:crypto';
import http from 'node:http';
import { Readable } from 'node:stream';
const REGION = 'us-east-1';
const SERVICE = 's3';
const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
const hmac = (k, s) => crypto.createHmac('sha256', k).update(s).digest();
// RFC 3986 encoding as SigV4 wants it; '/' kept for object keys in paths.
function uriEncode(str, keepSlash) {
return encodeURIComponent(str)
.replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())
.replace(keepSlash ? /%2F/g : /$^/, '/');
}
function canonicalQuery(query) {
return Object.keys(query).sort()
.map((k) => `${uriEncode(k)}=${uriEncode(String(query[k] ?? ''))}`)
.join('&');
}
function amzDate(d = new Date()) {
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
}
function signingKey(secret, date) {
return hmac(hmac(hmac(hmac('AWS4' + secret, date), REGION), SERVICE), 'aws4_request');
}
export function objectPath(bucket, key) {
return '/' + uriEncode(bucket) + (key != null ? '/' + uriEncode(key, true) : '');
}
export class S3Client {
// endpoint: 'http://127.0.0.1:8000'
constructor({ endpoint, accessKeyId, secretKey }) {
this.endpoint = new URL(endpoint);
this.accessKeyId = accessKeyId;
this.secretKey = secretKey;
}
sign(method, path, query, headers, { now = amzDate(), payloadHash = 'UNSIGNED-PAYLOAD' } = {}) {
const date = now.slice(0, 8);
const h = { ...headers, host: this.endpoint.host, 'x-amz-date': now, 'x-amz-content-sha256': payloadHash };
const names = Object.keys(h).map((k) => k.toLowerCase()).sort();
const lower = Object.fromEntries(Object.entries(h).map(([k, v]) => [k.toLowerCase(), String(v).trim()]));
const signed = names.join(';');
const canonical = [method, path, canonicalQuery(query), names.map((n) => `${n}:${lower[n]}\n`).join(''), signed, payloadHash].join('\n');
const scope = `${date}/${REGION}/${SERVICE}/aws4_request`;
const toSign = ['AWS4-HMAC-SHA256', now, scope, sha256(canonical)].join('\n');
const sig = crypto.createHmac('sha256', signingKey(this.secretKey, date)).update(toSign).digest('hex');
lower.authorization = `AWS4-HMAC-SHA256 Credential=${this.accessKeyId}/${scope}, SignedHeaders=${signed}, Signature=${sig}`;
delete lower.host;
return lower;
}
// Presigned GET for share links. publicEndpoint lets the link name the
// address readers will use, which may differ from the loopback the GUI uses.
presign(bucket, key, expiresSec = 3600, publicEndpoint, now = amzDate()) {
const base = publicEndpoint ? new URL(publicEndpoint) : this.endpoint;
const date = now.slice(0, 8);
const scope = `${date}/${REGION}/${SERVICE}/aws4_request`;
const path = objectPath(bucket, key);
const query = {
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
'X-Amz-Credential': `${this.accessKeyId}/${scope}`,
'X-Amz-Date': now,
'X-Amz-Expires': String(Math.min(Math.max(1, expiresSec | 0), 604800)),
'X-Amz-SignedHeaders': 'host',
};
const canonical = ['GET', path, canonicalQuery(query), `host:${base.host}\n`, 'host', 'UNSIGNED-PAYLOAD'].join('\n');
const toSign = ['AWS4-HMAC-SHA256', now, scope, sha256(canonical)].join('\n');
query['X-Amz-Signature'] = crypto.createHmac('sha256', signingKey(this.secretKey, date)).update(toSign).digest('hex');
return `${base.origin}${path}?${canonicalQuery(query)}`;
}
// Low-level request. body: Buffer | string | Readable | undefined.
// Resolves the raw http.IncomingMessage so callers can stream it.
request(method, path, { query = {}, headers = {}, body } = {}) {
const signedHeaders = this.sign(method, path, query, headers);
const qs = canonicalQuery(query);
return new Promise((resolve, reject) => {
const req = http.request({
protocol: this.endpoint.protocol,
hostname: this.endpoint.hostname,
port: this.endpoint.port,
method,
path: path + (qs ? '?' + qs : ''),
headers: signedHeaders,
}, resolve);
req.on('error', reject);
if (body instanceof Readable) body.pipe(req);
else req.end(body);
});
}
async call(method, path, opts) {
const res = await this.request(method, path, opts);
const text = await readBody(res);
if (res.statusCode >= 300) throw s3Error(res.statusCode, text);
return { status: res.statusCode, headers: res.headers, text };
}
async listBuckets() {
const { text } = await this.call('GET', '/');
return blocks(text, 'Bucket').map((b) => ({ name: tag(b, 'Name'), created: tag(b, 'CreationDate') }));
}
createBucket(bucket) { return this.call('PUT', objectPath(bucket)); }
deleteBucket(bucket) { return this.call('DELETE', objectPath(bucket)); }
async listObjects(bucket, { prefix = '', delimiter = '/', token, max = 1000 } = {}) {
const query = { 'list-type': '2', prefix, delimiter, 'max-keys': String(max) };
if (token) query['continuation-token'] = token;
const { text } = await this.call('GET', objectPath(bucket), { query });
return {
folders: blocks(text, 'CommonPrefixes').map((b) => tag(b, 'Prefix')),
objects: blocks(text, 'Contents').map((b) => ({
key: tag(b, 'Key'), size: Number(tag(b, 'Size')), modified: tag(b, 'LastModified'), etag: tag(b, 'ETag'),
})),
truncated: tag(text, 'IsTruncated') === 'true',
nextToken: tag(text, 'NextContinuationToken'),
};
}
async deleteObject(bucket, key) { return this.call('DELETE', objectPath(bucket, key)); }
// Deletes every key under prefix. Returns the number deleted.
async deletePrefix(bucket, prefix) {
let n = 0;
let token;
do {
const page = await this.listObjects(bucket, { prefix, delimiter: '', token });
for (const o of page.objects) { await this.deleteObject(bucket, o.key); n++; }
token = page.truncated ? page.nextToken : null;
} while (token);
return n;
}
putObject(bucket, key, body, { contentType, contentLength } = {}) {
const headers = {};
if (contentType) headers['content-type'] = contentType;
if (contentLength != null) headers['content-length'] = String(contentLength);
return this.call('PUT', objectPath(bucket, key), { headers, body });
}
getObject(bucket, key, { range } = {}) {
return this.request('GET', objectPath(bucket, key), { headers: range ? { range } : {} });
}
async getPolicy(bucket) {
const res = await this.request('GET', objectPath(bucket), { query: { policy: '' } });
const text = await readBody(res);
if (res.statusCode === 404) return null;
if (res.statusCode >= 300) throw s3Error(res.statusCode, text);
return JSON.parse(text);
}
putPolicy(bucket, policy) {
const body = JSON.stringify(policy);
return this.call('PUT', objectPath(bucket), { query: { policy: '' }, body, headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) } });
}
deletePolicy(bucket) { return this.call('DELETE', objectPath(bucket), { query: { policy: '' } }); }
}
// The only policy shape s3d accepts: anonymous read of objects and listing.
export function publicReadPolicy(bucket, { list = false } = {}) {
const statements = [{
Effect: 'Allow', Principal: '*', Action: ['s3:GetObject'], Resource: [`arn:aws:s3:::${bucket}/*`],
}];
if (list) statements.push({ Effect: 'Allow', Principal: '*', Action: ['s3:ListBucket'], Resource: [`arn:aws:s3:::${bucket}`] });
return { Version: '2012-10-17', Statement: statements };
}
export function readBody(res) {
return new Promise((resolve, reject) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
res.on('error', reject);
});
}
export class S3Error extends Error {
constructor(status, code, message) { super(message || code); this.status = status; this.code = code; }
}
function s3Error(status, text) {
return new S3Error(status, tag(text, 'Code') || `HTTP ${status}`, tag(text, 'Message') || text.slice(0, 200) || `HTTP ${status}`);
}
// S3 responses are flat enough that tag extraction is all we need.
function blocks(xml, name) {
const re = new RegExp(`<${name}>([\\s\\S]*?)</${name}>`, 'g');
const out = [];
let m;
while ((m = re.exec(xml))) out.push(m[1]);
return out;
}
function tag(xml, name) {
const m = xml.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`));
return m ? unescapeXml(m[1]) : null;
}
function unescapeXml(s) {
return s.replace(/&(lt|gt|amp|quot|apos|#\d+|#x[0-9a-f]+);/gi, (_, e) => {
switch (e.toLowerCase()) {
case 'lt': return '<'; case 'gt': return '>'; case 'amp': return '&';
case 'quot': return '"'; case 'apos': return "'";
default: return String.fromCodePoint(e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10));
}
});
}