@dotrino/content 0.3.13

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/ops.js ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * ops.js — las operaciones del PLANO DE CONTROL del node (DISENO.md §5.1 y §7).
3
+ *
4
+ * Es lo que tus propias apps pueden pedirle a este node a distancia: ver qué
5
+ * guarda, retener, soltar, borrar y marcar qué es público. Módulo PURO: no sabe de
6
+ * red, de sesiones ni de firmas — recibe un objeto ya descifrado y autorizado, y
7
+ * devuelve la respuesta. Así se prueba entero sin levantar nada, y quien autoriza
8
+ * (`agent.js`, vía `@dotrino/remote-agent`) queda en un solo sitio.
9
+ *
10
+ * SOBRE `put` Y `get`: existen, pero con un TOPE DURO de un mensaje (256 KB), y
11
+ * eso no es una limitación técnica que haya que levantar después — es la frontera
12
+ * (§7.1). El plano de control es el proxy del ecosistema: sus tramas son de 1 MB y
13
+ * su cola es de mensajes, no un almacén, y meter contenido por ahí sería usar la
14
+ * infraestructura de Dotrino como transporte, que es la regla dura 3. Por eso NO
15
+ * hay subida por partes: si algo no cabe en un mensaje, es que no es un mensaje.
16
+ *
17
+ * Lo que sí es un mensaje y por eso pasa: **un post** (un eco pesa cientos de
18
+ * bytes) y **una miniatura** (decenas de KB). Los originales suben en local por
19
+ * HTTP y, entre aparatos, por P2P — que es lo que este tope deja pendiente a
20
+ * propósito en vez de disimularlo con un troceado.
21
+ *
22
+ * El contrato de errores es `code`, no la frase: quien recibe compara `code`
23
+ * (`bad-request`, `not-found`, `unknown-op`, `failed`), que es lo estable. La frase
24
+ * es para un humano leyendo una bitácora.
25
+ */
26
+
27
+ /** Valores admitidos de ACL. Público es OPT-IN explícito; lo que no se dice, privado. */
28
+ export const ACL = Object.freeze({ PUBLIC: 'public', PRIVATE: 'private' })
29
+
30
+ /**
31
+ * Tope de lo que puede entrar o salir por el plano de control, en bytes crudos.
32
+ * En base64 dentro del sobre cifrado son ~350 KB, cómodos bajo la trama de 1 MB
33
+ * del proxy. **Es una frontera de diseño, no un parámetro a subir**: lo que no
34
+ * cabe aquí no es un mensaje y va por el otro camino.
35
+ */
36
+ export const CONTROL_PLANE_MAX_BYTES = 256 * 1024
37
+
38
+ import { Readable } from 'node:stream'
39
+ import { isValidCid } from './blobstore.js'
40
+
41
+ const ok = (rid, result) => ({ rid, ok: true, ...result })
42
+ const fail = (rid, code, error) => ({ rid, ok: false, code, error })
43
+
44
+ /**
45
+ * Metadatos de presentación, quedándose SOLO con los campos conocidos y
46
+ * recortados. Esto acaba en un HTML público (§7.3), así que no se guarda lo que
47
+ * llegue: ni campos de más ni textos sin fin.
48
+ * @returns {{name?:string,title?:string,description?:string}|null}
49
+ */
50
+ /** Cuántos enlaces lleva una tarjeta, y cuánto puede medir cada uno. */
51
+ const MAX_LINKS = 4
52
+ const MAX_LINK_LEN = 300
53
+
54
+ function cleanMeta (src) {
55
+ if (!src || typeof src !== 'object') return null
56
+ const out = Object.fromEntries(['name', 'title', 'description']
57
+ .map((k) => [k, typeof src[k] === 'string' ? src[k].trim().slice(0, 300) : null])
58
+ .filter(([, v]) => v))
59
+ // Los ENLACES del contenido (en un eco, la fuente de la que habla). Solo http(s)
60
+ // y contados: `meta` lo escribe quien publica y la tarjeta lo pinta como `href`,
61
+ // así que aquí se filtra en vez de creerse.
62
+ const links = (Array.isArray(src.links) ? src.links : [])
63
+ .filter((u) => typeof u === 'string')
64
+ .map((u) => u.trim())
65
+ .filter((u) => /^https?:\/\//i.test(u) && u.length <= MAX_LINK_LEN)
66
+ .slice(0, MAX_LINKS)
67
+ if (links.length) out.links = links
68
+ return Object.keys(out).length ? out : null
69
+ }
70
+
71
+ /**
72
+ * Construye el despachador de operaciones de un node.
73
+ *
74
+ * @param {import('./node.js').ContentNode} node
75
+ * @param {{ owner?: string|null, version?: string }} [meta]
76
+ * owner: `ownerId` (huella de la maestra) que este node representa — es la mitad
77
+ * de la referencia compartible `ownerId + cid` (§3).
78
+ * @returns {(msg: any) => Promise<any>}
79
+ */
80
+ export function createOps (node, { owner = null, version = null } = {}) {
81
+ /** Operaciones que exigen un `cid` válido y que el blob exista. */
82
+ const withBlob = (fn) => async (msg) => {
83
+ const cid = typeof msg.cid === 'string' ? msg.cid : null
84
+ if (!cid) return fail(msg.rid, 'bad-request', 'cid is required')
85
+ const meta = node.stat(cid)
86
+ if (!meta) return fail(msg.rid, 'not-found', 'no such cid')
87
+ return fn(msg, cid, meta)
88
+ }
89
+
90
+ const handlers = {
91
+ /** Quién es este node: sirve de saludo y de comprobación de vida. */
92
+ hello: async (msg) => ok(msg.rid, {
93
+ owner, version, stats: node.stats(), maxBytes: CONTROL_PLANE_MAX_BYTES
94
+ }),
95
+
96
+ /**
97
+ * Guardar algo pequeño desde otro aparato tuyo. Los bytes llegan en base64
98
+ * dentro del sobre ya cifrado de la sesión, en UN mensaje: no hay subida por
99
+ * partes y no la va a haber (ver la cabecera del archivo).
100
+ *
101
+ * Que el aparato esté autorizado ya lo comprobó `verifyChain` antes de que
102
+ * esto se llame; aquí solo se comprueba la forma y el tamaño.
103
+ */
104
+ put: async (msg) => {
105
+ if (typeof msg.data !== 'string') return fail(msg.rid, 'bad-request', 'data (base64) is required')
106
+ let buf
107
+ try { buf = Buffer.from(msg.data, 'base64') } catch { return fail(msg.rid, 'bad-request', 'data is not valid base64') }
108
+ if (!buf.length) return fail(msg.rid, 'bad-request', 'empty payload')
109
+ if (buf.length > CONTROL_PLANE_MAX_BYTES) {
110
+ return fail(msg.rid, 'too-large',
111
+ `the control plane carries up to ${CONTROL_PLANE_MAX_BYTES} bytes; upload larger blobs locally or peer to peer`)
112
+ }
113
+ const enc = msg.enc ? 1 : 0
114
+ // Mismo cerrojo que en `acl`, y por lo mismo: decir que algo cifrado es
115
+ // público solo engaña a quien lo mire, porque nadie sin la llave lo lee.
116
+ const acl = msg.acl === ACL.PUBLIC && !enc ? ACL.PUBLIC : ACL.PRIVATE
117
+ const ttlMs = Number(msg.ttl) || 0
118
+ const out = await node.put(Readable.from(buf), {
119
+ mime: typeof msg.mime === 'string' ? msg.mime : 'application/octet-stream',
120
+ enc,
121
+ acl,
122
+ ttl: ttlMs > 0 ? Date.now() + ttlMs : null,
123
+ meta: cleanMeta(msg.meta)
124
+ })
125
+ return ok(msg.rid, { ...out, acl })
126
+ },
127
+
128
+ /**
129
+ * Leer algo pequeño de vuelta (otro aparato tuyo, o el mismo tras reinstalar).
130
+ * Mismo tope, y por la misma razón: esto es el plano de control.
131
+ */
132
+ get: withBlob(async (msg, cid, meta) => {
133
+ if (meta.size > CONTROL_PLANE_MAX_BYTES) {
134
+ return fail(msg.rid, 'too-large',
135
+ `${meta.size} bytes do not fit in the control plane; fetch it locally or peer to peer`)
136
+ }
137
+ const chunks = []
138
+ for await (const c of node.read(cid)) chunks.push(c)
139
+ return ok(msg.rid, {
140
+ cid, size: meta.size, mime: meta.mime, enc: meta.enc, data: Buffer.concat(chunks).toString('base64')
141
+ })
142
+ }),
143
+
144
+ list: async (msg) => ok(msg.rid, { blobs: node.list() }),
145
+
146
+ stats: async (msg) => ok(msg.rid, { stats: node.stats() }),
147
+
148
+ stat: withBlob(async (msg, cid, meta) => ok(msg.rid, { blob: meta })),
149
+
150
+ /** Retener: un blob pineado no lo borra el GC ni por cuota ni por ttl. */
151
+ pin: withBlob(async (msg, cid) => ok(msg.rid, { pinned: node.pin(cid, true) })),
152
+
153
+ unpin: withBlob(async (msg, cid) => ok(msg.rid, { pinned: !node.pin(cid, false) })),
154
+
155
+ remove: withBlob(async (msg, cid) => {
156
+ await node.remove(cid)
157
+ return ok(msg.rid, { removed: cid })
158
+ }),
159
+
160
+ /**
161
+ * Marcar un blob como público o privado. Es el interruptor que la Fase 3 mira
162
+ * antes de servir algo por HTTP: sin `public` explícito, no sale del node.
163
+ */
164
+ acl: withBlob(async (msg, cid) => {
165
+ const acl = msg.acl === ACL.PUBLIC ? ACL.PUBLIC : msg.acl === ACL.PRIVATE ? ACL.PRIVATE : null
166
+ if (!acl) return fail(msg.rid, 'bad-request', `acl must be "${ACL.PUBLIC}" or "${ACL.PRIVATE}"`)
167
+ const meta = node.stat(cid)
168
+ // Un blob cifrado no se puede marcar público: nadie sin la llave podría leerlo,
169
+ // así que decir que lo es solo engaña a quien lo mire.
170
+ if (acl === ACL.PUBLIC && meta.enc) return fail(msg.rid, 'bad-request', 'an encrypted blob cannot be public')
171
+ node.setAcl(cid, acl)
172
+ return ok(msg.rid, { cid, acl })
173
+ }),
174
+
175
+ /**
176
+ * Metadatos de PRESENTACIÓN (nombre, título, descripción). Es lo único con lo
177
+ * que la vista previa pública arma su tarjeta (§7.3): sin esto, una tarjeta
178
+ * solo puede decir el tipo y el tamaño. No forma parte del `cid` —dos nombres
179
+ * para los mismos bytes son el mismo blob—, por eso se pone aparte.
180
+ */
181
+ meta: withBlob(async (msg, cid) => {
182
+ if (msg.meta !== null && (!msg.meta || typeof msg.meta !== 'object')) {
183
+ return fail(msg.rid, 'bad-request', 'meta must be an object or null')
184
+ }
185
+ const clean = cleanMeta(msg.meta)
186
+ node.setMeta(cid, clean)
187
+ return ok(msg.rid, { cid, meta: clean })
188
+ }),
189
+
190
+ /**
191
+ * Enlaza la miniatura de un blob. La miniatura es OTRO blob (subido por la
192
+ * app) y tiene que ser pública por su cuenta: enlazarla no la publica.
193
+ */
194
+ thumb: withBlob(async (msg, cid) => {
195
+ const t = msg.thumbnailCid
196
+ if (t !== null && !isValidCid(t)) return fail(msg.rid, 'bad-request', 'thumbnailCid must be a valid cid or null')
197
+ if (t && !node.stat(t)) return fail(msg.rid, 'not-found', 'the thumbnail is not in this node')
198
+ node.setThumbnail(cid, t)
199
+ return ok(msg.rid, { cid, thumbnailCid: t })
200
+ }),
201
+
202
+ /** Recolección de vencidos (lo que el temporizador hace solo, a pedido). */
203
+ gc: async (msg) => ok(msg.rid, node.gc())
204
+ }
205
+
206
+ return async function dispatch (msg) {
207
+ if (!msg || typeof msg !== 'object' || typeof msg.op !== 'string') {
208
+ return fail(msg?.rid, 'bad-request', 'op is required')
209
+ }
210
+ const handler = handlers[msg.op]
211
+ if (!handler) return fail(msg.rid, 'unknown-op', `unknown op: ${msg.op}`)
212
+ try {
213
+ return await handler(msg)
214
+ } catch (e) {
215
+ return fail(msg.rid, e?.code === 'ENOSPC' ? 'no-space' : 'failed', e?.message || 'failed')
216
+ }
217
+ }
218
+ }
219
+
220
+ export default { createOps, ACL, CONTROL_PLANE_MAX_BYTES }
package/src/public.js ADDED
@@ -0,0 +1,400 @@
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
+ /** El host de una URL, o '' si no se puede leer. */
152
+ const hostOf = (u) => { try { return new URL(u).hostname } catch { return '' } }
153
+
154
+ /** Un enlace legible: se cae el `https://` y la cola larga, no el destino. */
155
+ function shortLink (u) {
156
+ try {
157
+ const url = new URL(u)
158
+ const tail = (url.pathname === '/' ? '' : url.pathname) + url.search
159
+ return url.hostname.replace(/^www\./, '') + (tail.length > 40 ? tail.slice(0, 39) + '…' : tail)
160
+ } catch { return u }
161
+ }
162
+
163
+ const parseMeta = (row) => {
164
+ if (!row?.meta) return {}
165
+ try { return JSON.parse(row.meta) || {} } catch { return {} }
166
+ }
167
+
168
+ /**
169
+ * Página del permalink (§7.3). Es una tarjeta y un enlace, y se dice así: no
170
+ * pretende ser un visor. Lleva la imagen solo si el propio blob es una imagen o
171
+ * si tiene una miniatura pública enlazada (`thumbnailCid`).
172
+ *
173
+ * El texto se pinta ENTERO: recortarlo aquí dejaba tarjetas cortadas a mitad de
174
+ * frase, y quien decide cuánto texto hay es quien lo publicó, no esta página.
175
+ * Si el contenido trae enlaces (`meta.links` — en un eco, la fuente de la que
176
+ * habla), se pintan: sin ellos la tarjeta cuenta la noticia y esconde de dónde
177
+ * salió.
178
+ */
179
+ export function previewHtml ({ cid, row, base, appUrl, owner, index, imageCid = null, imageFallback = null }) {
180
+ const m = parseMeta(row)
181
+ const title = m.title || m.name || 'Contenido compartido'
182
+ // La imagen de la tarjeta la decide la ruta, que es quien pudo COMPROBAR que
183
+ // ese cid es una imagen de verdad y que sale por el puerto. Aquí solo se pinta.
184
+ // Sin imagen propia, la tarjeta de la app (`og.jpg`): una tarjeta sin imagen casi no
185
+ // se ve en X/LinkedIn, y la app es de quien es el contenido.
186
+ const image = imageCid ? `${base}/c/${imageCid}` : (imageFallback || null)
187
+ const kb = Math.max(1, Math.round(row.size / 1024))
188
+ const desc = m.description || `${row.mime} · ${kb} KB`
189
+ // El enlace de "abrir" lleva la referencia en el #fragment: el servidor de la
190
+ // app nunca la ve (CLAUDE.md §SEO). Sin `owner` no hay referencia que armar.
191
+ const open = owner ? `${appUrl.replace(/\/+$/, '')}/#${owner}/${cid}` : null
192
+ // Solo http(s): `meta` lo escribe quien publica, y un `javascript:` en un href
193
+ // sería un agujero abierto de par en par.
194
+ const links = (Array.isArray(m.links) ? m.links : [])
195
+ .map((u) => String(u)).filter((u) => /^https?:\/\//i.test(u)).slice(0, 4)
196
+ const openLabel = `Abrir en ${hostOf(appUrl) || 'la app'}`
197
+ return `<!doctype html>
198
+ <html lang="es">
199
+ <head>
200
+ <meta charset="utf-8">
201
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
202
+ <title>${esc(title)}</title>
203
+ <meta name="robots" content="${index ? 'index, follow' : 'noindex, nofollow'}">
204
+ <meta name="description" content="${esc(desc)}">
205
+ <link rel="canonical" href="${esc(base)}/p/${cid}">
206
+ <meta property="og:type" content="website">
207
+ <meta property="og:site_name" content="Dotrino">
208
+ <meta property="og:title" content="${esc(title)}">
209
+ <meta property="og:description" content="${esc(desc)}">
210
+ <meta property="og:url" content="${esc(base)}/p/${cid}">
211
+ ${image ? `<meta property="og:image" content="${esc(image)}">` : ''}
212
+ <meta name="twitter:card" content="${image ? 'summary_large_image' : 'summary'}">
213
+ <meta name="twitter:title" content="${esc(title)}">
214
+ <meta name="twitter:description" content="${esc(desc)}">
215
+ ${image ? `<meta name="twitter:image" content="${esc(image)}">` : ''}
216
+ <style>
217
+ :root { color-scheme: dark; }
218
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center;
219
+ background: #0e1116; color: #e8ecf1; font: 16px/1.5 system-ui, sans-serif;
220
+ padding: max(1rem, env(safe-area-inset-top)) 1rem; }
221
+ main { max-width: 34rem; text-align: center; }
222
+ img { max-width: 100%; height: auto; border-radius: .75rem; }
223
+ h1 { font-size: 1.25rem; margin: 1rem 0 .25rem; }
224
+ p { color: #9aa7b4; margin: .25rem 0 1.25rem; text-align: left; }
225
+ ul.links { list-style: none; margin: 0 0 1.25rem; padding: 0; text-align: left;
226
+ overflow-wrap: anywhere; }
227
+ ul.links li { margin: .2rem 0; }
228
+ a { color: #6ea0ff; }
229
+ a.open { font-weight: 600; }
230
+ small { display: block; margin-top: 1.5rem; color: #6b7787; }
231
+ small a { color: #6b7787; }
232
+ </style>
233
+ </head>
234
+ <body>
235
+ <main>
236
+ ${image ? `<img src="${esc(image)}" alt="${esc(title)}">` : ''}
237
+ <h1>${esc(title)}</h1>
238
+ <p>${esc(desc)}</p>
239
+ ${links.length ? `<ul class="links">${links.map((u) => `<li><a href="${esc(u)}" rel="noopener nofollow">${esc(shortLink(u))}</a></li>`).join('')}</ul>` : ''}
240
+ ${open ? `<a class="open" href="${esc(open)}">${esc(openLabel)}</a>` : ''}
241
+ <small>Servido desde el node de su dueño · <a href="https://content.dotrino.com/">Dotrino</a></small>
242
+ </main>
243
+ </body>
244
+ </html>`
245
+ }
246
+
247
+ /**
248
+ * Servidor del modo público.
249
+ *
250
+ * @param {import('./node.js').ContentNode} node
251
+ * @param {{
252
+ * maxBytes?: number, ratePerMin?: number, maxEgressBytes?: number,
253
+ * publicUrl?: string|null, appUrl?: string, index?: boolean, owner?: string|null,
254
+ * quiet?: boolean
255
+ * }} [opts]
256
+ */
257
+ export function createPublicServer (node, opts = {}) {
258
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES
259
+ const maxEgressBytes = opts.maxEgressBytes || 0
260
+ const appUrl = opts.appUrl || 'https://eco.dotrino.com/'
261
+ const limiter = new RateLimiter(opts.ratePerMin ?? DEFAULT_RATE_PER_MIN)
262
+
263
+ /** ¿Queda techo de egress para hoy? */
264
+ const egressLeft = () => {
265
+ if (!maxEgressBytes) return Infinity
266
+ return maxEgressBytes - node.index.egressOn(utcDay())
267
+ }
268
+
269
+ const server = http.createServer(async (req, res) => {
270
+ try {
271
+ if (!limiter.allow(clientIp(req))) return text(res, 429, 'demasiadas peticiones\n', { 'retry-after': '60' })
272
+ await route(req, res)
273
+ } catch (err) {
274
+ if (res.headersSent) { res.destroy(); return }
275
+ text(res, 500, 'error\n')
276
+ if (!opts.quiet) console.error('[content:public]', err.message)
277
+ }
278
+ })
279
+
280
+ async function route (req, res) {
281
+ if (req.method !== 'GET' && req.method !== 'HEAD') return text(res, 405, 'método no permitido\n')
282
+ const url = new URL(req.url, 'http://localhost')
283
+ const [, top, cid] = url.pathname.split('/')
284
+
285
+ if (url.pathname === '/robots.txt') {
286
+ return text(res, 200, opts.index ? 'User-agent: *\nAllow: /p/\nDisallow: /c/\n' : 'User-agent: *\nDisallow: /\n')
287
+ }
288
+ if (url.pathname === '/health') {
289
+ const used = maxEgressBytes ? node.index.egressOn(utcDay()) : 0
290
+ return json(res, 200, { ok: true, egressToday: used, maxEgressBytes: maxEgressBytes || null })
291
+ }
292
+
293
+ if ((top === 'c' || top === 'p') && cid) {
294
+ if (!isValidCid(cid)) return text(res, 400, 'cid inválido\n')
295
+ // 404 y no 403 para lo privado: un 403 confirmaría que ese cid existe aquí.
296
+ const row = node.publicStat(cid)
297
+ if (!row) return text(res, 404, 'no disponible\n')
298
+
299
+ if (top === 'p') {
300
+ const body = previewHtml({
301
+ cid, row, imageCid: await pickImage(cid, row),
302
+ base: baseUrl(req, opts.publicUrl), appUrl, owner: opts.owner ?? node.owner, index: !!opts.index,
303
+ imageFallback: opts.imageFallback ?? `${appUrl.replace(/\/+$/, '')}/og.jpg`
304
+ })
305
+ res.writeHead(200, {
306
+ 'content-type': 'text/html; charset=utf-8',
307
+ 'cache-control': 'public, max-age=3600',
308
+ 'x-robots-tag': opts.index ? 'all' : 'noindex, nofollow'
309
+ })
310
+ return res.end(req.method === 'HEAD' ? undefined : body)
311
+ }
312
+
313
+ // --- bytes ---
314
+ // El tipo REAL, sacado del archivo. `row.mime` no decide nada aquí.
315
+ const kind = await sniffImage(node.store, cid, { public: true })
316
+ if (!kind) return text(res, 404, 'no disponible\n')
317
+ if (maxBytes && row.size > maxBytes) {
318
+ // No es un error del que pide: es la política del node. Se dice cuál es.
319
+ return text(res, 413, 'este node solo publica vistas previas; el contenido se abre en la app\n')
320
+ }
321
+ const headers = {
322
+ 'content-type': kind,
323
+ etag: `"${cid}"`,
324
+ 'cache-control': 'public, max-age=31536000, immutable',
325
+ 'accept-ranges': 'bytes',
326
+ 'x-content-type-options': 'nosniff',
327
+ 'x-robots-tag': 'noindex',
328
+ // El blob es de otro dominio y lo pide una app del ecosistema: sin CORS
329
+ // el <img>/fetch de la app no lo puede leer.
330
+ 'access-control-allow-origin': '*'
331
+ }
332
+ if (req.headers['if-none-match'] === `"${cid}"`) return res.writeHead(304, headers).end()
333
+
334
+ const range = parseRangeHeader(req.headers.range, row.size)
335
+ if (range === false) return res.writeHead(416, { 'content-range': `bytes */${row.size}` }).end()
336
+ const sending = range ? range.end - range.start + 1 : row.size
337
+ // El techo se mira contra lo que ESTA respuesta va a mandar, no contra cero:
338
+ // dejar entrar una petición porque "aún quedan 10 bytes" es rebasarlo igual,
339
+ // solo que fingiendo que no. Un HEAD no manda cuerpo, así que no gasta.
340
+ if (req.method === 'GET' && egressLeft() < sending) {
341
+ return text(res, 503, 'techo de salida diario alcanzado\n', { 'retry-after': '3600' })
342
+ }
343
+ if (range) {
344
+ headers['content-range'] = `bytes ${range.start}-${range.end}/${row.size}`
345
+ headers['content-length'] = sending
346
+ res.writeHead(206, headers)
347
+ } else {
348
+ headers['content-length'] = sending
349
+ res.writeHead(200, headers)
350
+ }
351
+ if (req.method === 'HEAD') return res.end()
352
+ // Se contabiliza lo que de verdad sale por el socket, no lo que se prometió:
353
+ // una descarga abortada a la mitad no debe gastar el techo entero.
354
+ let sent = 0
355
+ res.on('close', () => { if (maxEgressBytes && sent) node.index.addEgress(sent, utcDay()) })
356
+ const src = node.read(cid, range ?? undefined)
357
+ src.on('data', (c) => { sent += c.length })
358
+ await pipeline(src, res)
359
+ return
360
+ }
361
+
362
+ return text(res, 404, 'no disponible\n')
363
+ }
364
+
365
+ /**
366
+ * Qué imagen lleva la tarjeta: el propio blob si es una imagen servible, y si
367
+ * no su miniatura (`thumbnailCid`), que tiene que ser pública por su cuenta —
368
+ * enlazar una miniatura no la vuelve pública, la publica su dueño.
369
+ * @returns {Promise<string|null>}
370
+ */
371
+ async function pickImage (cid, row) {
372
+ for (const candidate of [{ cid, row }, row.thumbnailCid ? { cid: row.thumbnailCid, row: node.publicStat(row.thumbnailCid) } : null]) {
373
+ if (!candidate?.row) continue
374
+ if (maxBytes && candidate.row.size > maxBytes) continue
375
+ if (await sniffImage(node.store, candidate.cid, { public: true })) return candidate.cid
376
+ }
377
+ return null
378
+ }
379
+
380
+ return server
381
+ }
382
+
383
+ /** Igual que el del servidor local; duplicado mínimo para no acoplar los dos. */
384
+ function parseRangeHeader (header, size) {
385
+ if (!header) return null
386
+ const m = /^bytes=(\d*)-(\d*)$/.exec(String(header).trim())
387
+ if (!m || (m[1] === '' && m[2] === '')) return false
388
+ let start, end
389
+ if (m[1] === '') {
390
+ const n = Number(m[2])
391
+ if (n === 0) return false
392
+ start = Math.max(0, size - n); end = size - 1
393
+ } else {
394
+ start = Number(m[1]); end = m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1)
395
+ }
396
+ if (start >= size || start > end) return false
397
+ return { start, end }
398
+ }
399
+
400
+ export default { createPublicServer, RateLimiter, previewHtml, utcDay, DEFAULT_PUBLIC_PORT, DEFAULT_MAX_BYTES }