dotrino-content 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +173 -0
- package/bin/cli.js +203 -0
- package/package.json +57 -0
- package/src/agent.js +104 -0
- package/src/announce.js +132 -0
- package/src/blobstore-s3.js +177 -0
- package/src/blobstore.js +139 -0
- package/src/db.js +237 -0
- package/src/index.js +3 -0
- package/src/node.js +243 -0
- package/src/ops.js +207 -0
- package/src/public.js +370 -0
- package/src/s3.js +257 -0
- package/src/server.js +142 -0
- package/src/storage.js +175 -0
- package/src/vaultEnv.js +144 -0
package/src/node.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ContentNode = BlobStore (bytes) + Index (metadatos) + cuota/GC (Fase 1).
|
|
3
|
+
*
|
|
4
|
+
* Sigue sin saber de red: quien la pone es `agent.js` (Fase 2), que además le dice
|
|
5
|
+
* qué `owner` representa —la huella de la maestra del vault, la mitad izquierda de
|
|
6
|
+
* la referencia compartible `ownerId + cid` (DISENO.md §3)— y usa el `acl` para
|
|
7
|
+
* decidir qué puede salir del node cuando llegue el modo público (§7.2).
|
|
8
|
+
*/
|
|
9
|
+
import { mkdir } from 'node:fs/promises'
|
|
10
|
+
import { BlobStore, isValidCid } from './blobstore.js'
|
|
11
|
+
import { Index } from './db.js'
|
|
12
|
+
|
|
13
|
+
export { isValidCid }
|
|
14
|
+
|
|
15
|
+
export class ContentNode {
|
|
16
|
+
/**
|
|
17
|
+
* @param {{ dir: string, maxBytes?: number, maxBlobBytes?: number, owner?: string|null,
|
|
18
|
+
* store?: any, log?: (m:string)=>void }} opts
|
|
19
|
+
* dir: raíz de datos · maxBytes: cuota total de disco (0 = sin límite)
|
|
20
|
+
* maxBlobBytes: tamaño máximo por blob (0 = sin límite)
|
|
21
|
+
* owner: `ownerId` de la maestra (lo pone el agente al enlazar; null sin vault)
|
|
22
|
+
* store: almacén ya montado (`storage.js`); por defecto, disco
|
|
23
|
+
*/
|
|
24
|
+
constructor (opts) {
|
|
25
|
+
if (!opts?.dir) throw new Error('falta opts.dir')
|
|
26
|
+
this.dir = opts.dir
|
|
27
|
+
this.maxBytes = opts.maxBytes || 0
|
|
28
|
+
this.maxBlobBytes = opts.maxBlobBytes || 0
|
|
29
|
+
this.owner = opts.owner || null
|
|
30
|
+
// El almacén se puede INYECTAR (`storage.js` monta el de bucket cuando toca). Por
|
|
31
|
+
// defecto, disco: sin configuración, un node funciona con lo que hay en la máquina.
|
|
32
|
+
this.store = opts.store || new BlobStore(opts.dir)
|
|
33
|
+
this.index = null
|
|
34
|
+
/** Subidas al bucket en curso, por cid: para no lanzar dos veces la misma. */
|
|
35
|
+
this._uploading = new Map()
|
|
36
|
+
this.log = opts.log || (() => {})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async init () {
|
|
40
|
+
await mkdir(this.dir, { recursive: true })
|
|
41
|
+
await this.store.init()
|
|
42
|
+
this.index = new Index(this.dir)
|
|
43
|
+
return this
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Sube un stream. Si la cuota no alcanza, intenta GC de no-pineados; si aun
|
|
48
|
+
* así no cabe, rechaza con code ENOSPC (no borra pineados jamás).
|
|
49
|
+
*/
|
|
50
|
+
async put (readable, { mime = 'application/octet-stream', enc = 0, ttl = null, acl = null, meta = null } = {}) {
|
|
51
|
+
const { cid, size, existed } = await this.store.put(readable, {
|
|
52
|
+
maxBytes: this.maxBlobBytes || undefined
|
|
53
|
+
})
|
|
54
|
+
if (this.maxBytes && !existed) {
|
|
55
|
+
// La cuota es del DISCO, así que cuenta lo que está cacheado aquí, no el
|
|
56
|
+
// inventario: con bucket detrás, lo desalojado sigue existiendo (§15.11)
|
|
57
|
+
// pero ya no ocupa nada — contarlo dejaría la cuota excedida para siempre.
|
|
58
|
+
const over = (this.index.cachedBytes() + size) - this.maxBytes
|
|
59
|
+
if (over > 0 && this.gc({ needBytes: over }).freed < over) {
|
|
60
|
+
await this.store.remove(cid)
|
|
61
|
+
throw Object.assign(new Error('cuota de disco excedida'), { code: 'ENOSPC' })
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
this.index.upsert({ cid, size, mime, owner: this.owner, enc, acl, ttl, meta })
|
|
65
|
+
// Con bucket detrás, la subida va DESPUÉS de responder: quien sube no espera a la
|
|
66
|
+
// red. Hasta que el bucket confirme, este blob no es desalojable (§15.11).
|
|
67
|
+
if (!existed) this.backup(cid, { size, mime, public: acl === 'public' })
|
|
68
|
+
return { cid, size, mime, existed }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Metadatos de un blob (o null). */
|
|
72
|
+
stat (cid) {
|
|
73
|
+
return isValidCid(cid) ? this.index.get(cid) : null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Marca un blob como público o privado (`acl`). Es lo único que autoriza a que
|
|
78
|
+
* los bytes salgan del node cuando esté encendido el modo público (DISENO.md
|
|
79
|
+
* §7.2); sin `public` explícito, no sale.
|
|
80
|
+
*/
|
|
81
|
+
setAcl (cid, acl) {
|
|
82
|
+
const ok = this.index.setAcl(cid, acl)
|
|
83
|
+
// Publicar mueve los bytes al bucket público, que es OTRO bucket (§15.1). Sin esto
|
|
84
|
+
// el blob quedaría marcado público y sin estar donde se sirve lo público.
|
|
85
|
+
if (ok && acl === 'public' && this.store.backed) {
|
|
86
|
+
const m = this.index.get(cid)
|
|
87
|
+
if (m && !m.enc) this.backup(cid, { size: m.size, mime: m.mime, public: true })
|
|
88
|
+
}
|
|
89
|
+
return ok
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Sube un blob a su bucket y lo marca `remote` **solo cuando el bucket confirma**.
|
|
94
|
+
* Marcar al lanzar la subida es exactamente el error que deja perder contenido: un
|
|
95
|
+
* GC oportuno se llevaría la única copia que existe.
|
|
96
|
+
*
|
|
97
|
+
* No espera nadie a esto (se lanza y se olvida), pero un fallo se REPORTA: lo que no
|
|
98
|
+
* subió sigue siendo la cola pendiente del índice, y se reintenta al arrancar.
|
|
99
|
+
*/
|
|
100
|
+
backup (cid, meta) {
|
|
101
|
+
if (!this.store.backed || this._uploading.has(cid)) return null
|
|
102
|
+
const p = this.store.upload(cid, meta)
|
|
103
|
+
.then(() => { this.index.setRemote(cid, true) })
|
|
104
|
+
.catch((e) => { this.log(`[almacén] no se pudo subir ${cid.slice(0, 14)}…: ${e.message}`) })
|
|
105
|
+
.finally(() => this._uploading.delete(cid))
|
|
106
|
+
this._uploading.set(cid, p)
|
|
107
|
+
return p
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Reintenta lo que quedó sin subir. Se llama al arrancar: un reinicio a media subida
|
|
112
|
+
* no debe perder el pendiente, y por eso la cola es una consulta al índice y no una
|
|
113
|
+
* lista en memoria.
|
|
114
|
+
*/
|
|
115
|
+
async backupPending (limit = 100) {
|
|
116
|
+
if (!this.store.backed) return { pending: 0 }
|
|
117
|
+
const rows = this.index.pendingUpload(limit)
|
|
118
|
+
for (const r of rows) {
|
|
119
|
+
const m = this.index.get(r.cid)
|
|
120
|
+
if (m) await this.backup(r.cid, { size: m.size, mime: m.mime, public: m.acl === 'public' })
|
|
121
|
+
}
|
|
122
|
+
return { pending: rows.length }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Metadatos de presentación (nombre/título/descripción). Es lo único con lo que
|
|
127
|
+
* el permalink público arma su tarjeta (DISENO.md §7.3): sin esto una vista
|
|
128
|
+
* previa solo puede decir el tipo y el tamaño.
|
|
129
|
+
*/
|
|
130
|
+
setMeta (cid, meta) {
|
|
131
|
+
return this.index.setMeta(cid, meta)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Enlaza la miniatura pública de un blob (la genera la app, no el node). */
|
|
135
|
+
setThumbnail (cid, thumbnailCid) {
|
|
136
|
+
return this.index.setThumbnail(cid, thumbnailCid)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Índice de lo servible al mundo (público y en claro). */
|
|
140
|
+
listPublic (opts) {
|
|
141
|
+
return this.index.listPublic(opts)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* ¿Puede este blob salir del node por el HTTP público? Dos condiciones, y las
|
|
146
|
+
* dos se comprueban aquí —en el único sitio por donde salen los bytes— aunque
|
|
147
|
+
* `ops.acl` ya impida marcar público lo cifrado: un índice traído de otra
|
|
148
|
+
* versión, o tocado a mano, no debe poder abrir una puerta.
|
|
149
|
+
* @returns {any|null} los metadatos si es servible, null si no.
|
|
150
|
+
*/
|
|
151
|
+
publicStat (cid) {
|
|
152
|
+
const meta = this.stat(cid)
|
|
153
|
+
if (!meta || meta.acl !== 'public' || meta.enc) return null
|
|
154
|
+
return meta
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* ReadStream del blob; range = { start, end } inclusivo.
|
|
159
|
+
*
|
|
160
|
+
* Anota el acceso (`lastRead`), que es lo que ordena el desalojo de la caché
|
|
161
|
+
* (§15.11): sin esto, el GC tira lo más antiguo, y lo antiguo y muy pedido es
|
|
162
|
+
* justo lo que hay que conservar.
|
|
163
|
+
*/
|
|
164
|
+
read (cid, range) {
|
|
165
|
+
this.index.touch(cid)
|
|
166
|
+
return this.store.read(cid, range)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async remove (cid) {
|
|
170
|
+
await this.store.remove(cid)
|
|
171
|
+
this.index.remove(cid)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
list () {
|
|
175
|
+
return this.index.list()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
pin (cid, pinned = true) {
|
|
179
|
+
return this.index.setPinned(cid, pinned)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
stats () {
|
|
183
|
+
const cached = this.index.cachedBytes()
|
|
184
|
+
return {
|
|
185
|
+
blobs: this.index.count(),
|
|
186
|
+
// `bytes` es lo que ocupa el disco (lo que mira la cuota); `totalBytes` es
|
|
187
|
+
// el inventario entero, que con bucket es mayor porque incluye lo desalojado.
|
|
188
|
+
bytes: cached,
|
|
189
|
+
totalBytes: this.index.totalBytes(),
|
|
190
|
+
maxBytes: this.maxBytes || null,
|
|
191
|
+
backed: this.store.backed === true,
|
|
192
|
+
dir: this.dir
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* GC: borra vencidos (ttl) siempre; si `needBytes`, además desaloja
|
|
198
|
+
* no-pineados más viejos hasta liberar esa cantidad.
|
|
199
|
+
* Síncrono sobre el índice; el borrado de disco es fire-and-forget seguro
|
|
200
|
+
* (los bytes huérfanos se re-borran en el próximo GC vía índice… el índice
|
|
201
|
+
* es la fuente de verdad de qué existe).
|
|
202
|
+
*/
|
|
203
|
+
/**
|
|
204
|
+
* Libera espacio. Hace DOS cosas distintas y conviene no confundirlas (§15.11):
|
|
205
|
+
*
|
|
206
|
+
* · **Caducar** (`ttl` vencido) es borrar de verdad: se va la fila, los bytes
|
|
207
|
+
* locales y —si hay bucket— también los de allí. Es lo que se pidió al subir.
|
|
208
|
+
* · **Desalojar** por cuota es tirar una copia caliente. Con bucket detrás solo
|
|
209
|
+
* toca lo que el bucket YA confirmó (`remote = 1`) y **la fila se queda** con
|
|
210
|
+
* `cached = 0`: si se fuera, el blob quedaría en el bucket sin ACL, sin dueño
|
|
211
|
+
* y sin tipo. Sin bucket no hay segunda copia, así que desalojar ES destruir
|
|
212
|
+
* y se comporta como hasta ahora.
|
|
213
|
+
*/
|
|
214
|
+
gc ({ needBytes = 0, now = Date.now() } = {}) {
|
|
215
|
+
let freed = 0
|
|
216
|
+
const backed = this.store.backed === true
|
|
217
|
+
|
|
218
|
+
const destroy = ({ cid, size }) => {
|
|
219
|
+
this.index.remove(cid)
|
|
220
|
+
this.store.remove(cid).catch(() => {})
|
|
221
|
+
freed += size
|
|
222
|
+
}
|
|
223
|
+
const evict = ({ cid, size }) => {
|
|
224
|
+
if (!backed) return destroy({ cid, size })
|
|
225
|
+
this.store.evict(cid).catch(() => {})
|
|
226
|
+
this.index.setCached(cid, false)
|
|
227
|
+
freed += size
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
for (const b of this.index.expired(now)) destroy(b)
|
|
231
|
+
if (needBytes > freed) {
|
|
232
|
+
for (const b of this.index.evictable({ requireRemote: backed })) {
|
|
233
|
+
if (freed >= needBytes) break
|
|
234
|
+
evict(b)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return { freed }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
close () {
|
|
241
|
+
this.index?.close()
|
|
242
|
+
}
|
|
243
|
+
}
|
package/src/ops.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
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
|
+
function cleanMeta (src) {
|
|
51
|
+
if (!src || typeof src !== 'object') return null
|
|
52
|
+
const out = Object.fromEntries(['name', 'title', 'description']
|
|
53
|
+
.map((k) => [k, typeof src[k] === 'string' ? src[k].trim().slice(0, 300) : null])
|
|
54
|
+
.filter(([, v]) => v))
|
|
55
|
+
return Object.keys(out).length ? out : null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Construye el despachador de operaciones de un node.
|
|
60
|
+
*
|
|
61
|
+
* @param {import('./node.js').ContentNode} node
|
|
62
|
+
* @param {{ owner?: string|null, version?: string }} [meta]
|
|
63
|
+
* owner: `ownerId` (huella de la maestra) que este node representa — es la mitad
|
|
64
|
+
* de la referencia compartible `ownerId + cid` (§3).
|
|
65
|
+
* @returns {(msg: any) => Promise<any>}
|
|
66
|
+
*/
|
|
67
|
+
export function createOps (node, { owner = null, version = null } = {}) {
|
|
68
|
+
/** Operaciones que exigen un `cid` válido y que el blob exista. */
|
|
69
|
+
const withBlob = (fn) => async (msg) => {
|
|
70
|
+
const cid = typeof msg.cid === 'string' ? msg.cid : null
|
|
71
|
+
if (!cid) return fail(msg.rid, 'bad-request', 'cid is required')
|
|
72
|
+
const meta = node.stat(cid)
|
|
73
|
+
if (!meta) return fail(msg.rid, 'not-found', 'no such cid')
|
|
74
|
+
return fn(msg, cid, meta)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const handlers = {
|
|
78
|
+
/** Quién es este node: sirve de saludo y de comprobación de vida. */
|
|
79
|
+
hello: async (msg) => ok(msg.rid, {
|
|
80
|
+
owner, version, stats: node.stats(), maxBytes: CONTROL_PLANE_MAX_BYTES
|
|
81
|
+
}),
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Guardar algo pequeño desde otro aparato tuyo. Los bytes llegan en base64
|
|
85
|
+
* dentro del sobre ya cifrado de la sesión, en UN mensaje: no hay subida por
|
|
86
|
+
* partes y no la va a haber (ver la cabecera del archivo).
|
|
87
|
+
*
|
|
88
|
+
* Que el aparato esté autorizado ya lo comprobó `verifyChain` antes de que
|
|
89
|
+
* esto se llame; aquí solo se comprueba la forma y el tamaño.
|
|
90
|
+
*/
|
|
91
|
+
put: async (msg) => {
|
|
92
|
+
if (typeof msg.data !== 'string') return fail(msg.rid, 'bad-request', 'data (base64) is required')
|
|
93
|
+
let buf
|
|
94
|
+
try { buf = Buffer.from(msg.data, 'base64') } catch { return fail(msg.rid, 'bad-request', 'data is not valid base64') }
|
|
95
|
+
if (!buf.length) return fail(msg.rid, 'bad-request', 'empty payload')
|
|
96
|
+
if (buf.length > CONTROL_PLANE_MAX_BYTES) {
|
|
97
|
+
return fail(msg.rid, 'too-large',
|
|
98
|
+
`the control plane carries up to ${CONTROL_PLANE_MAX_BYTES} bytes; upload larger blobs locally or peer to peer`)
|
|
99
|
+
}
|
|
100
|
+
const enc = msg.enc ? 1 : 0
|
|
101
|
+
// Mismo cerrojo que en `acl`, y por lo mismo: decir que algo cifrado es
|
|
102
|
+
// público solo engaña a quien lo mire, porque nadie sin la llave lo lee.
|
|
103
|
+
const acl = msg.acl === ACL.PUBLIC && !enc ? ACL.PUBLIC : ACL.PRIVATE
|
|
104
|
+
const ttlMs = Number(msg.ttl) || 0
|
|
105
|
+
const out = await node.put(Readable.from(buf), {
|
|
106
|
+
mime: typeof msg.mime === 'string' ? msg.mime : 'application/octet-stream',
|
|
107
|
+
enc,
|
|
108
|
+
acl,
|
|
109
|
+
ttl: ttlMs > 0 ? Date.now() + ttlMs : null,
|
|
110
|
+
meta: cleanMeta(msg.meta)
|
|
111
|
+
})
|
|
112
|
+
return ok(msg.rid, { ...out, acl })
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Leer algo pequeño de vuelta (otro aparato tuyo, o el mismo tras reinstalar).
|
|
117
|
+
* Mismo tope, y por la misma razón: esto es el plano de control.
|
|
118
|
+
*/
|
|
119
|
+
get: withBlob(async (msg, cid, meta) => {
|
|
120
|
+
if (meta.size > CONTROL_PLANE_MAX_BYTES) {
|
|
121
|
+
return fail(msg.rid, 'too-large',
|
|
122
|
+
`${meta.size} bytes do not fit in the control plane; fetch it locally or peer to peer`)
|
|
123
|
+
}
|
|
124
|
+
const chunks = []
|
|
125
|
+
for await (const c of node.read(cid)) chunks.push(c)
|
|
126
|
+
return ok(msg.rid, {
|
|
127
|
+
cid, size: meta.size, mime: meta.mime, enc: meta.enc, data: Buffer.concat(chunks).toString('base64')
|
|
128
|
+
})
|
|
129
|
+
}),
|
|
130
|
+
|
|
131
|
+
list: async (msg) => ok(msg.rid, { blobs: node.list() }),
|
|
132
|
+
|
|
133
|
+
stats: async (msg) => ok(msg.rid, { stats: node.stats() }),
|
|
134
|
+
|
|
135
|
+
stat: withBlob(async (msg, cid, meta) => ok(msg.rid, { blob: meta })),
|
|
136
|
+
|
|
137
|
+
/** Retener: un blob pineado no lo borra el GC ni por cuota ni por ttl. */
|
|
138
|
+
pin: withBlob(async (msg, cid) => ok(msg.rid, { pinned: node.pin(cid, true) })),
|
|
139
|
+
|
|
140
|
+
unpin: withBlob(async (msg, cid) => ok(msg.rid, { pinned: !node.pin(cid, false) })),
|
|
141
|
+
|
|
142
|
+
remove: withBlob(async (msg, cid) => {
|
|
143
|
+
await node.remove(cid)
|
|
144
|
+
return ok(msg.rid, { removed: cid })
|
|
145
|
+
}),
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Marcar un blob como público o privado. Es el interruptor que la Fase 3 mira
|
|
149
|
+
* antes de servir algo por HTTP: sin `public` explícito, no sale del node.
|
|
150
|
+
*/
|
|
151
|
+
acl: withBlob(async (msg, cid) => {
|
|
152
|
+
const acl = msg.acl === ACL.PUBLIC ? ACL.PUBLIC : msg.acl === ACL.PRIVATE ? ACL.PRIVATE : null
|
|
153
|
+
if (!acl) return fail(msg.rid, 'bad-request', `acl must be "${ACL.PUBLIC}" or "${ACL.PRIVATE}"`)
|
|
154
|
+
const meta = node.stat(cid)
|
|
155
|
+
// Un blob cifrado no se puede marcar público: nadie sin la llave podría leerlo,
|
|
156
|
+
// así que decir que lo es solo engaña a quien lo mire.
|
|
157
|
+
if (acl === ACL.PUBLIC && meta.enc) return fail(msg.rid, 'bad-request', 'an encrypted blob cannot be public')
|
|
158
|
+
node.setAcl(cid, acl)
|
|
159
|
+
return ok(msg.rid, { cid, acl })
|
|
160
|
+
}),
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Metadatos de PRESENTACIÓN (nombre, título, descripción). Es lo único con lo
|
|
164
|
+
* que la vista previa pública arma su tarjeta (§7.3): sin esto, una tarjeta
|
|
165
|
+
* solo puede decir el tipo y el tamaño. No forma parte del `cid` —dos nombres
|
|
166
|
+
* para los mismos bytes son el mismo blob—, por eso se pone aparte.
|
|
167
|
+
*/
|
|
168
|
+
meta: withBlob(async (msg, cid) => {
|
|
169
|
+
if (msg.meta !== null && (!msg.meta || typeof msg.meta !== 'object')) {
|
|
170
|
+
return fail(msg.rid, 'bad-request', 'meta must be an object or null')
|
|
171
|
+
}
|
|
172
|
+
const clean = cleanMeta(msg.meta)
|
|
173
|
+
node.setMeta(cid, clean)
|
|
174
|
+
return ok(msg.rid, { cid, meta: clean })
|
|
175
|
+
}),
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Enlaza la miniatura de un blob. La miniatura es OTRO blob (subido por la
|
|
179
|
+
* app) y tiene que ser pública por su cuenta: enlazarla no la publica.
|
|
180
|
+
*/
|
|
181
|
+
thumb: withBlob(async (msg, cid) => {
|
|
182
|
+
const t = msg.thumbnailCid
|
|
183
|
+
if (t !== null && !isValidCid(t)) return fail(msg.rid, 'bad-request', 'thumbnailCid must be a valid cid or null')
|
|
184
|
+
if (t && !node.stat(t)) return fail(msg.rid, 'not-found', 'the thumbnail is not in this node')
|
|
185
|
+
node.setThumbnail(cid, t)
|
|
186
|
+
return ok(msg.rid, { cid, thumbnailCid: t })
|
|
187
|
+
}),
|
|
188
|
+
|
|
189
|
+
/** Recolección de vencidos (lo que el temporizador hace solo, a pedido). */
|
|
190
|
+
gc: async (msg) => ok(msg.rid, node.gc())
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return async function dispatch (msg) {
|
|
194
|
+
if (!msg || typeof msg !== 'object' || typeof msg.op !== 'string') {
|
|
195
|
+
return fail(msg?.rid, 'bad-request', 'op is required')
|
|
196
|
+
}
|
|
197
|
+
const handler = handlers[msg.op]
|
|
198
|
+
if (!handler) return fail(msg.rid, 'unknown-op', `unknown op: ${msg.op}`)
|
|
199
|
+
try {
|
|
200
|
+
return await handler(msg)
|
|
201
|
+
} catch (e) {
|
|
202
|
+
return fail(msg.rid, e?.code === 'ENOSPC' ? 'no-space' : 'failed', e?.message || 'failed')
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export default { createOps, ACL, CONTROL_PLANE_MAX_BYTES }
|