@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/db.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Índice de metadatos (Fase 1, DISENO.md §3): SQLite embebido de Node
|
|
3
|
+
* (`node:sqlite`, sin dependencias). Los bytes viven en el BlobStore;
|
|
4
|
+
* aquí solo `cid, size, mime, createdAt, owner, enc, acl, ttl, pinned…`.
|
|
5
|
+
*/
|
|
6
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
|
|
9
|
+
export class Index {
|
|
10
|
+
/** @param {string} root directorio raíz de datos */
|
|
11
|
+
constructor (root) {
|
|
12
|
+
this.db = new DatabaseSync(path.join(root, 'index.db'))
|
|
13
|
+
this.db.exec(`
|
|
14
|
+
PRAGMA journal_mode = WAL;
|
|
15
|
+
CREATE TABLE IF NOT EXISTS blobs (
|
|
16
|
+
cid TEXT PRIMARY KEY,
|
|
17
|
+
size INTEGER NOT NULL,
|
|
18
|
+
mime TEXT NOT NULL DEFAULT 'application/octet-stream',
|
|
19
|
+
createdAt INTEGER NOT NULL,
|
|
20
|
+
owner TEXT,
|
|
21
|
+
enc INTEGER NOT NULL DEFAULT 0,
|
|
22
|
+
acl TEXT,
|
|
23
|
+
ttl INTEGER, -- epoch ms de expiración (NULL = no expira)
|
|
24
|
+
pinned INTEGER NOT NULL DEFAULT 0,
|
|
25
|
+
thumbnailCid TEXT,
|
|
26
|
+
meta TEXT -- JSON de presentación: { name, title, description }
|
|
27
|
+
);
|
|
28
|
+
CREATE TABLE IF NOT EXISTS egress (
|
|
29
|
+
day TEXT PRIMARY KEY, -- YYYY-MM-DD (UTC)
|
|
30
|
+
bytes INTEGER NOT NULL DEFAULT 0
|
|
31
|
+
);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_blobs_ttl ON blobs (ttl) WHERE ttl IS NOT NULL;
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_blobs_gc ON blobs (pinned, createdAt);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_blobs_acl ON blobs (acl, createdAt);
|
|
35
|
+
`)
|
|
36
|
+
// Migración de un índice creado antes de que existiera `meta`: añadir la
|
|
37
|
+
// columna a una base ya escrita en disco. `ADD COLUMN` con default NULL es
|
|
38
|
+
// barato y no reescribe la tabla; si ya está, SQLite tira y se ignora.
|
|
39
|
+
try { this.db.exec('ALTER TABLE blobs ADD COLUMN meta TEXT') } catch (_) { /* ya existía */ }
|
|
40
|
+
|
|
41
|
+
// Columnas de la CACHÉ (DISENO.md §15.11). Existen aunque no haya bucket: sin
|
|
42
|
+
// él, `remote` se queda en 0 y `cached` en 1 para siempre, y nada cambia.
|
|
43
|
+
//
|
|
44
|
+
// · `remote` — el bucket YA CONFIRMÓ estos bytes. Se pone a 1 con la
|
|
45
|
+
// confirmación, nunca al lanzar la subida: es el cerrojo que
|
|
46
|
+
// impide desalojar algo cuya única copia sigue siendo esta.
|
|
47
|
+
// · `cached` — los bytes están en el disco de esta máquina. Al desalojar se
|
|
48
|
+
// pone a 0 y la FILA SE QUEDA: si se fuera con los bytes, el
|
|
49
|
+
// blob quedaría en el bucket sin dueño, sin ACL y sin tipo.
|
|
50
|
+
// · `lastRead` — para desalojar por último acceso y no por antigüedad. Un
|
|
51
|
+
// almacén ordena por edad; una caché, por uso.
|
|
52
|
+
for (const col of [
|
|
53
|
+
'remote INTEGER NOT NULL DEFAULT 0',
|
|
54
|
+
'cached INTEGER NOT NULL DEFAULT 1',
|
|
55
|
+
'lastRead INTEGER'
|
|
56
|
+
]) {
|
|
57
|
+
try { this.db.exec(`ALTER TABLE blobs ADD COLUMN ${col}`) } catch (_) { /* ya existía */ }
|
|
58
|
+
}
|
|
59
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_blobs_evict ON blobs (pinned, cached, remote, lastRead)')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
upsert ({ cid, size, mime, owner = null, enc = 0, acl = null, ttl = null, meta = null }) {
|
|
63
|
+
this.db.prepare(`
|
|
64
|
+
INSERT INTO blobs (cid, size, mime, createdAt, owner, enc, acl, ttl, meta)
|
|
65
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
66
|
+
ON CONFLICT (cid) DO UPDATE SET
|
|
67
|
+
mime = excluded.mime,
|
|
68
|
+
ttl = excluded.ttl,
|
|
69
|
+
-- Volver a subir los mismos bytes NO borra la presentación que ya tenían.
|
|
70
|
+
meta = COALESCE(excluded.meta, blobs.meta)
|
|
71
|
+
`).run(cid, size, mime, Date.now(), owner, enc ? 1 : 0, acl, ttl, meta ? JSON.stringify(meta) : null)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get (cid) {
|
|
75
|
+
return this.db.prepare('SELECT * FROM blobs WHERE cid = ?').get(cid) ?? null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
list () {
|
|
79
|
+
return this.db.prepare(
|
|
80
|
+
'SELECT cid, size, mime, createdAt, owner, enc, acl, ttl, pinned, meta FROM blobs ORDER BY createdAt DESC'
|
|
81
|
+
).all()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Cambia el `acl` de un blob (`public` | `private`). Devuelve false si no existe.
|
|
86
|
+
* Se cambia aparte del `upsert` a propósito: volver a subir los mismos bytes NO
|
|
87
|
+
* debe reabrir ni cerrar un blob por accidente.
|
|
88
|
+
*/
|
|
89
|
+
setAcl (cid, acl) {
|
|
90
|
+
const { changes } = this.db.prepare('UPDATE blobs SET acl = ? WHERE cid = ?').run(acl, cid)
|
|
91
|
+
return changes > 0
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
remove (cid) {
|
|
95
|
+
this.db.prepare('DELETE FROM blobs WHERE cid = ?').run(cid)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
setPinned (cid, pinned) {
|
|
99
|
+
const { changes } = this.db.prepare('UPDATE blobs SET pinned = ? WHERE cid = ?')
|
|
100
|
+
.run(pinned ? 1 : 0, cid)
|
|
101
|
+
return changes > 0
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Suma de bytes indexados. @returns {number} */
|
|
105
|
+
totalBytes () {
|
|
106
|
+
return Number(this.db.prepare('SELECT COALESCE(SUM(size), 0) AS n FROM blobs').get().n)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** @returns {number} */
|
|
110
|
+
count () {
|
|
111
|
+
return Number(this.db.prepare('SELECT COUNT(*) AS n FROM blobs').get().n)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Blobs con ttl vencido (candidatos a GC incondicional, aunque estén sin pin).
|
|
116
|
+
* @returns {{ cid: string, size: number }[]}
|
|
117
|
+
*/
|
|
118
|
+
expired (now = Date.now()) {
|
|
119
|
+
return /** @type {{ cid: string, size: number }[]} */ (this.db.prepare(
|
|
120
|
+
'SELECT cid, size FROM blobs WHERE ttl IS NOT NULL AND ttl <= ? AND pinned = 0'
|
|
121
|
+
).all(now))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Candidatos a liberar espacio por cuota: no pineados, con bytes en disco, y
|
|
126
|
+
* **el menos usado primero** (DISENO.md §15.11). `COALESCE(lastRead, createdAt)`
|
|
127
|
+
* porque lo que nunca se ha leído cuenta por su fecha de subida.
|
|
128
|
+
*
|
|
129
|
+
* @param {{ requireRemote?: boolean }} [opts] `requireRemote` lo pone el backend
|
|
130
|
+
* con bucket: entonces solo se desaloja lo que el bucket YA confirmó, porque
|
|
131
|
+
* desalojar es tirar una copia caliente y sin la otra copia sería destruir.
|
|
132
|
+
* @returns {{ cid: string, size: number }[]}
|
|
133
|
+
*/
|
|
134
|
+
evictable ({ requireRemote = false } = {}) {
|
|
135
|
+
return /** @type {{ cid: string, size: number }[]} */ (this.db.prepare(`
|
|
136
|
+
SELECT cid, size FROM blobs
|
|
137
|
+
WHERE pinned = 0 AND cached = 1 ${requireRemote ? 'AND remote = 1' : ''}
|
|
138
|
+
ORDER BY COALESCE(lastRead, createdAt) ASC
|
|
139
|
+
`).all())
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Marca que estos bytes ya están confirmados en el bucket. */
|
|
143
|
+
setRemote (cid, remote = true) {
|
|
144
|
+
const { changes } = this.db.prepare('UPDATE blobs SET remote = ? WHERE cid = ?')
|
|
145
|
+
.run(remote ? 1 : 0, cid)
|
|
146
|
+
return changes > 0
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Marca si los bytes están (o ya no) en el disco de esta máquina. */
|
|
150
|
+
setCached (cid, cached = true) {
|
|
151
|
+
const { changes } = this.db.prepare('UPDATE blobs SET cached = ? WHERE cid = ?')
|
|
152
|
+
.run(cached ? 1 : 0, cid)
|
|
153
|
+
return changes > 0
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Anota que este blob se acaba de leer (es lo que ordena el desalojo). */
|
|
157
|
+
touch (cid, now = Date.now()) {
|
|
158
|
+
this.db.prepare('UPDATE blobs SET lastRead = ? WHERE cid = ?').run(now, cid)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Lo que aún NO está confirmado en el bucket: la cola de subida pendiente. Que
|
|
163
|
+
* esto sea una consulta y no una lista en memoria es a propósito — un reinicio
|
|
164
|
+
* a media subida no debe perder el pendiente.
|
|
165
|
+
*/
|
|
166
|
+
pendingUpload (limit = 100) {
|
|
167
|
+
return /** @type {{ cid: string, size: number }[]} */ (this.db.prepare(
|
|
168
|
+
'SELECT cid, size FROM blobs WHERE remote = 0 AND cached = 1 ORDER BY createdAt ASC LIMIT ?'
|
|
169
|
+
).all(limit))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Bytes que ocupan disco AQUÍ (la cuota es de la caché, no del inventario). */
|
|
173
|
+
cachedBytes () {
|
|
174
|
+
return Number(this.db.prepare('SELECT COALESCE(SUM(size), 0) AS n FROM blobs WHERE cached = 1').get().n)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Metadatos de PRESENTACIÓN de un blob (nombre, título, descripción): lo único
|
|
179
|
+
* que la vista previa pública (§7.3) tiene para armar la tarjeta. Se guarda
|
|
180
|
+
* aparte de los bytes porque no forma parte del `cid` — dos nombres distintos
|
|
181
|
+
* para el mismo archivo son el mismo blob.
|
|
182
|
+
* @param {string} cid
|
|
183
|
+
* @param {{name?:string,title?:string,description?:string}|null} meta
|
|
184
|
+
*/
|
|
185
|
+
setMeta (cid, meta) {
|
|
186
|
+
const { changes } = this.db.prepare('UPDATE blobs SET meta = ? WHERE cid = ?')
|
|
187
|
+
.run(meta ? JSON.stringify(meta) : null, cid)
|
|
188
|
+
return changes > 0
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Enlaza la MINIATURA de un blob (otro blob, con su propio `cid`). El node no
|
|
193
|
+
* genera miniaturas: las hace la app al subir —el aparato pone el trabajo, que
|
|
194
|
+
* es el patrón del ecosistema— y aquí solo se anota cuál es.
|
|
195
|
+
*/
|
|
196
|
+
setThumbnail (cid, thumbnailCid) {
|
|
197
|
+
const { changes } = this.db.prepare('UPDATE blobs SET thumbnailCid = ? WHERE cid = ?')
|
|
198
|
+
.run(thumbnailCid, cid)
|
|
199
|
+
return changes > 0
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Blobs servibles al mundo: `acl = public` y EN CLARO. Un blob cifrado no sale
|
|
204
|
+
* por aquí ni marcado público (ops.js ya lo impide al marcarlo, esto es el
|
|
205
|
+
* segundo cerrojo, en el sitio donde los bytes de verdad salen).
|
|
206
|
+
*/
|
|
207
|
+
listPublic ({ limit = 100, offset = 0 } = {}) {
|
|
208
|
+
return this.db.prepare(`
|
|
209
|
+
SELECT cid, size, mime, createdAt, meta FROM blobs
|
|
210
|
+
WHERE acl = 'public' AND enc = 0
|
|
211
|
+
ORDER BY createdAt DESC LIMIT ? OFFSET ?
|
|
212
|
+
`).all(limit, offset)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Contabilidad de EGRESS del modo público (§7.2), por día UTC y persistida: un
|
|
217
|
+
* techo que se reinicia con el proceso no es un techo — el reinicio es
|
|
218
|
+
* exactamente lo que pasa cuando algo se descontrola.
|
|
219
|
+
* @param {number} bytes @param {string} day YYYY-MM-DD
|
|
220
|
+
*/
|
|
221
|
+
addEgress (bytes, day) {
|
|
222
|
+
if (!(bytes > 0)) return
|
|
223
|
+
this.db.prepare(`
|
|
224
|
+
INSERT INTO egress (day, bytes) VALUES (?, ?)
|
|
225
|
+
ON CONFLICT (day) DO UPDATE SET bytes = bytes + excluded.bytes
|
|
226
|
+
`).run(day, Math.round(bytes))
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Bytes servidos ese día. @returns {number} */
|
|
230
|
+
egressOn (day) {
|
|
231
|
+
return Number(this.db.prepare('SELECT COALESCE(bytes, 0) AS n FROM egress WHERE day = ?').get(day)?.n || 0)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
close () {
|
|
235
|
+
this.db.close()
|
|
236
|
+
}
|
|
237
|
+
}
|
package/src/fetch.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetch.js — el node ATIENDE a terceros (DISENO.md §16).
|
|
3
|
+
*
|
|
4
|
+
* Un aparato del acta entra por sesión cifrada (`ops.js`). Cualquier otro —quien
|
|
5
|
+
* recibió un enlace— no tiene sesión ni la tendrá: manda un mensaje suelto por el
|
|
6
|
+
* proxio, `content.fetch { cid }`, y recibe el blob si y solo si está marcado
|
|
7
|
+
* `public` y en claro. Es la misma regla que el modo HTTP (§7.2): sin `public`
|
|
8
|
+
* explícito, no sale nada, y un 'not-found' nunca distingue «no existe» de «no es
|
|
9
|
+
* público» para no confirmar qué guarda el node.
|
|
10
|
+
*
|
|
11
|
+
* Lo que va en la respuesta:
|
|
12
|
+
* - `url`: el atajo del bucket (§15.13) si el node lo tiene y el bucket ya confirmó
|
|
13
|
+
* esos bytes. La app la usa para un `<img>`; si falla, vuelve a pedir por aquí.
|
|
14
|
+
* - `data`: los bytes en base64, salvo `head` o si no caben en un mensaje. Lo que no
|
|
15
|
+
* cabe se sirve solo por URL; sin bucket, 'too-large' — lo grande es P2P (§13).
|
|
16
|
+
*
|
|
17
|
+
* Límite por remitente (token) para que un enlace viral no convierta el node en un
|
|
18
|
+
* CDN por el proxio: el proxio ya tiene el suyo, y este es el del node.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { isValidCid } from '../lib/ref.js'
|
|
22
|
+
import { MSG, FETCH_MAX_BYTES } from '../lib/public.js'
|
|
23
|
+
|
|
24
|
+
export { MSG, FETCH_MAX_BYTES }
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {{ client: any, node: import('./node.js').ContentNode, ratePerMin?: number, quiet?: boolean }} opts
|
|
28
|
+
* client: el `WebSocketProxyClient` YA conectado del agente (no se abre otro).
|
|
29
|
+
* @returns {{ close: () => void, served: () => number }}
|
|
30
|
+
*/
|
|
31
|
+
export function startPublicFetch ({ client, node, ratePerMin = 60, quiet = false }) {
|
|
32
|
+
if (!client || !node) throw new Error('startPublicFetch: client and node are required')
|
|
33
|
+
/** token → { n, windowStart } */
|
|
34
|
+
const buckets = new Map()
|
|
35
|
+
let served = 0
|
|
36
|
+
|
|
37
|
+
const allowed = (from) => {
|
|
38
|
+
const now = Date.now()
|
|
39
|
+
const b = buckets.get(from) || { n: 0, windowStart: now }
|
|
40
|
+
if (now - b.windowStart >= 60_000) { b.n = 0; b.windowStart = now }
|
|
41
|
+
b.n++
|
|
42
|
+
buckets.set(from, b)
|
|
43
|
+
if (buckets.size > 5000) buckets.clear() // que la tabla no crezca sin techo
|
|
44
|
+
return b.n <= ratePerMin
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const send = (to, obj) => { try { client.send(to, obj) } catch (e) { if (!quiet) console.error('[content] fetch reply:', e.message) } }
|
|
48
|
+
const fail = (to, rid, code, error) => send(to, { type: MSG.FETCH_ERR, rid, code, error })
|
|
49
|
+
|
|
50
|
+
const handle = async (from, p) => {
|
|
51
|
+
const rid = typeof p.rid === 'string' ? p.rid : null
|
|
52
|
+
if (!rid) return
|
|
53
|
+
if (!quiet) console.log(`[content] fetch ${String(p.cid).slice(0, 20)}… from ${String(from).slice(0, 16)}${p.head ? ' (head)' : ''}`)
|
|
54
|
+
if (!allowed(from)) return fail(from, rid, 'rate-limited', 'too many requests')
|
|
55
|
+
if (!isValidCid(p.cid)) return fail(from, rid, 'bad-input', 'invalid cid')
|
|
56
|
+
const meta = node.stat(p.cid)
|
|
57
|
+
// Mismo 'not-found' para «no existe» y «no es público»: el mismo motivo que §7.2.
|
|
58
|
+
if (!meta || meta.acl !== 'public' || meta.enc) return fail(from, rid, 'not-found', 'no such public cid')
|
|
59
|
+
const url = node.publicUrl(p.cid)
|
|
60
|
+
const out = { type: MSG.FETCH_OK, rid, cid: p.cid, mime: meta.mime, size: meta.size, url }
|
|
61
|
+
// Con atajo, los bytes NO viajan por el proxio salvo que el que pide insista
|
|
62
|
+
// (`full`): cargar por URL es gratis para el node y para la red, y la app ya
|
|
63
|
+
// sabe volver aquí si la URL le falla. Sin atajo, van en el mensaje si caben.
|
|
64
|
+
if (!p.head && (!url || p.full)) {
|
|
65
|
+
if (meta.size > FETCH_MAX_BYTES) {
|
|
66
|
+
if (!url) return fail(from, rid, 'too-large', `${meta.size} bytes do not fit in a message and this node has no public bucket`)
|
|
67
|
+
// Cabe solo por URL: se contesta sin bytes y la app carga por ahí.
|
|
68
|
+
} else {
|
|
69
|
+
const chunks = []
|
|
70
|
+
for await (const c of node.read(p.cid)) chunks.push(c)
|
|
71
|
+
out.data = Buffer.concat(chunks).toString('base64')
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
served++
|
|
75
|
+
send(from, out)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const off = client.on('message', (from, p) => {
|
|
79
|
+
if (!p || p.type !== MSG.FETCH) return
|
|
80
|
+
handle(from, p).catch((e) => fail(from, p.rid, 'failed', e.message))
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
served: () => served,
|
|
85
|
+
close () { try { off?.() } catch (_) {} }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export default { startPublicFetch, MSG, FETCH_MAX_BYTES }
|
package/src/index.js
ADDED
package/src/node.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
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('opts.dir is required')
|
|
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
|
+
// Base de la URL pública del bucket (§15.13). Sin bucket público es null y lo
|
|
34
|
+
// público viaja por la red, que es el camino que SIEMPRE existe.
|
|
35
|
+
this.publicBase = opts.publicBase || null
|
|
36
|
+
this.index = null
|
|
37
|
+
/** Subidas al bucket en curso, por cid: para no lanzar dos veces la misma. */
|
|
38
|
+
this._uploading = new Map()
|
|
39
|
+
this.log = opts.log || (() => {})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async init () {
|
|
43
|
+
await mkdir(this.dir, { recursive: true })
|
|
44
|
+
await this.store.init()
|
|
45
|
+
this.index = new Index(this.dir)
|
|
46
|
+
return this
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Sube un stream. Si la cuota no alcanza, intenta GC de no-pineados; si aun
|
|
51
|
+
* así no cabe, rechaza con code ENOSPC (no borra pineados jamás).
|
|
52
|
+
*/
|
|
53
|
+
async put (readable, { mime = 'application/octet-stream', enc = 0, ttl = null, acl = null, meta = null } = {}) {
|
|
54
|
+
const { cid, size, existed } = await this.store.put(readable, {
|
|
55
|
+
maxBytes: this.maxBlobBytes || undefined
|
|
56
|
+
})
|
|
57
|
+
if (this.maxBytes && !existed) {
|
|
58
|
+
// La cuota es del DISCO, así que cuenta lo que está cacheado aquí, no el
|
|
59
|
+
// inventario: con bucket detrás, lo desalojado sigue existiendo (§15.11)
|
|
60
|
+
// pero ya no ocupa nada — contarlo dejaría la cuota excedida para siempre.
|
|
61
|
+
const over = (this.index.cachedBytes() + size) - this.maxBytes
|
|
62
|
+
if (over > 0 && this.gc({ needBytes: over }).freed < over) {
|
|
63
|
+
await this.store.remove(cid)
|
|
64
|
+
throw Object.assign(new Error('disk quota exceeded'), { code: 'ENOSPC' })
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
this.index.upsert({ cid, size, mime, owner: this.owner, enc, acl, ttl, meta })
|
|
68
|
+
// Con bucket detrás, la subida va DESPUÉS de responder: quien sube no espera a la
|
|
69
|
+
// red. Hasta que el bucket confirme, este blob no es desalojable (§15.11).
|
|
70
|
+
if (!existed) this.backup(cid, { size, mime, public: acl === 'public' })
|
|
71
|
+
return { cid, size, mime, existed }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Metadatos de un blob (o null). */
|
|
75
|
+
stat (cid) {
|
|
76
|
+
return isValidCid(cid) ? this.index.get(cid) : null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* La URL pública de un blob, o null. Es un ATAJO (§15.13), nunca el enlace: solo
|
|
81
|
+
* existe si hay bucket público, el blob está marcado público y en claro, y el
|
|
82
|
+
* bucket YA confirmó esos bytes (`remote`) — una URL que todavía da 404 no ayuda.
|
|
83
|
+
*/
|
|
84
|
+
publicUrl (cid) {
|
|
85
|
+
if (!this.publicBase || !this.store.backed || typeof this.store.urlFor !== 'function') return null
|
|
86
|
+
const m = this.stat(cid)
|
|
87
|
+
if (!m || m.acl !== 'public' || m.enc || !m.remote) return null
|
|
88
|
+
return this.store.urlFor(cid, this.publicBase)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Marca un blob como público o privado (`acl`). Es lo único que autoriza a que
|
|
93
|
+
* los bytes salgan del node cuando esté encendido el modo público (DISENO.md
|
|
94
|
+
* §7.2); sin `public` explícito, no sale.
|
|
95
|
+
*/
|
|
96
|
+
setAcl (cid, acl) {
|
|
97
|
+
const ok = this.index.setAcl(cid, acl)
|
|
98
|
+
// Publicar mueve los bytes al bucket público, que es OTRO bucket (§15.1). Sin esto
|
|
99
|
+
// el blob quedaría marcado público y sin estar donde se sirve lo público.
|
|
100
|
+
if (ok && acl === 'public' && this.store.backed) {
|
|
101
|
+
const m = this.index.get(cid)
|
|
102
|
+
if (m && !m.enc) this.backup(cid, { size: m.size, mime: m.mime, public: true })
|
|
103
|
+
}
|
|
104
|
+
return ok
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Sube un blob a su bucket y lo marca `remote` **solo cuando el bucket confirma**.
|
|
109
|
+
* Marcar al lanzar la subida es exactamente el error que deja perder contenido: un
|
|
110
|
+
* GC oportuno se llevaría la única copia que existe.
|
|
111
|
+
*
|
|
112
|
+
* No espera nadie a esto (se lanza y se olvida), pero un fallo se REPORTA: lo que no
|
|
113
|
+
* subió sigue siendo la cola pendiente del índice, y se reintenta al arrancar.
|
|
114
|
+
*/
|
|
115
|
+
backup (cid, meta) {
|
|
116
|
+
if (!this.store.backed) return null
|
|
117
|
+
// Si ya hay una subida en curso (la privada, típicamente), esta va DETRÁS, no se
|
|
118
|
+
// descarta: publicar a mitad de la subida tiene que terminar en el bucket público.
|
|
119
|
+
const prev = this._uploading.get(cid)
|
|
120
|
+
const p = (prev ? prev.catch(() => {}) : Promise.resolve())
|
|
121
|
+
.then(() => this.store.upload(cid, meta))
|
|
122
|
+
.then(() => { this.index.setRemote(cid, true) })
|
|
123
|
+
.catch((e) => { this.log(`[storage] could not upload ${cid.slice(0, 14)}…: ${e.message}`) })
|
|
124
|
+
.finally(() => this._uploading.delete(cid))
|
|
125
|
+
this._uploading.set(cid, p)
|
|
126
|
+
return p
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Reintenta lo que quedó sin subir. Se llama al arrancar: un reinicio a media subida
|
|
131
|
+
* no debe perder el pendiente, y por eso la cola es una consulta al índice y no una
|
|
132
|
+
* lista en memoria.
|
|
133
|
+
*/
|
|
134
|
+
async backupPending (limit = 100) {
|
|
135
|
+
if (!this.store.backed) return { pending: 0 }
|
|
136
|
+
const rows = this.index.pendingUpload(limit)
|
|
137
|
+
for (const r of rows) {
|
|
138
|
+
const m = this.index.get(r.cid)
|
|
139
|
+
if (m) await this.backup(r.cid, { size: m.size, mime: m.mime, public: m.acl === 'public' })
|
|
140
|
+
}
|
|
141
|
+
return { pending: rows.length }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Metadatos de presentación (nombre/título/descripción). Es lo único con lo que
|
|
146
|
+
* el permalink público arma su tarjeta (DISENO.md §7.3): sin esto una vista
|
|
147
|
+
* previa solo puede decir el tipo y el tamaño.
|
|
148
|
+
*/
|
|
149
|
+
setMeta (cid, meta) {
|
|
150
|
+
return this.index.setMeta(cid, meta)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Enlaza la miniatura pública de un blob (la genera la app, no el node). */
|
|
154
|
+
setThumbnail (cid, thumbnailCid) {
|
|
155
|
+
return this.index.setThumbnail(cid, thumbnailCid)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Índice de lo servible al mundo (público y en claro). */
|
|
159
|
+
listPublic (opts) {
|
|
160
|
+
return this.index.listPublic(opts)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* ¿Puede este blob salir del node por el HTTP público? Dos condiciones, y las
|
|
165
|
+
* dos se comprueban aquí —en el único sitio por donde salen los bytes— aunque
|
|
166
|
+
* `ops.acl` ya impida marcar público lo cifrado: un índice traído de otra
|
|
167
|
+
* versión, o tocado a mano, no debe poder abrir una puerta.
|
|
168
|
+
* @returns {any|null} los metadatos si es servible, null si no.
|
|
169
|
+
*/
|
|
170
|
+
publicStat (cid) {
|
|
171
|
+
const meta = this.stat(cid)
|
|
172
|
+
if (!meta || meta.acl !== 'public' || meta.enc) return null
|
|
173
|
+
return meta
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* ReadStream del blob; range = { start, end } inclusivo.
|
|
178
|
+
*
|
|
179
|
+
* Anota el acceso (`lastRead`), que es lo que ordena el desalojo de la caché
|
|
180
|
+
* (§15.11): sin esto, el GC tira lo más antiguo, y lo antiguo y muy pedido es
|
|
181
|
+
* justo lo que hay que conservar.
|
|
182
|
+
*/
|
|
183
|
+
read (cid, range) {
|
|
184
|
+
this.index.touch(cid)
|
|
185
|
+
return this.store.read(cid, range)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async remove (cid) {
|
|
189
|
+
await this.store.remove(cid)
|
|
190
|
+
this.index.remove(cid)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
list () {
|
|
194
|
+
return this.index.list()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
pin (cid, pinned = true) {
|
|
198
|
+
return this.index.setPinned(cid, pinned)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
stats () {
|
|
202
|
+
const cached = this.index.cachedBytes()
|
|
203
|
+
return {
|
|
204
|
+
blobs: this.index.count(),
|
|
205
|
+
// `bytes` es lo que ocupa el disco (lo que mira la cuota); `totalBytes` es
|
|
206
|
+
// el inventario entero, que con bucket es mayor porque incluye lo desalojado.
|
|
207
|
+
bytes: cached,
|
|
208
|
+
totalBytes: this.index.totalBytes(),
|
|
209
|
+
maxBytes: this.maxBytes || null,
|
|
210
|
+
backed: this.store.backed === true,
|
|
211
|
+
dir: this.dir
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* GC: borra vencidos (ttl) siempre; si `needBytes`, además desaloja
|
|
217
|
+
* no-pineados más viejos hasta liberar esa cantidad.
|
|
218
|
+
* Síncrono sobre el índice; el borrado de disco es fire-and-forget seguro
|
|
219
|
+
* (los bytes huérfanos se re-borran en el próximo GC vía índice… el índice
|
|
220
|
+
* es la fuente de verdad de qué existe).
|
|
221
|
+
*/
|
|
222
|
+
/**
|
|
223
|
+
* Libera espacio. Hace DOS cosas distintas y conviene no confundirlas (§15.11):
|
|
224
|
+
*
|
|
225
|
+
* · **Caducar** (`ttl` vencido) es borrar de verdad: se va la fila, los bytes
|
|
226
|
+
* locales y —si hay bucket— también los de allí. Es lo que se pidió al subir.
|
|
227
|
+
* · **Desalojar** por cuota es tirar una copia caliente. Con bucket detrás solo
|
|
228
|
+
* toca lo que el bucket YA confirmó (`remote = 1`) y **la fila se queda** con
|
|
229
|
+
* `cached = 0`: si se fuera, el blob quedaría en el bucket sin ACL, sin dueño
|
|
230
|
+
* y sin tipo. Sin bucket no hay segunda copia, así que desalojar ES destruir
|
|
231
|
+
* y se comporta como hasta ahora.
|
|
232
|
+
*/
|
|
233
|
+
gc ({ needBytes = 0, now = Date.now() } = {}) {
|
|
234
|
+
let freed = 0
|
|
235
|
+
const backed = this.store.backed === true
|
|
236
|
+
|
|
237
|
+
const destroy = ({ cid, size }) => {
|
|
238
|
+
this.index.remove(cid)
|
|
239
|
+
this.store.remove(cid).catch(() => {})
|
|
240
|
+
freed += size
|
|
241
|
+
}
|
|
242
|
+
const evict = ({ cid, size }) => {
|
|
243
|
+
if (!backed) return destroy({ cid, size })
|
|
244
|
+
this.store.evict(cid).catch(() => {})
|
|
245
|
+
this.index.setCached(cid, false)
|
|
246
|
+
freed += size
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
for (const b of this.index.expired(now)) destroy(b)
|
|
250
|
+
if (needBytes > freed) {
|
|
251
|
+
for (const b of this.index.evictable({ requireRemote: backed })) {
|
|
252
|
+
if (freed >= needBytes) break
|
|
253
|
+
evict(b)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return { freed }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
close () {
|
|
260
|
+
this.index?.close()
|
|
261
|
+
}
|
|
262
|
+
}
|