@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 ADDED
@@ -0,0 +1,183 @@
1
+ # dotrino-content
2
+
3
+ Nodo de contenido del ecosistema **Dotrino**: guarda y sirve **media pesada**
4
+ (video/imagen/audio/archivos) direccionada por **hash de contenido** (`cid`),
5
+ autohospedado por el usuario. Diseño completo en
6
+ [`docs/DISENO.md`](./docs/DISENO.md); estado/continuación en
7
+ [`docs/HANDOFF.md`](./docs/HANDOFF.md).
8
+
9
+ > **Estado: Fase 2 (aparato del vault) + vistas previas públicas.** Al core local se
10
+ > le suma la identidad: el node se enrola a tu bóveda y se puede administrar desde tus
11
+ > apps por el proxy, sin abrir puertos. El HTTP de administración sigue escuchando
12
+ > **solo en `127.0.0.1`**; con `--public` se abre, aparte, un puerto que sirve
13
+ > **únicamente vistas previas** para que un enlace compartido tenga tarjeta en las
14
+ > redes. El transporte P2P entre aparatos es lo que queda de la Fase 3.
15
+
16
+ ## Uso
17
+
18
+ > **El paquete se llama `@dotrino/content` desde la 0.3.13.** Hasta la 0.3.12 se publicó
19
+ > como `dotrino-content`, fuera del scope, y era el único del ecosistema así — el resto de
20
+ > los daemons (`@dotrino/vaultd`, `@dotrino/sealers`, `@dotrino/terminal-agent`,
21
+ > `@dotrino/tunnel`) siempre lo llevaron. No había ninguna razón detrás: nació así y nadie
22
+ > lo revisó. **El comando no cambia**, sigue siendo `dotrino-content`; lo que cambia es lo
23
+ > que se instala. Quien lo tenga puesto por el nombre viejo, reinstálelo:
24
+ > `npm i -g @dotrino/content`.
25
+
26
+
27
+ ```sh
28
+ # una vez: enlazar este node a tu bóveda (saca el código de `dotrino-vault pair`)
29
+ npx @dotrino/content enroll <código>
30
+
31
+ npx @dotrino/content start [--port 3777] [--dir ~/.dotrino-content] \
32
+ [--max-gb 50] [--max-blob-mb 512] [--gc-min 60] [--no-agent]
33
+
34
+ # con vistas previas públicas (ver más abajo antes de encenderlo)
35
+ npx @dotrino/content start --public --public-port 3778 --public-egress-gb 5 \
36
+ --public-url https://content.tudominio.com
37
+ ```
38
+
39
+ Al enrolar, el node genera **su propia llave** y muestra un código que tienes que
40
+ tipear en la bóveda para aprobarlo: ese código no viaja por la red, así que aprobar
41
+ exige tener delante esta máquina. La **clave maestra nunca llega aquí** — solo un
42
+ certificado con caducidad, que el node renueva solo y que puedes revocar
43
+ (`dotrino-vault revoke <deviceId>`) sin tocar el resto de tus aparatos.
44
+
45
+ Env: `DOTRINO_CONTENT_DIR` (datos), `DOTRINO_CONTENT_LINK_DIR` (enlace), `PORT`.
46
+ Requiere **Node ≥ 22.5**. El core usa solo `node:crypto` y `node:sqlite`; la
47
+ identidad viene de los pilares del ecosistema (`@dotrino/remote-agent`,
48
+ `@dotrino/identity`).
49
+
50
+ ## Plano de control (Fase 2, por el proxy, cifrado)
51
+
52
+ Con el node enlazado, tus propias apps —cualquier aparato con un certificado de **la
53
+ misma** bóveda— pueden administrarlo a distancia dentro de una sesión cifrada:
54
+
55
+ ```
56
+ hello quién es el node (owner, versión, uso de disco, tope)
57
+ put {data,mime,…} guardar algo pequeño desde otro aparato tuyo (≤ 256 KB)
58
+ get <cid> leerlo de vuelta (≤ 256 KB)
59
+ list · stat <cid> · stats qué guarda
60
+ pin <cid> · unpin <cid> retener / soltar
61
+ remove <cid> borrar
62
+ acl <cid> public|private abrir o cerrar un blob (público es opt-in explícito)
63
+ meta <cid> {…} nombre/título/descripción para la tarjeta de la vista previa
64
+ thumb <cid> <thumbCid> enlazar la miniatura (otro blob, público por su cuenta)
65
+ gc recolectar vencidos ahora
66
+ ```
67
+
68
+ **`put` y `get` tienen un tope duro de 256 KB, y eso NO es un límite a subir: es la
69
+ frontera.** El plano de control es el proxy del ecosistema —trama de 1 MB, cola de
70
+ mensajes, no un almacén—, así que por aquí pasa lo que **es** un mensaje: un post (un
71
+ eco pesa cientos de bytes) y una miniatura (decenas de KB). Por eso **no hay subida
72
+ por partes**: trocear sería disimular la frontera y acabar usando la infraestructura
73
+ del ecosistema como transporte. Los originales suben en local por HTTP y, entre
74
+ aparatos, por P2P.
75
+
76
+ ## Vistas previas públicas (`--public`, apagado por defecto)
77
+
78
+ **Para qué es: para que un enlace que compartes tenga TARJETA** en X, LinkedIn,
79
+ WhatsApp o Telegram. No es para servir tu contenido — eso se sigue abriendo en la app,
80
+ con la referencia en el `#fragment`, que nunca llega a ningún servidor. Lo que sale por
81
+ este puerto es la **miniatura** que tú marcaste pública, no el archivo.
82
+
83
+ ```
84
+ GET|HEAD /c/<cid> los bytes, si pasan TODOS los cerrojos de abajo
85
+ GET /p/<cid> permalink: tarjeta (og:*) + botón "Abrir" hacia la app
86
+ GET /robots.txt · /health
87
+ ```
88
+
89
+ Cinco cerrojos, y ninguno se puede saltar desde fuera:
90
+
91
+ | | |
92
+ |---|---|
93
+ | **Solo lo público y en claro** | lo cifrado no sale ni marcado público a mano en el índice |
94
+ | **Solo imágenes de mapa de bits** | JPEG, PNG, GIF, WebP, AVIF. **SVG no**: es un documento que ejecuta scripts |
95
+ | **El tipo se comprueba en los bytes** | el `Content-Type` lo declara quien sube, así que no se cree: un HTML subido como `image/png` responde 404 |
96
+ | **Tope de tamaño** (`--public-max-kb`, 512) | es lo que hace que esto sea un servidor de miniaturas y no un CDN. `0` lo quita y entonces sirve originales: el ancho de banda lo pagas tú |
97
+ | **Límite por IP + techo diario** | `--public-rate` (60/min) y `--public-egress-gb`, que se **persiste** y corta antes de mandar una respuesta que no quepa |
98
+
99
+ Lo privado responde **404, nunca 403**: un 403 confirmaría que ese `cid` está aquí. Y
100
+ `robots.txt` prohíbe todo (las tarjetas funcionan igual: los rastreadores de las redes
101
+ piden la página cuando alguien pega el enlace, no indexan). `--public-index` lo levanta.
102
+
103
+ **La miniatura la genera tu app al subir** (con un canvas) y se sube como **otro
104
+ blob**, que se enlaza con la op `thumb`. El node no decodifica imágenes: así no
105
+ arrastra dependencias nativas. Enlazar una miniatura **no** la publica — se marca
106
+ pública por su cuenta.
107
+
108
+ ## API HTTP (localhost)
109
+
110
+ ```
111
+ POST /c?ttl=<ms>&enc=1 subir (streaming; Content-Type = mime) → { cid, size, mime, existed }
112
+ GET /c/<cid> descargar/streamear (Range → 206; ETag = cid, immutable)
113
+ HEAD /c/<cid> size/mime/etag sin cuerpo
114
+ DELETE /c/<cid> borrar
115
+ GET /list índice de blobs
116
+ POST /pin/<cid> retención (excluye del GC) POST /unpin/<cid>
117
+ GET /stats nº de blobs, bytes usados, cuota
118
+ ```
119
+
120
+ - `cid = sha256-<hex>` (prefijo de algoritmo → extensible a BLAKE3 después; se
121
+ usa SHA-256 de `node:crypto` porque no exige dependencias nativas y el
122
+ `.npmrc` del ecosistema bloquea build scripts de npm).
123
+ - Disco: `blobs/<aa>/<bb>/<cid>` (sharding); índice en SQLite (`index.db`).
124
+ - Dedup por contenido: re-subir el mismo archivo devuelve `existed: true`.
125
+ - **Cuota** (`--max-gb`): al no caber, el GC desaloja no-pineados más viejos;
126
+ los **pineados jamás se borran** (si solo quedan pineados → `507`).
127
+ - **TTL** opcional por blob (`?ttl=<ms>`): vencido = candidato a GC.
128
+ - El cifrado E2E es **del lado del cliente** (el node solo ve ciphertext si
129
+ subes cifrado y marcas `enc=1`); la llave viaja en el `#fragment` del enlace.
130
+ - **`owner` y `acl`:** con el node enlazado, todo lo que se sube queda estampado con
131
+ el `ownerId` de tu bóveda (la mitad izquierda de la referencia compartible
132
+ `ownerId + cid`). El `acl` nace privado: lo que no se marca `public` a mano no sale
133
+ del node cuando llegue el modo público, y un blob cifrado no puede marcarse
134
+ público (nadie sin la llave podría leerlo).
135
+
136
+ ## `@dotrino/content-client` (lo que usa una app)
137
+
138
+ El cliente de navegador vive en [`lib/`](./lib) y se publica desde este mismo repo,
139
+ igual que `dotrino-vault` publica su lib. Está aquí a propósito: el protocolo —los
140
+ nombres de las ops, el tope de 256 KB, el formato de la referencia— es de las dos
141
+ puntas, y separarlas en dos repos es la forma de que acaben diciendo cosas distintas.
142
+
143
+ ```js
144
+ import { ContentClient, buildUrl } from '@dotrino/content-client'
145
+
146
+ const cc = await ContentClient.connect({ link }) // link del vault: { id, cert, iss }
147
+ const ref = await cc.put(bytes, { mime: 'image/png' }) // cifra por defecto
148
+ const url = buildUrl(ref) // https://eco.dotrino.com/#<owner>/<cid>/<llave>
149
+ const back = await cc.get(ref) // comprueba el hash antes de devolver nada
150
+ ```
151
+
152
+ - **Cifra por defecto** (§4): el node guarda ciphertext y la llave sale en la
153
+ referencia, para el `#fragment`. Con `encrypt: false` queda en claro, que es lo que
154
+ hace falta para poder marcarlo `public` y que tenga tarjeta.
155
+ - **`connect()` falla con `code: 'no-node'`** si no tienes ninguno encendido, en vez
156
+ de esperar. Es lo esperable y **la app tiene que saber seguir sin node**: al
157
+ `@dotrino/store` va lo que debe estar siempre disponible; aquí van los bytes.
158
+ - **`get()` comprueba el hash** de lo que llega: el `cid` **es** el hash, así que unos
159
+ bytes que no cuadren se rechazan vengan de donde vengan.
160
+ - **Miniaturas** (`@dotrino/content-client/thumb`): `makeThumbnail()` en canvas y
161
+ `putImageWithThumbnail()`, que sube el original **cifrado y privado** y la miniatura
162
+ **en claro y pública** — que es el reparto que hace que haya tarjeta sin publicar el
163
+ archivo.
164
+ - **Lo PÚBLICO de otro usuario ya se lee por la red** (`fetchPublic` en la lib,
165
+ DISENO §16): un tercero con tu enlace encuentra tu node por el proxio y le pide el
166
+ `cid`; el node contesta solo lo marcado público y en claro, y los bytes se
167
+ verifican contra el `cid`. Lo privado sigue siendo de tus aparatos.
168
+
169
+ ## Tests
170
+
171
+ ```sh
172
+ npm test
173
+ ```
174
+
175
+ ## Fases
176
+
177
+ 1. ✅ **Core local**: blobs por `cid`, Range/206, índice, cuota+GC.
178
+ 2. ✅ **Aparato del vault** (este estado): enrolamiento, plano de control cifrado,
179
+ `owner` + `acl`.
180
+ 3. Exposición: P2P/swarm por WebRTC + modo público HTTP opt-in + sembrador 24/7.
181
+ 4. Integración con **eco** (la app que resuelve el `#fragment`) + catálogo.
182
+
183
+ Licencia MIT.
package/bin/cli.js ADDED
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI de dotrino-content.
4
+ *
5
+ * dotrino-content enroll <código> enlaza este node a tu vault (una vez)
6
+ * dotrino-content start [--port 3777] [--dir <ruta>] [--max-gb <n>] [--gc-min <min>]
7
+ * [--no-agent] arranca sin el plano de control
8
+ * [--public] abre el puerto de VISTAS PREVIAS (§7.2)
9
+ *
10
+ * El HTTP de administración sigue escuchando SOLO en loopback: es la vía local
11
+ * para subir y leer. Lo que añade el enlace es el plano de CONTROL (administrar el
12
+ * node desde tus apps, por el proxy, sin abrir puertos) — ver DISENO.md §7.
13
+ *
14
+ * `--public` levanta un SEGUNDO servidor, aparte y con sus propias reglas
15
+ * (src/public.js): sirve las **vistas previas** de lo que marcaste público —solo
16
+ * imágenes comprobadas, con tope de tamaño, límite por IP y techo de salida— para
17
+ * que un enlace compartido tenga tarjeta en las redes. No es un CDN y no lo va a
18
+ * ser: el contenido se sigue abriendo en la app.
19
+ *
20
+ * Env: DOTRINO_CONTENT_DIR (datos), DOTRINO_CONTENT_LINK_DIR (enlace), PORT.
21
+ */
22
+ import os from 'node:os'
23
+ import path from 'node:path'
24
+ import { parseArgs } from 'node:util'
25
+ import { ContentNode } from '../src/node.js'
26
+ import { createServer } from '../src/server.js'
27
+ import { createPublicServer, DEFAULT_PUBLIC_PORT, DEFAULT_MAX_BYTES, DEFAULT_RATE_PER_MIN } from '../src/public.js'
28
+ import { isLinked, linkDir, startContentAgent } from '../src/agent.js'
29
+
30
+ const USAGE = `uso:
31
+ dotrino-content enroll <código>
32
+ dotrino-content start [--port 3777] [--dir <ruta>] [--max-gb <n>] [--max-blob-mb <n>] [--gc-min <min>] [--no-agent]
33
+ [--public] [--public-port 3778] [--public-host 0.0.0.0] [--public-max-kb 512]
34
+ [--public-rate 60] [--public-egress-gb <n>] [--public-url https://…] [--app-url https://…]
35
+ [--public-index]`
36
+
37
+ const { values, positionals } = parseArgs({
38
+ allowPositionals: true,
39
+ options: {
40
+ port: { type: 'string' },
41
+ dir: { type: 'string' },
42
+ 'max-gb': { type: 'string' },
43
+ 'max-blob-mb': { type: 'string' },
44
+ 'gc-min': { type: 'string' },
45
+ 'no-agent': { type: 'boolean' },
46
+ // --- modo público (§7.2): apagado por defecto, y cada límite es un flag ---
47
+ public: { type: 'boolean' },
48
+ 'public-port': { type: 'string' },
49
+ 'public-host': { type: 'string' },
50
+ 'public-max-kb': { type: 'string' },
51
+ 'public-rate': { type: 'string' },
52
+ 'public-egress-gb': { type: 'string' },
53
+ 'public-url': { type: 'string' },
54
+ 'app-url': { type: 'string' },
55
+ 'public-index': { type: 'boolean' },
56
+ help: { type: 'boolean', short: 'h' }
57
+ }
58
+ })
59
+
60
+ if (values.help) {
61
+ console.log(USAGE)
62
+ process.exit(0)
63
+ }
64
+
65
+ const cmd = positionals[0] || 'start'
66
+ if (cmd !== 'start' && cmd !== 'enroll') {
67
+ console.error(`comando desconocido: ${cmd}\n${USAGE}`)
68
+ process.exit(1)
69
+ }
70
+
71
+ /**
72
+ * Enrolamiento: el flujo endurecido del ecosistema, tal cual lo hace la terminal.
73
+ * Este node genera su llave, MUESTRA un código y espera a que lo apruebes tipeando
74
+ * ese código en tu bóveda — así aprobar exige tener delante esta máquina. La clave
75
+ * maestra nunca llega aquí: solo un certificado con fecha de caducidad.
76
+ */
77
+ if (cmd === 'enroll') {
78
+ const pairing = positionals[1]
79
+ if (!pairing) {
80
+ console.error('falta el código de emparejamiento.\n' +
81
+ 'Sácalo de tu bóveda (dotrino-vault pair, o profile.dotrino.com/#myvault) y pásalo aquí:\n' +
82
+ ' dotrino-content enroll <código>')
83
+ process.exit(1)
84
+ }
85
+ // Se enrola como SERVICIO (`ns:content`), que es lo que le da además la llave de
86
+ // CIFRADO a la que el vault le sella sus variables. El enlace del plano de control
87
+ // queda escrito con la MISMA llave: un aparato, una identidad (ver `vaultEnv.js`).
88
+ const { enrollToVault, serviceDir, NS } = await import('../src/vaultEnv.js')
89
+ try {
90
+ const res = await enrollToVault(pairing, {
91
+ onReplace: (prev) => {
92
+ console.log(`\n⚠ este node YA estaba enrolado (aparato ${prev.deviceId}).`)
93
+ console.log(' Se descarta esa identidad; con ella se va su cajón de variables,')
94
+ console.log(' que va indexado por su llave. Si solo querías recargar la')
95
+ console.log(' configuración, NO enroles: reinicia el node.\n')
96
+ },
97
+ onCode: ({ deviceId, code }) => {
98
+ console.log(`\n este node es el aparato ${deviceId}`)
99
+ console.log(` apruébalo en tu bóveda: dotrino-vault approve ${code}\n`)
100
+ console.log(' (el código NO viaja por la red: lo tipeas tú)')
101
+ }
102
+ })
103
+ // El papel ya no vence por tiempo: vale mientras el acta lo diga, y se rehace solo
104
+ // cuando cambias permisos. Decir «válido N días» era describir un reloj que ya no existe.
105
+ console.log(`\nlisto: node enlazado. El permiso vale mientras lo diga tu bóveda; si se lo quitas, deja de valer en el acto.`)
106
+ console.log(`identidad en ${serviceDir()} · enlace en ${linkDir()}`)
107
+ console.log(`\nahora carga su configuración en la bóveda (namespace «${NS}»):`)
108
+ console.log(' dotrino-vault secret set content CONTENT_STORAGE=local --public')
109
+ } catch (e) {
110
+ console.error(`\nno se pudo enlazar: ${e.message}`)
111
+ process.exit(1)
112
+ }
113
+ process.exit(0)
114
+ }
115
+
116
+ const dir = values.dir || process.env.DOTRINO_CONTENT_DIR ||
117
+ path.join(os.homedir(), '.dotrino-content')
118
+ const port = Number(values.port || process.env.PORT || 3777)
119
+ const maxBytes = values['max-gb'] ? Number(values['max-gb']) * 1024 ** 3 : 0
120
+ const maxBlobBytes = values['max-blob-mb'] ? Number(values['max-blob-mb']) * 1024 ** 2 : 0
121
+ const gcMin = Number(values['gc-min'] || 60)
122
+
123
+ // La configuración la sirve el vault (§15.14). Se pide ANTES de levantar nada: de
124
+ // ahí sale `CONTENT_STORAGE` y, con él, qué almacén usa este node. Sin vault esto no
125
+ // hace nada y el node corre en local, que es el modo normal de un autohospedado.
126
+ const { startVaultConfig, isEnrolled } = await import('../src/vaultEnv.js')
127
+ const log = (m) => console.log(m)
128
+ const vaultConfig = startVaultConfig({ log })
129
+ if (!isEnrolled()) console.log('sin vault: configuración local (enrola con: dotrino-content enroll <código>)')
130
+ // Se ESPERA a la configuración antes de montar el almacén, porque de ella sale cuál es
131
+ // (§15.14). Con plazo: si la bóveda no contesta, se arranca con el disco y ya se
132
+ // reiniciará cuando llegue — un node no puede quedarse sin servir por eso.
133
+ await vaultConfig.ready
134
+
135
+ const { openStore } = await import('../src/storage.js')
136
+ const { store, cfg } = await openStore({ dir, log })
137
+
138
+ const node = await new ContentNode({ dir, maxBytes, maxBlobBytes, store, log, publicBase: cfg?.pub ? cfg.baseUrl : null }).init()
139
+ // Lo que quedó sin subir en un arranque anterior. Se lanza y no se espera.
140
+ node.backupPending().then(({ pending }) => { if (pending) log(`[almacén] ${pending} pendiente(s) de subir al bucket`) })
141
+ const server = createServer(node)
142
+
143
+ // GC periódico de vencidos (ttl); el GC por cuota corre inline en cada put.
144
+ const gcTimer = setInterval(() => node.gc(), gcMin * 60_000)
145
+ gcTimer.unref()
146
+ node.gc()
147
+
148
+ // Escuchar SOLO en loopback. La exposición al mundo es la Fase 3 (§7.2) y va con su
149
+ // propia ACL, su límite por IP y su techo de salida: no se activa por descuido.
150
+ server.listen(port, '127.0.0.1', () => {
151
+ const s = node.stats()
152
+ console.log(`dotrino-content en http://127.0.0.1:${port} · datos: ${dir}`)
153
+ console.log(`blobs: ${s.blobs} · bytes: ${s.bytes}${maxBytes ? ` / ${maxBytes}` : ''}`)
154
+ })
155
+
156
+ // Plano de control: solo si este node ya está enlazado a un vault. Sin enlace sigue
157
+ // siendo lo de antes (un node local), y se dice en voz alta para que nadie crea que
158
+ // tiene administración remota cuando no la tiene.
159
+ let agent = null
160
+ if (values['no-agent']) {
161
+ console.log('plano de control: apagado (--no-agent)')
162
+ } else if (isLinked()) {
163
+ try {
164
+ agent = await startContentAgent({ node })
165
+ } catch (e) {
166
+ console.error(`plano de control: no arrancó (${e.message})`)
167
+ }
168
+ } else {
169
+ console.log('plano de control: sin enlace (corre `dotrino-content enroll <código>` para administrarlo desde tus apps)')
170
+ }
171
+
172
+ // Modo público (§7.2). Va DESPUÉS del agente a propósito: el `owner` del node lo
173
+ // resuelve el enlace con el vault, y es la mitad izquierda de la referencia
174
+ // `ownerId + cid` que la tarjeta necesita para armar el enlace "Abrir".
175
+ let publicServer = null
176
+ if (values.public) {
177
+ const maxKb = values['public-max-kb'] !== undefined ? Number(values['public-max-kb']) : DEFAULT_MAX_BYTES / 1024
178
+ const publicPort = Number(values['public-port'] || DEFAULT_PUBLIC_PORT)
179
+ const publicHost = values['public-host'] || '0.0.0.0'
180
+ const egressGb = Number(values['public-egress-gb'] || 0)
181
+ publicServer = createPublicServer(node, {
182
+ maxBytes: maxKb * 1024,
183
+ ratePerMin: Number(values['public-rate'] || DEFAULT_RATE_PER_MIN),
184
+ maxEgressBytes: egressGb ? egressGb * 1024 ** 3 : 0,
185
+ publicUrl: values['public-url'] || null,
186
+ appUrl: values['app-url'] || undefined,
187
+ index: !!values['public-index'],
188
+ owner: node.owner
189
+ })
190
+ publicServer.listen(publicPort, publicHost, () => {
191
+ console.log(`vistas previas públicas en http://${publicHost}:${publicPort} · solo imágenes` +
192
+ `${maxKb ? ` de hasta ${maxKb} KB` : ' (SIN tope de tamaño)'}` +
193
+ `${egressGb ? ` · techo de salida ${egressGb} GB/día` : ''}`)
194
+ if (!node.owner) {
195
+ console.log(' ojo: este node no está enlazado a un vault, así que las tarjetas salen sin enlace "Abrir"')
196
+ }
197
+ if (!maxKb) {
198
+ console.log(' ojo: sin tope de tamaño esto sirve originales, no vistas previas — y el ancho de banda lo pagas tú')
199
+ }
200
+ })
201
+ }
202
+
203
+ const shutdown = () => {
204
+ clearInterval(gcTimer)
205
+ try { agent?.close() } catch (_) {}
206
+ try { publicServer?.close() } catch (_) {}
207
+ server.close(() => { node.close(); process.exit(0) })
208
+ }
209
+ process.on('SIGINT', shutdown)
210
+ process.on('SIGTERM', shutdown)
package/lib/public.js ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * public.js — leer contenido PÚBLICO de otro dueño por la red de Dotrino.
3
+ *
4
+ * Es el camino del tercero (DISENO.md §16): quien recibe una referencia
5
+ * `ownerId + cid` no tiene sesión con ese node ni la va a tener —las sesiones son de
6
+ * los aparatos del acta—. Lo que sí puede hacer es lo mismo que con un teléfono:
7
+ * buscar al dueño en la guía (el canal `content_<owner>` del proxio, §3.1) y
8
+ * pedirle el `cid` en un mensaje suelto. El node contesta SOLO lo marcado público y
9
+ * en claro, y el que pide comprueba que los bytes cuadren con el `cid`: el node no
10
+ * es autoridad de nada, el hash sí.
11
+ *
12
+ * Si el node tiene bucket, contesta además la URL pública (§15.13): es un ATAJO
13
+ * para cargar la imagen con un `<img>` sin mover los bytes por el proxio. Nunca es
14
+ * el enlace —el enlace sigue siendo `app/#owner/cid`— y si la URL falla, se vuelve
15
+ * a pedir por la red.
16
+ *
17
+ * Isomórfico: el `client` es un `WebSocketProxyClient` ya conectado (el que la app
18
+ * ya tiene; no se abre otro). Sin dependencias.
19
+ */
20
+
21
+ import { isValidCid } from './ref.js'
22
+
23
+ /** Nombre del canal de un dueño dentro de un proxio (espejo de src/announce.js). */
24
+ export const channelFor = (nodeId, ownerId) => `${nodeId}/content_${ownerId}`
25
+
26
+ export const MSG = Object.freeze({
27
+ FETCH: 'content.fetch',
28
+ FETCH_OK: 'content.fetch.ok',
29
+ FETCH_ERR: 'content.fetch.err'
30
+ })
31
+
32
+ /** Lo que cabe en un mensaje del proxio: el mismo tope que el plano de control. */
33
+ export const FETCH_MAX_BYTES = 256 * 1024
34
+
35
+ const b64ToBytes = (s) => /** @type {Uint8Array<ArrayBuffer>} */ (Uint8Array.from(atob(s), (c) => c.charCodeAt(0)))
36
+
37
+ async function cidOf (bytes) {
38
+ const digest = await crypto.subtle.digest('SHA-256', bytes)
39
+ return 'sha256-' + [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
40
+ }
41
+
42
+ const err = (code, msg) => Object.assign(new Error(msg), { code })
43
+
44
+ /**
45
+ * Los nodes de un dueño que están en línea ahora mismo, como tokens del proxio.
46
+ * Se mira en todos los proxios de la malla: un canal sin prefijo es local a cada
47
+ * uno, y el node se anuncia en todos. Quitarse a uno mismo evita que una app con
48
+ * node propio se pregunte a sí misma.
49
+ * @param {{ client: any, owner: string }} opts
50
+ * @returns {Promise<string[]>}
51
+ */
52
+ export async function findNodes ({ client, owner }) {
53
+ if (!client || !owner) throw new Error('findNodes: client and owner are required')
54
+ const known = Array.isArray(client.knownNodes) && client.knownNodes.length
55
+ ? client.knownNodes
56
+ : (client.node ? [client.node] : [])
57
+ const out = new Set()
58
+ for (const nodeId of known) {
59
+ try {
60
+ for (const token of await client.list(channelFor(nodeId, owner))) out.add(token)
61
+ } catch (_) { /* un proxio que no contesta no invalida al otro */ }
62
+ }
63
+ if (client.token) out.delete(client.token)
64
+ return [...out]
65
+ }
66
+
67
+ let rid = 0
68
+
69
+ /**
70
+ * Pide un `cid` público a un node concreto (por su token) y espera la respuesta.
71
+ * @returns {Promise<{ cid: string, mime: string, size: number, url: string|null, bytes: Uint8Array<ArrayBuffer>|null }>}
72
+ */
73
+ export async function fetchFrom ({ client, token, cid, head = false, full = false, timeoutMs = 8000 }) {
74
+ const id = `f${++rid}`
75
+ const reply = new Promise((resolve, reject) => {
76
+ const done = (fn, v) => { off(); clearTimeout(t); fn(v) }
77
+ const off = client.on('message', (_from, p) => {
78
+ if (!p || p.rid !== id) return
79
+ if (p.type === MSG.FETCH_OK) done(resolve, p)
80
+ else if (p.type === MSG.FETCH_ERR) done(reject, err(p.code || 'failed', p.error || 'fetch failed'))
81
+ })
82
+ const t = setTimeout(() => done(reject, err('timeout', 'the node did not answer')), timeoutMs)
83
+ })
84
+ client.send(token, { type: MSG.FETCH, rid: id, cid, head: !!head, full: !!full })
85
+ const p = await reply
86
+ let bytes = null
87
+ if (typeof p.data === 'string') {
88
+ bytes = b64ToBytes(p.data)
89
+ // El node no es autoridad: lo que diga que es el cid tiene que SERLO.
90
+ if (await cidOf(bytes) !== cid) throw err('corrupt', 'the bytes do not match the cid')
91
+ }
92
+ return { cid, mime: p.mime || 'application/octet-stream', size: Number(p.size) || (bytes?.length ?? 0), url: p.url || null, bytes }
93
+ }
94
+
95
+ /**
96
+ * Resuelve una referencia pública `ownerId + cid` por la red: encuentra un node
97
+ * vivo del dueño y le pide el blob. Prueba los que haya hasta que uno conteste.
98
+ *
99
+ * @param {{ client: any, owner: string, cid: string, head?: boolean, full?: boolean, timeoutMs?: number }} opts
100
+ * head: solo metadatos y URL, sin bytes.
101
+ * full: los bytes aunque haya URL (por defecto, con URL el node no los manda:
102
+ * cargas por la URL y solo vuelves aquí con `full` si te falló).
103
+ * @throws {Error & {code:'no-node'|'not-found'|'private'|'too-large'|'corrupt'|'timeout'}}
104
+ */
105
+ export async function fetchPublic ({ client, owner, cid, head = false, full = false, timeoutMs = 8000 }) {
106
+ if (!isValidCid(cid)) throw err('bad-input', 'invalid cid')
107
+ const tokens = await findNodes({ client, owner })
108
+ if (!tokens.length) throw err('no-node', 'no node of that owner is online')
109
+ // A TODOS a la vez, y se queda con el primero que conteste bien. En serie, cada
110
+ // token de un node ya apagado —el proxio los conserva un rato— costaba un timeout
111
+ // entero antes de llegar al que sí está vivo. Un `not-found` es una respuesta de
112
+ // verdad (otro node del mismo dueño diría lo mismo) y gana sobre los silencios.
113
+ return new Promise((resolve, reject) => {
114
+ let pending = tokens.length
115
+ let answered = null // el primer error "de verdad" (el node contestó que no)
116
+ let noise = null // timeout / corrupt: no dicen nada del contenido
117
+ for (const token of tokens) {
118
+ fetchFrom({ client, token, cid, head, full, timeoutMs }).then(resolve, (e) => {
119
+ if (e.code && e.code !== 'timeout' && e.code !== 'corrupt') answered = answered || e
120
+ else noise = noise || e
121
+ if (--pending === 0) reject(answered || noise || err('no-node', 'no node answered'))
122
+ })
123
+ }
124
+ })
125
+ }
126
+
127
+ export default { channelFor, findNodes, fetchFrom, fetchPublic, MSG, FETCH_MAX_BYTES }
package/lib/ref.js ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * ref.js — la REFERENCIA compartible (DISENO.md §3 y §7). Isomórfico y sin
3
+ * dependencias: lo usan las apps, el node y las pruebas.
4
+ *
5
+ * Una referencia es `ownerId + cid` y, si el contenido va cifrado, la LLAVE:
6
+ *
7
+ * https://eco.dotrino.com/#<ownerId>/<cid>/<llave>
8
+ * └──────── el #fragment ────────┘
9
+ *
10
+ * Las dos mitades hacen falta y ninguna sobra:
11
+ * - el **`cid`** es el hash del contenido: lo vuelve inmutable, deduplicable y
12
+ * **verificable** (quien lo recibe comprueba que los bytes son los pedidos);
13
+ * - el **`ownerId`** es la huella de la maestra del dueño, y es lo que permite
14
+ * **rutear** (`ownerId` → sus nodes) y comprobar que quien sirvió los bytes es
15
+ * un aparato suyo. Un `cid` suelto es ambiguo: cualquiera podría reclamarlo.
16
+ *
17
+ * **La llave va en el fragmento y por eso NUNCA llega a un servidor**: el
18
+ * navegador no manda el `#` en la petición. Es lo mismo que hace el resto del
19
+ * ecosistema, y es lo que permite compartir un enlace de contenido cifrado sin
20
+ * que quien lo hospeda pueda leerlo.
21
+ */
22
+
23
+ const CID_RE = /^sha256-[0-9a-f]{64}$/
24
+
25
+ /** ¿Tiene forma de cid? (no dice si existe, solo si es un cid) */
26
+ export const isValidCid = (cid) => typeof cid === 'string' && CID_RE.test(cid)
27
+
28
+ /**
29
+ * Arma la parte de fragmento de una referencia (sin el `#`).
30
+ * @param {{ owner: string, cid: string, key?: string|null }} ref
31
+ * @returns {string} `<owner>/<cid>` o `<owner>/<cid>/<llave>`
32
+ */
33
+ export function buildRef ({ owner, cid, key = null }) {
34
+ if (!owner) throw new Error('buildRef: owner is required')
35
+ if (!isValidCid(cid)) throw new Error(`buildRef: invalid cid: ${cid}`)
36
+ return key ? `${owner}/${cid}/${key}` : `${owner}/${cid}`
37
+ }
38
+
39
+ /**
40
+ * Arma el enlace completo hacia una app del ecosistema.
41
+ * @param {{ owner: string, cid: string, key?: string|null }} ref
42
+ * @param {string} [appUrl]
43
+ */
44
+ export function buildUrl (ref, appUrl = 'https://eco.dotrino.com/') {
45
+ return `${appUrl.replace(/\/+$/, '')}/#${buildRef(ref)}`
46
+ }
47
+
48
+ /**
49
+ * Lee una referencia de un fragmento, una URL o la barra de direcciones.
50
+ * Devuelve `null` si eso no es una referencia — que es lo normal: las apps del
51
+ * ecosistema usan el fragmento para muchas cosas (`#room=`, `#vault`…), así que
52
+ * esto tiene que poder decir "no es mío" sin ruido.
53
+ * @param {string} input
54
+ * @returns {{ owner: string, cid: string, key: string|null }|null}
55
+ */
56
+ export function parseRef (input) {
57
+ if (typeof input !== 'string' || !input) return null
58
+ let frag = input
59
+ const hash = frag.indexOf('#')
60
+ if (hash >= 0) frag = frag.slice(hash + 1)
61
+ frag = frag.replace(/^\/+/, '')
62
+ const parts = frag.split('/')
63
+ if (parts.length < 2 || parts.length > 3) return null
64
+ const [owner, cid, key = null] = parts
65
+ if (!owner || !isValidCid(cid)) return null
66
+ return { owner, cid, key: key || null }
67
+ }
68
+
69
+ export default { buildRef, buildUrl, parseRef, isValidCid }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@dotrino/content",
3
+ "version": "0.3.13",
4
+ "description": "Nodo de contenido del ecosistema Dotrino: guarda los bytes del usuario direccionados por su hash (cid), en su propia maquina y, si el dueno quiere, respaldados en su propio bucket (R2, Backblaze, Hetzner, Storj o S3). Lo publico sale por URL directa; lo privado, cifrado y solo por la red Dotrino.",
5
+ "type": "module",
6
+ "bin": {
7
+ "dotrino-content": "bin/cli.js"
8
+ },
9
+ "exports": {
10
+ ".": "./src/index.js"
11
+ },
12
+ "scripts": {
13
+ "start": "node bin/cli.js start",
14
+ "test": "node --test 'test/**/*.test.js'",
15
+ "type-check": "tsc --noEmit && tsc --noEmit -p lib"
16
+ },
17
+ "engines": {
18
+ "node": ">=22.5"
19
+ },
20
+ "license": "MIT",
21
+ "devDependencies": {
22
+ "@types/node": "22.20.1",
23
+ "typescript": "5.9.3"
24
+ },
25
+ "dependencies": {
26
+ "@dotrino/identity": "0.78.0",
27
+ "@dotrino/remote-agent": "0.9.2",
28
+ "@dotrino/vault": "0.52.0"
29
+ },
30
+ "keywords": [
31
+ "dotrino",
32
+ "content-addressed",
33
+ "cid",
34
+ "self-hosted",
35
+ "privacy",
36
+ "s3",
37
+ "r2",
38
+ "blob-storage"
39
+ ],
40
+ "homepage": "https://github.com/imdotrino/dotrino-content#readme",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/imdotrino/dotrino-content.git"
44
+ },
45
+ "bugs": {
46
+ "url": "https://github.com/imdotrino/dotrino-content/issues"
47
+ },
48
+ "author": "Dotrino",
49
+ "files": [
50
+ "bin",
51
+ "src",
52
+ "lib/public.js",
53
+ "lib/ref.js",
54
+ "README.md"
55
+ ],
56
+ "publishConfig": {
57
+ "access": "public"
58
+ }
59
+ }