dotrino-content 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/public.js ADDED
@@ -0,0 +1,370 @@
1
+ /**
2
+ * public.js — MODO PÚBLICO del node (DISENO.md §7.2 y §7.3). APAGADO por defecto.
3
+ *
4
+ * Es la única puerta por la que los bytes de este node salen a internet sin pasar
5
+ * por una app del ecosistema, y existe para UNA cosa: que un enlace compartido
6
+ * tenga **vista previa** (la tarjeta de X, LinkedIn, WhatsApp, Telegram…). El
7
+ * contenido de verdad sigue viajando por el camino de siempre — app + `#fragment`
8
+ * + transporte P2P—, donde el servidor nunca ve la referencia.
9
+ *
10
+ * Cuatro cerrojos, y ninguno es decorativo:
11
+ *
12
+ * 1. **ACL:** solo sale lo marcado `public` y EN CLARO. Lo cifrado no sale ni
13
+ * aunque alguien le ponga `public` a mano en el índice (`node.publicStat`).
14
+ * 1b. **SOLO IMÁGENES, y de mapa de bits.** Es lo único que una vista previa
15
+ * necesita, y de paso cierra de golpe todo lo demás: nada de HTML, PDF,
16
+ * vídeo ni archivos comprimidos saliendo de tu máquina. **El SVG queda
17
+ * fuera a propósito**: es un documento que ejecuta scripts, así que servirlo
18
+ * desde tu dominio es regalarle un origen a quien lo suba. Y el tipo NO se
19
+ * cree: el `mime` lo declara quien sube, así que se comprueban los BYTES
20
+ * MÁGICOS del archivo antes de mandarlo (`sniffImage`).
21
+ * 2. **TOPE DE TAMAÑO (`maxBytes`, 512 KB por defecto):** es lo que convierte
22
+ * esto en «un servidor de miniaturas» en vez de «un CDN gratis». Una
23
+ * miniatura pesa decenas de KB; un original, megas. Con el tope puesto, el
24
+ * hotlinking —que es el modo natural en que esta puerta te cuesta dinero—
25
+ * deja de importar. `maxBytes: 0` lo quita, y es una decisión del dueño.
26
+ * 3. **Límite por IP:** cubeta por minuto, para que un bucle ajeno no te use de
27
+ * origen.
28
+ * 4. **TECHO DE EGRESS DIARIO, persistido:** al pasarse, corta con 503. Se
29
+ * guarda en el índice y no en memoria a propósito: un techo que se reinicia
30
+ * con el proceso no es un techo.
31
+ *
32
+ * Rutas (y no hay más):
33
+ * GET|HEAD /c/<cid> los bytes, si pasan los cuatro cerrojos (Range/206, ETag)
34
+ * GET /p/<cid> permalink: HTML con las etiquetas OG de la tarjeta
35
+ * GET /robots.txt Disallow: / (ver abajo)
36
+ * GET /health vivo + uso del techo, sin decir qué guarda
37
+ *
38
+ * `robots.txt` prohíbe TODO a propósito. Las tarjetas sociales funcionan igual
39
+ * (los rastreadores de redes piden la página cuando alguien pega el enlace, no
40
+ * indexan), y la norma del ecosistema es que el contenido del usuario no se
41
+ * indexa (CLAUDE.md §SEO). Se puede levantar con `index: true`, que es lo que
42
+ * haría la cuenta oficial para su contenido público, y de nadie más.
43
+ */
44
+ import http from 'node:http'
45
+ import { open as openFile } from 'node:fs/promises'
46
+ import { pipeline } from 'node:stream/promises'
47
+ import { isValidCid } from './node.js'
48
+
49
+ export const DEFAULT_PUBLIC_PORT = 3778
50
+ /** Tope por blob: lo que hace que esto sirva previsualizaciones y no originales. */
51
+ export const DEFAULT_MAX_BYTES = 512 * 1024
52
+ /** Cubeta por IP y minuto. */
53
+ export const DEFAULT_RATE_PER_MIN = 60
54
+
55
+ /**
56
+ * Lo único que sale por el puerto público: imágenes de mapa de bits. Cada entrada
57
+ * lleva su firma en los primeros bytes, porque el `mime` del índice es lo que
58
+ * DIJO quien subió, y aquí no se cree nada que no se pueda comprobar.
59
+ */
60
+ const IMAGE_TYPES = [
61
+ { mime: 'image/jpeg', test: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
62
+ { mime: 'image/png', test: (b) => b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 },
63
+ { mime: 'image/gif', test: (b) => b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38 },
64
+ // RIFF....WEBP
65
+ { mime: 'image/webp', test: (b) => b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP' },
66
+ // ....ftypavif (caja ISO-BMFF)
67
+ { mime: 'image/avif', test: (b) => b.subarray(4, 8).toString('latin1') === 'ftyp' && b.subarray(8, 12).toString('latin1').startsWith('avif') }
68
+ ]
69
+
70
+ export const PUBLIC_MIMES = Object.freeze(IMAGE_TYPES.map((t) => t.mime))
71
+
72
+ /**
73
+ * El tipo REAL a partir de los primeros bytes, o `null` si no es una imagen de las
74
+ * admitidas. Es lo que se sirve como `content-type`: si alguien subió un HTML diciendo
75
+ * que era un PNG, aquí no pasa.
76
+ * @param {Buffer|Uint8Array|null} buf
77
+ * @returns {string|null}
78
+ */
79
+ export function sniffBytes (buf) {
80
+ if (!buf || buf.length < 12) return null
81
+ return IMAGE_TYPES.find((t) => t.test(buf))?.mime || null
82
+ }
83
+
84
+ /**
85
+ * El tipo real de un blob, pidiéndole al ALMACÉN sus primeros bytes.
86
+ *
87
+ * Antes abría el archivo por su ruta (`node.store.pathFor`), y eso ataba este cerrojo
88
+ * al disco: con un bucket detrás (§15) no hay ninguna ruta que abrir y el puerto
89
+ * público se habría quedado sin poder comprobar nada — o, peor, comprobándolo solo
90
+ * cuando el blob estuviera en la caché.
91
+ * @param {{ readHead: (cid: string, n: number, opts?: any) => Promise<Buffer|Uint8Array> }} store
92
+ * @param {string} cid
93
+ * @param {any} [opts]
94
+ * @returns {Promise<string|null>}
95
+ */
96
+ export async function sniffImage (store, cid, opts) {
97
+ try {
98
+ return sniffBytes(await store.readHead(cid, 16, opts))
99
+ } catch {
100
+ return null
101
+ }
102
+ }
103
+
104
+ /** Día UTC (clave del techo de egress). */
105
+ export const utcDay = (now = Date.now()) => new Date(now).toISOString().slice(0, 10)
106
+
107
+ const esc = (s) => String(s ?? '')
108
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
109
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;')
110
+
111
+ const text = (res, code, body, extra = {}) =>
112
+ res.writeHead(code, { 'content-type': 'text/plain; charset=utf-8', ...extra }).end(body)
113
+
114
+ const json = (res, code, obj) =>
115
+ res.writeHead(code, { 'content-type': 'application/json' }).end(JSON.stringify(obj))
116
+
117
+ /** Cubeta por IP: N peticiones por minuto, ventana deslizante gruesa (por minuto). */
118
+ export class RateLimiter {
119
+ constructor (perMinute = DEFAULT_RATE_PER_MIN) {
120
+ this.perMinute = perMinute
121
+ this.window = 0
122
+ this.hits = new Map()
123
+ }
124
+
125
+ /** @returns {boolean} true si la petición pasa. */
126
+ allow (ip, now = Date.now()) {
127
+ if (!this.perMinute) return true
128
+ const w = Math.floor(now / 60_000)
129
+ if (w !== this.window) { this.window = w; this.hits.clear() }
130
+ const n = (this.hits.get(ip) || 0) + 1
131
+ this.hits.set(ip, n)
132
+ return n <= this.perMinute
133
+ }
134
+ }
135
+
136
+ /** IP del cliente, respetando un proxy inverso delante (nginx/Caddy/Cloudflare). */
137
+ export function clientIp (req) {
138
+ const fwd = req.headers['x-forwarded-for']
139
+ if (typeof fwd === 'string' && fwd) return fwd.split(',')[0].trim()
140
+ return req.socket?.remoteAddress || '?'
141
+ }
142
+
143
+ /** Base pública absoluta (para las URLs de las etiquetas OG). */
144
+ function baseUrl (req, configured) {
145
+ if (configured) return configured.replace(/\/+$/, '')
146
+ const proto = (req.headers['x-forwarded-proto'] || 'http').toString().split(',')[0].trim()
147
+ const host = req.headers.host || 'localhost'
148
+ return `${proto}://${host}`
149
+ }
150
+
151
+ const parseMeta = (row) => {
152
+ if (!row?.meta) return {}
153
+ try { return JSON.parse(row.meta) || {} } catch { return {} }
154
+ }
155
+
156
+ /**
157
+ * Página del permalink (§7.3). Es una tarjeta y un enlace, y se dice así: no
158
+ * pretende ser un visor. Lleva la imagen solo si el propio blob es una imagen o
159
+ * si tiene una miniatura pública enlazada (`thumbnailCid`).
160
+ */
161
+ export function previewHtml ({ cid, row, base, appUrl, owner, index, imageCid = null }) {
162
+ const m = parseMeta(row)
163
+ const title = m.title || m.name || 'Contenido compartido'
164
+ // La imagen de la tarjeta la decide la ruta, que es quien pudo COMPROBAR que
165
+ // ese cid es una imagen de verdad y que sale por el puerto. Aquí solo se pinta.
166
+ const image = imageCid ? `${base}/c/${imageCid}` : null
167
+ const kb = Math.max(1, Math.round(row.size / 1024))
168
+ const desc = m.description || `${row.mime} · ${kb} KB`
169
+ // El enlace de "abrir" lleva la referencia en el #fragment: el servidor de la
170
+ // app nunca la ve (CLAUDE.md §SEO). Sin `owner` no hay referencia que armar.
171
+ const open = owner ? `${appUrl.replace(/\/+$/, '')}/#${owner}/${cid}` : null
172
+ return `<!doctype html>
173
+ <html lang="es">
174
+ <head>
175
+ <meta charset="utf-8">
176
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
177
+ <title>${esc(title)}</title>
178
+ <meta name="robots" content="${index ? 'index, follow' : 'noindex, nofollow'}">
179
+ <meta name="description" content="${esc(desc)}">
180
+ <link rel="canonical" href="${esc(base)}/p/${cid}">
181
+ <meta property="og:type" content="website">
182
+ <meta property="og:site_name" content="Dotrino">
183
+ <meta property="og:title" content="${esc(title)}">
184
+ <meta property="og:description" content="${esc(desc)}">
185
+ <meta property="og:url" content="${esc(base)}/p/${cid}">
186
+ ${image ? `<meta property="og:image" content="${esc(image)}">` : ''}
187
+ <meta name="twitter:card" content="${image ? 'summary_large_image' : 'summary'}">
188
+ <meta name="twitter:title" content="${esc(title)}">
189
+ <meta name="twitter:description" content="${esc(desc)}">
190
+ ${image ? `<meta name="twitter:image" content="${esc(image)}">` : ''}
191
+ <style>
192
+ :root { color-scheme: dark; }
193
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center;
194
+ background: #0e1116; color: #e8ecf1; font: 16px/1.5 system-ui, sans-serif;
195
+ padding: max(1rem, env(safe-area-inset-top)) 1rem; }
196
+ main { max-width: 34rem; text-align: center; }
197
+ img { max-width: 100%; height: auto; border-radius: .75rem; }
198
+ h1 { font-size: 1.25rem; margin: 1rem 0 .25rem; }
199
+ p { color: #9aa7b4; margin: .25rem 0 1.25rem; }
200
+ a.open { display: inline-block; background: #2f6bff; color: #fff; text-decoration: none;
201
+ padding: .7rem 1.4rem; border-radius: .6rem; font-weight: 600; }
202
+ small { display: block; margin-top: 1.5rem; color: #6b7787; }
203
+ small a { color: #6b7787; }
204
+ </style>
205
+ </head>
206
+ <body>
207
+ <main>
208
+ ${image ? `<img src="${esc(image)}" alt="${esc(title)}">` : ''}
209
+ <h1>${esc(title)}</h1>
210
+ <p>${esc(desc)}</p>
211
+ ${open ? `<a class="open" href="${esc(open)}">Abrir</a>` : ''}
212
+ <small>Servido desde el node de su dueño · <a href="https://content.dotrino.com/">Dotrino</a></small>
213
+ </main>
214
+ </body>
215
+ </html>`
216
+ }
217
+
218
+ /**
219
+ * Servidor del modo público.
220
+ *
221
+ * @param {import('./node.js').ContentNode} node
222
+ * @param {{
223
+ * maxBytes?: number, ratePerMin?: number, maxEgressBytes?: number,
224
+ * publicUrl?: string|null, appUrl?: string, index?: boolean, owner?: string|null,
225
+ * quiet?: boolean
226
+ * }} [opts]
227
+ */
228
+ export function createPublicServer (node, opts = {}) {
229
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES
230
+ const maxEgressBytes = opts.maxEgressBytes || 0
231
+ const appUrl = opts.appUrl || 'https://eco.dotrino.com/'
232
+ const limiter = new RateLimiter(opts.ratePerMin ?? DEFAULT_RATE_PER_MIN)
233
+
234
+ /** ¿Queda techo de egress para hoy? */
235
+ const egressLeft = () => {
236
+ if (!maxEgressBytes) return Infinity
237
+ return maxEgressBytes - node.index.egressOn(utcDay())
238
+ }
239
+
240
+ const server = http.createServer(async (req, res) => {
241
+ try {
242
+ if (!limiter.allow(clientIp(req))) return text(res, 429, 'demasiadas peticiones\n', { 'retry-after': '60' })
243
+ await route(req, res)
244
+ } catch (err) {
245
+ if (res.headersSent) { res.destroy(); return }
246
+ text(res, 500, 'error\n')
247
+ if (!opts.quiet) console.error('[content:public]', err.message)
248
+ }
249
+ })
250
+
251
+ async function route (req, res) {
252
+ if (req.method !== 'GET' && req.method !== 'HEAD') return text(res, 405, 'método no permitido\n')
253
+ const url = new URL(req.url, 'http://localhost')
254
+ const [, top, cid] = url.pathname.split('/')
255
+
256
+ if (url.pathname === '/robots.txt') {
257
+ return text(res, 200, opts.index ? 'User-agent: *\nAllow: /p/\nDisallow: /c/\n' : 'User-agent: *\nDisallow: /\n')
258
+ }
259
+ if (url.pathname === '/health') {
260
+ const used = maxEgressBytes ? node.index.egressOn(utcDay()) : 0
261
+ return json(res, 200, { ok: true, egressToday: used, maxEgressBytes: maxEgressBytes || null })
262
+ }
263
+
264
+ if ((top === 'c' || top === 'p') && cid) {
265
+ if (!isValidCid(cid)) return text(res, 400, 'cid inválido\n')
266
+ // 404 y no 403 para lo privado: un 403 confirmaría que ese cid existe aquí.
267
+ const row = node.publicStat(cid)
268
+ if (!row) return text(res, 404, 'no disponible\n')
269
+
270
+ if (top === 'p') {
271
+ const body = previewHtml({
272
+ cid, row, imageCid: await pickImage(cid, row),
273
+ base: baseUrl(req, opts.publicUrl), appUrl, owner: opts.owner ?? node.owner, index: !!opts.index
274
+ })
275
+ res.writeHead(200, {
276
+ 'content-type': 'text/html; charset=utf-8',
277
+ 'cache-control': 'public, max-age=3600',
278
+ 'x-robots-tag': opts.index ? 'all' : 'noindex, nofollow'
279
+ })
280
+ return res.end(req.method === 'HEAD' ? undefined : body)
281
+ }
282
+
283
+ // --- bytes ---
284
+ // El tipo REAL, sacado del archivo. `row.mime` no decide nada aquí.
285
+ const kind = await sniffImage(node.store, cid, { public: true })
286
+ if (!kind) return text(res, 404, 'no disponible\n')
287
+ if (maxBytes && row.size > maxBytes) {
288
+ // No es un error del que pide: es la política del node. Se dice cuál es.
289
+ return text(res, 413, 'este node solo publica vistas previas; el contenido se abre en la app\n')
290
+ }
291
+ const headers = {
292
+ 'content-type': kind,
293
+ etag: `"${cid}"`,
294
+ 'cache-control': 'public, max-age=31536000, immutable',
295
+ 'accept-ranges': 'bytes',
296
+ 'x-content-type-options': 'nosniff',
297
+ 'x-robots-tag': 'noindex',
298
+ // El blob es de otro dominio y lo pide una app del ecosistema: sin CORS
299
+ // el <img>/fetch de la app no lo puede leer.
300
+ 'access-control-allow-origin': '*'
301
+ }
302
+ if (req.headers['if-none-match'] === `"${cid}"`) return res.writeHead(304, headers).end()
303
+
304
+ const range = parseRangeHeader(req.headers.range, row.size)
305
+ if (range === false) return res.writeHead(416, { 'content-range': `bytes */${row.size}` }).end()
306
+ const sending = range ? range.end - range.start + 1 : row.size
307
+ // El techo se mira contra lo que ESTA respuesta va a mandar, no contra cero:
308
+ // dejar entrar una petición porque "aún quedan 10 bytes" es rebasarlo igual,
309
+ // solo que fingiendo que no. Un HEAD no manda cuerpo, así que no gasta.
310
+ if (req.method === 'GET' && egressLeft() < sending) {
311
+ return text(res, 503, 'techo de salida diario alcanzado\n', { 'retry-after': '3600' })
312
+ }
313
+ if (range) {
314
+ headers['content-range'] = `bytes ${range.start}-${range.end}/${row.size}`
315
+ headers['content-length'] = sending
316
+ res.writeHead(206, headers)
317
+ } else {
318
+ headers['content-length'] = sending
319
+ res.writeHead(200, headers)
320
+ }
321
+ if (req.method === 'HEAD') return res.end()
322
+ // Se contabiliza lo que de verdad sale por el socket, no lo que se prometió:
323
+ // una descarga abortada a la mitad no debe gastar el techo entero.
324
+ let sent = 0
325
+ res.on('close', () => { if (maxEgressBytes && sent) node.index.addEgress(sent, utcDay()) })
326
+ const src = node.read(cid, range ?? undefined)
327
+ src.on('data', (c) => { sent += c.length })
328
+ await pipeline(src, res)
329
+ return
330
+ }
331
+
332
+ return text(res, 404, 'no disponible\n')
333
+ }
334
+
335
+ /**
336
+ * Qué imagen lleva la tarjeta: el propio blob si es una imagen servible, y si
337
+ * no su miniatura (`thumbnailCid`), que tiene que ser pública por su cuenta —
338
+ * enlazar una miniatura no la vuelve pública, la publica su dueño.
339
+ * @returns {Promise<string|null>}
340
+ */
341
+ async function pickImage (cid, row) {
342
+ for (const candidate of [{ cid, row }, row.thumbnailCid ? { cid: row.thumbnailCid, row: node.publicStat(row.thumbnailCid) } : null]) {
343
+ if (!candidate?.row) continue
344
+ if (maxBytes && candidate.row.size > maxBytes) continue
345
+ if (await sniffImage(node.store, candidate.cid, { public: true })) return candidate.cid
346
+ }
347
+ return null
348
+ }
349
+
350
+ return server
351
+ }
352
+
353
+ /** Igual que el del servidor local; duplicado mínimo para no acoplar los dos. */
354
+ function parseRangeHeader (header, size) {
355
+ if (!header) return null
356
+ const m = /^bytes=(\d*)-(\d*)$/.exec(String(header).trim())
357
+ if (!m || (m[1] === '' && m[2] === '')) return false
358
+ let start, end
359
+ if (m[1] === '') {
360
+ const n = Number(m[2])
361
+ if (n === 0) return false
362
+ start = Math.max(0, size - n); end = size - 1
363
+ } else {
364
+ start = Number(m[1]); end = m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1)
365
+ }
366
+ if (start >= size || start > end) return false
367
+ return { start, end }
368
+ }
369
+
370
+ export default { createPublicServer, RateLimiter, previewHtml, utcDay, DEFAULT_PUBLIC_PORT, DEFAULT_MAX_BYTES }
package/src/s3.js ADDED
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Cliente S3 mínimo, sin SDK (DISENO.md §15.10).
3
+ *
4
+ * Habla el protocolo, no un proveedor: **R2, Backblaze, Hetzner, Storj y el propio
5
+ * S3 de Amazon son el mismo código** con otro `endpoint`. Por eso no hay una
6
+ * "integración de Cloudflare" y otra "de S3": es una sola.
7
+ *
8
+ * Sin dependencias, como toda la Fase 1: `node:crypto` para firmar y `fetch` para
9
+ * hablar. El `.npmrc` del ecosistema bloquea los scripts de instalación, y el SDK
10
+ * oficial son decenas de paquetes para hacer un `PUT`.
11
+ *
12
+ * **El detalle que hace esto barato:** S3 exige mandar el SHA-256 del cuerpo en
13
+ * `x-amz-content-sha256`… y aquí el `cid` YA ES ese hash (`sha256-<hex>`), porque los
14
+ * bytes que se suben son exactamente los que el `cid` direcciona. Así que se firma sin
15
+ * leer el cuerpo dos veces y sin tenerlo entero en memoria: se sube en streaming.
16
+ */
17
+ import { createHmac, createHash } from 'node:crypto'
18
+
19
+ const ALGO = 'AWS4-HMAC-SHA256'
20
+ const EMPTY_SHA256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
21
+
22
+ const sha256hex = (data) => createHash('sha256').update(data).digest('hex')
23
+ const hmac = (key, data) => createHmac('sha256', key).update(data, 'utf8').digest()
24
+
25
+ /**
26
+ * Codificación de URI de AWS: es la de RFC 3986, que NO es la de `encodeURIComponent`
27
+ * (deja pasar `!'()*`). Una sola diferencia hace que la firma no cuadre y el error que
28
+ * devuelve el servidor no dice cuál.
29
+ */
30
+ export function uriEncode (str, encodeSlash = true) {
31
+ let out = ''
32
+ for (const ch of Buffer.from(String(str), 'utf8')) {
33
+ const c = String.fromCharCode(ch)
34
+ if (/[A-Za-z0-9\-._~]/.test(c)) out += c
35
+ else if (c === '/') out += encodeSlash ? '%2F' : '/'
36
+ else out += '%' + ch.toString(16).toUpperCase().padStart(2, '0')
37
+ }
38
+ return out
39
+ }
40
+
41
+ /** `20150830T123600Z` y `20150830` a partir de una fecha. */
42
+ export function amzDate (date) {
43
+ const iso = date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
44
+ return { amz: iso, day: iso.slice(0, 8) }
45
+ }
46
+
47
+ /**
48
+ * Firma una petición con SigV4 y devuelve las cabeceras que hay que mandar.
49
+ *
50
+ * @param {object} o
51
+ * @param {string} o.method
52
+ * @param {string|URL} o.url
53
+ * @param {Record<string,string>} [o.headers] las que ya lleva (se firman todas)
54
+ * @param {string} [o.payloadHash] hex del SHA-256 del cuerpo; por defecto, cuerpo vacío
55
+ * @param {string} o.accessKeyId
56
+ * @param {string} o.secretAccessKey
57
+ * @param {string} o.region
58
+ * @param {string} [o.service] `s3` salvo en los vectores de prueba de AWS
59
+ * @param {Date} [o.now]
60
+ * @returns {Record<string,string>} cabeceras completas, con `authorization`
61
+ */
62
+ export function signRequest ({
63
+ method, url, headers = {}, payloadHash = EMPTY_SHA256,
64
+ accessKeyId, secretAccessKey, region, service = 's3', now = new Date()
65
+ }) {
66
+ const u = new URL(url)
67
+ const { amz, day } = amzDate(now)
68
+
69
+ // Las cabeceras firmadas van en minúsculas y ordenadas; `host` y la fecha son
70
+ // obligatorias, y en S3 también el hash del cuerpo.
71
+ const all = { ...headers, host: u.host, 'x-amz-date': amz }
72
+ if (service === 's3') all['x-amz-content-sha256'] = payloadHash
73
+ const names = Object.keys(all).map((k) => k.toLowerCase()).sort()
74
+ const lower = Object.fromEntries(Object.entries(all).map(([k, v]) => [k.toLowerCase(), String(v).trim()]))
75
+
76
+ const canonicalHeaders = names.map((n) => `${n}:${lower[n]}\n`).join('')
77
+ const signedHeaders = names.join(';')
78
+
79
+ const query = [...u.searchParams.entries()]
80
+ .map(([k, v]) => [uriEncode(k), uriEncode(v)])
81
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
82
+ .map(([k, v]) => `${k}=${v}`)
83
+ .join('&')
84
+
85
+ const canonicalRequest = [
86
+ method.toUpperCase(),
87
+ uriEncode(decodeURIComponent(u.pathname), false),
88
+ query,
89
+ canonicalHeaders,
90
+ signedHeaders,
91
+ payloadHash
92
+ ].join('\n')
93
+
94
+ const scope = `${day}/${region}/${service}/aws4_request`
95
+ const stringToSign = [ALGO, amz, scope, sha256hex(canonicalRequest)].join('\n')
96
+
97
+ const kDate = hmac(`AWS4${secretAccessKey}`, day)
98
+ const kRegion = hmac(kDate, region)
99
+ const kService = hmac(kRegion, service)
100
+ const kSigning = hmac(kService, 'aws4_request')
101
+ const signature = createHmac('sha256', kSigning).update(stringToSign, 'utf8').digest('hex')
102
+
103
+ return {
104
+ ...all,
105
+ authorization: `${ALGO} Credential=${accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
106
+ // Se devuelven para poder diagnosticar una firma que no cuadra: el servidor
107
+ // contesta "SignatureDoesNotMatch" y nada más, así que sin esto no hay por dónde.
108
+ _canonicalRequest: canonicalRequest,
109
+ _stringToSign: stringToSign
110
+ }
111
+ }
112
+
113
+ /** Error de S3 con el código que devolvió el servidor (`NoSuchKey`, `AccessDenied`…). */
114
+ export class S3Error extends Error {
115
+ constructor (status, code, message, key) {
116
+ super(`S3 ${status} ${code}${key ? ` (${key})` : ''}: ${message}`)
117
+ this.name = 'S3Error'
118
+ this.status = status
119
+ this.code = code
120
+ }
121
+ }
122
+
123
+ /** El cuerpo de error de S3 es XML; solo interesan dos etiquetas. */
124
+ const parseError = (xml) => ({
125
+ code: /<Code>([^<]+)<\/Code>/.exec(xml)?.[1] || 'Unknown',
126
+ message: /<Message>([^<]+)<\/Message>/.exec(xml)?.[1] || xml.slice(0, 200)
127
+ })
128
+
129
+ /**
130
+ * Un bucket. Se crea uno por bucket (público y privado tienen credenciales
131
+ * distintas a propósito, §15.1), y no guarda estado: cada petición se firma sola.
132
+ */
133
+ export class S3Bucket {
134
+ /**
135
+ * @param {object} o
136
+ * @param {string} o.endpoint p.ej. `https://<id>.r2.cloudflarestorage.com`
137
+ * @param {string} o.bucket
138
+ * @param {string} o.accessKeyId
139
+ * @param {string} o.secretAccessKey
140
+ * @param {string} [o.region] `auto` en R2
141
+ * @param {typeof fetch} [o.fetch] inyectable para la prueba de integración
142
+ */
143
+ constructor ({ endpoint, bucket, accessKeyId, secretAccessKey, region = 'auto', fetch: f = fetch }) {
144
+ if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) {
145
+ throw new Error('S3Bucket: faltan endpoint, bucket o credenciales')
146
+ }
147
+ this.base = `${String(endpoint).replace(/\/+$/, '')}/${bucket}`
148
+ this.bucket = bucket
149
+ this.region = region
150
+ this.accessKeyId = accessKeyId
151
+ this.secretAccessKey = secretAccessKey
152
+ this.fetch = f
153
+ }
154
+
155
+ /** URL de un objeto (path-style: es lo que entienden todos los proveedores). */
156
+ urlFor (key) {
157
+ return `${this.base}/${key.split('/').map((s) => uriEncode(s)).join('/')}`
158
+ }
159
+
160
+ /**
161
+ * @private
162
+ * @param {string} method
163
+ * @param {string} key
164
+ * @param {{ headers?: Record<string,string>, body?: any, payloadHash?: string,
165
+ * contentLength?: number|null }} [opts]
166
+ */
167
+ async request (method, key, { headers = {}, body = null, payloadHash = '', contentLength = null } = {}) {
168
+ const url = this.urlFor(key)
169
+ const h = signRequest({
170
+ method,
171
+ url,
172
+ headers,
173
+ payloadHash: payloadHash || EMPTY_SHA256,
174
+ accessKeyId: this.accessKeyId,
175
+ secretAccessKey: this.secretAccessKey,
176
+ region: this.region
177
+ })
178
+ delete h._canonicalRequest
179
+ delete h._stringToSign
180
+ if (contentLength != null) h['content-length'] = String(contentLength)
181
+
182
+ const res = await this.fetch(url, {
183
+ method,
184
+ headers: h,
185
+ body,
186
+ // Node lo exige para mandar un cuerpo en streaming; sin esto hay que
187
+ // tener el archivo entero en memoria, que es justo lo que se evita.
188
+ ...(body && typeof body !== 'string' ? { duplex: 'half' } : {})
189
+ })
190
+ return res
191
+ }
192
+
193
+ /**
194
+ * Sube un objeto. `sha256` es el hex del contenido — que aquí es el `cid` sin su
195
+ * prefijo, así que no hay que calcular nada.
196
+ * @param {string} key
197
+ * @param {any} body stream o buffer
198
+ * @param {{ sha256?: string, size?: number, contentType?: string, cacheControl?: string }} [o]
199
+ */
200
+ async put (key, body, { sha256 = '', size = 0, contentType = '', cacheControl = '' } = {}) {
201
+ if (!sha256) throw new Error('S3Bucket.put: hace falta el sha256 del contenido')
202
+ /** @type {Record<string,string>} */
203
+ const headers = {}
204
+ if (contentType) headers['content-type'] = contentType
205
+ if (cacheControl) headers['cache-control'] = cacheControl
206
+ const res = await this.request('PUT', key, { headers, body, payloadHash: sha256, contentLength: size })
207
+ if (!res.ok) {
208
+ const { code, message } = parseError(await res.text())
209
+ throw new S3Error(res.status, code, message, key)
210
+ }
211
+ return { etag: res.headers.get('etag') }
212
+ }
213
+
214
+ /**
215
+ * Descarga un objeto. `range` es `{ start, end }` inclusivo, igual que en el
216
+ * BlobStore, para que el 206 de la API local siga funcionando igual.
217
+ * @returns {Promise<{ body: ReadableStream, size: number|null, contentType: string|null }>}
218
+ */
219
+ async get (key, range = null) {
220
+ const headers = range ? { range: `bytes=${range.start}-${range.end}` } : {}
221
+ const res = await this.request('GET', key, { headers })
222
+ if (!res.ok) {
223
+ const { code, message } = parseError(await res.text())
224
+ throw new S3Error(res.status, code, message, key)
225
+ }
226
+ const len = res.headers.get('content-length')
227
+ return { body: res.body, size: len == null ? null : Number(len), contentType: res.headers.get('content-type') }
228
+ }
229
+
230
+ /** Los primeros `n` bytes, que es lo que necesita olfatear un tipo de imagen (§7.2). */
231
+ async head (key, n) {
232
+ const { body } = await this.get(key, { start: 0, end: n - 1 })
233
+ const chunks = []
234
+ for await (const c of body) chunks.push(Buffer.from(c))
235
+ return Buffer.concat(chunks)
236
+ }
237
+
238
+ /** Tamaño del objeto, o `null` si no está. No baja el cuerpo. */
239
+ async size (key) {
240
+ const res = await this.request('HEAD', key)
241
+ if (res.status === 404) return null
242
+ if (!res.ok) throw new S3Error(res.status, 'HeadFailed', res.statusText, key)
243
+ const len = res.headers.get('content-length')
244
+ return len == null ? null : Number(len)
245
+ }
246
+
247
+ /** Borra. Borrar lo que no está NO es un error (igual que `rm -f`). */
248
+ async remove (key) {
249
+ const res = await this.request('DELETE', key)
250
+ if (!res.ok && res.status !== 404) {
251
+ const { code, message } = parseError(await res.text())
252
+ throw new S3Error(res.status, code, message, key)
253
+ }
254
+ }
255
+ }
256
+
257
+ export default { S3Bucket, signRequest, uriEncode, amzDate, S3Error }