// 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]*?)`, '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]*?)`)); 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)); } }); }