@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/README.md +183 -0
- package/bin/cli.js +210 -0
- package/lib/public.js +127 -0
- package/lib/ref.js +69 -0
- package/package.json +59 -0
- package/src/agent.js +111 -0
- package/src/announce.js +138 -0
- package/src/blobstore-s3.js +177 -0
- package/src/blobstore.js +139 -0
- package/src/db.js +237 -0
- package/src/fetch.js +89 -0
- package/src/index.js +3 -0
- package/src/node.js +262 -0
- package/src/ops.js +220 -0
- package/src/public.js +400 -0
- package/src/s3.js +257 -0
- package/src/server.js +142 -0
- package/src/storage.js +175 -0
- package/src/vaultEnv.js +158 -0
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, '&').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
|
+
/** 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 }
|