2026-10-03 19:03:05 +02:00
|
|
|
// 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) {
|
2026-10-03 22:12:45 +02:00
|
|
|
const out = encodeURIComponent(str)
|
|
|
|
|
.replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
|
|
|
|
|
// (A "never matches" regex like /$^/ is not one: it matches the empty
|
|
|
|
|
// string, which turned every empty query value into "/".)
|
|
|
|
|
return keepSlash ? out.replace(/%2F/g, '/') : out;
|
2026-10-03 19:03:05 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|