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/README.md +173 -0
- package/bin/cli.js +203 -0
- package/package.json +57 -0
- package/src/agent.js +104 -0
- package/src/announce.js +132 -0
- package/src/blobstore-s3.js +177 -0
- package/src/blobstore.js +139 -0
- package/src/db.js +237 -0
- package/src/index.js +3 -0
- package/src/node.js +243 -0
- package/src/ops.js +207 -0
- package/src/public.js +370 -0
- package/src/s3.js +257 -0
- package/src/server.js +142 -0
- package/src/storage.js +175 -0
- package/src/vaultEnv.js +144 -0
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
109
|
+
.replace(/"/g, '"').replace(/'/g, ''')
|
|
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 }
|