@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/agent.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent.js — Fase 2 (DISENO.md §5, §11): este node deja de ser una pieza local y
|
|
3
|
+
* pasa a ser un APARATO DEL VAULT, administrable a distancia desde tus propias apps
|
|
4
|
+
* sin abrir un solo puerto.
|
|
5
|
+
*
|
|
6
|
+
* NO IMPLEMENTA NADA DE ESO: lo hace `@dotrino/remote-agent`, el middleware que ya
|
|
7
|
+
* usan `dotrino-terminal` y `dotrino-ia`. De ahí salen, y por eso no se reescriben
|
|
8
|
+
* aquí: el enrolamiento contra el vault (llave `D` propia + cert `D ← maestra`, la
|
|
9
|
+
* maestra nunca vive aquí), el `identify` firmado en el proxy, el canal cifrado por
|
|
10
|
+
* sesión, el refresco de la lista de revocados, la RENOVACIÓN del cert antes de que
|
|
11
|
+
* venza y el auto-borrado cuando llega un `vault.revoked` firmado por la maestra.
|
|
12
|
+
*
|
|
13
|
+
* Lo único de este archivo es el pegamento: cada payload que llega por una sesión
|
|
14
|
+
* ya autorizada se despacha en `ops.js`, y la respuesta vuelve por el mismo canal
|
|
15
|
+
* cifrado. Autorizar = tener un cert de LA MISMA maestra que este node (eso lo
|
|
16
|
+
* comprueba `verifyChain` dentro del middleware); no hay lista de invitados propia.
|
|
17
|
+
*
|
|
18
|
+
* El `owner` del node se deriva de la maestra a la que está enlazado: es el
|
|
19
|
+
* `ownerId` de la referencia compartible `ownerId + cid` (§3), y se estampa en todo
|
|
20
|
+
* lo que se suba mientras el node esté enlazado.
|
|
21
|
+
*/
|
|
22
|
+
import { pubkeyId } from '@dotrino/identity/capabilities'
|
|
23
|
+
import { startRemoteAgent } from '@dotrino/remote-agent/agent'
|
|
24
|
+
import { dataDir, loadLink } from '@dotrino/remote-agent/link'
|
|
25
|
+
import { startAnnounce } from './announce.js'
|
|
26
|
+
import { startPublicFetch } from './fetch.js'
|
|
27
|
+
import { createOps } from './ops.js'
|
|
28
|
+
|
|
29
|
+
/** Carpeta de datos del enlace (NO es la de los blobs: el enlace es del aparato). */
|
|
30
|
+
export const linkDir = () => process.env.DOTRINO_CONTENT_LINK_DIR || dataDir('dotrino-content')
|
|
31
|
+
|
|
32
|
+
/** ¿Está este node enlazado a un vault? (sin enlace, Fase 1: solo loopback) */
|
|
33
|
+
export const isLinked = (dir = linkDir()) => {
|
|
34
|
+
const link = loadLink(dir)
|
|
35
|
+
return !!(link?.device?.privateJwk && link?.cert && link?.iss)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Arranca el plano de control del node.
|
|
40
|
+
*
|
|
41
|
+
* @param {{
|
|
42
|
+
* node?: import('./node.js').ContentNode,
|
|
43
|
+
* dir?: string, proxyUrl?: string, version?: string|null,
|
|
44
|
+
* client?: any, quiet?: boolean, onRevoked?: () => void, announce?: boolean
|
|
45
|
+
* }} opts
|
|
46
|
+
* client: transporte inyectado — SOLO para pruebas (bus en memoria). En
|
|
47
|
+
* producción lo levanta el middleware con el proxy del ecosistema.
|
|
48
|
+
* announce: publicarse en el canal del dueño para que un TERCERO con el enlace
|
|
49
|
+
* pueda encontrar este node (§3.1). Encendido por defecto; se apaga con `false`
|
|
50
|
+
* en un node que solo quieras usar desde tus propios aparatos.
|
|
51
|
+
* @returns {Promise<{ machine: string, machineId: string, owner: string, close: () => void }>}
|
|
52
|
+
*/
|
|
53
|
+
export async function startContentAgent ({
|
|
54
|
+
node, dir = linkDir(), proxyUrl, version = null, client, quiet = false, onRevoked, announce = true
|
|
55
|
+
} = {}) {
|
|
56
|
+
if (!node) throw new Error('startContentAgent: node is required')
|
|
57
|
+
const link = loadLink(dir)
|
|
58
|
+
if (!link?.iss) {
|
|
59
|
+
throw new Error('this node is not linked to a vault. Run first: dotrino-content enroll <code>')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// El node representa a la maestra que lo certificó: su huella es el `ownerId`.
|
|
63
|
+
// Se resuelve ANTES de abrir el transporte, para que la primera sesión que entre
|
|
64
|
+
// ya encuentre el despachador armado.
|
|
65
|
+
const owner = await pubkeyId(link.iss)
|
|
66
|
+
node.owner = owner
|
|
67
|
+
const dispatch = createOps(node, { owner, version })
|
|
68
|
+
|
|
69
|
+
const agent = await startRemoteAgent({
|
|
70
|
+
dir,
|
|
71
|
+
proxyUrl,
|
|
72
|
+
client,
|
|
73
|
+
quiet,
|
|
74
|
+
onRevoked,
|
|
75
|
+
onSession: (session) => {
|
|
76
|
+
session.on('message', (msg) => {
|
|
77
|
+
dispatch(msg)
|
|
78
|
+
.then((reply) => session.send(reply))
|
|
79
|
+
.catch((e) => session.send({ rid: msg?.rid, ok: false, code: 'failed', error: e.message }))
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
if (!quiet) console.log(`[content] control plane ready · owner ${owner.slice(0, 16)}`)
|
|
85
|
+
|
|
86
|
+
// ANUNCIO (§3.1): sin esto el node es administrable por sus dueños pero
|
|
87
|
+
// INVISIBLE para quien recibe un enlace — la referencia nombra al dueño, y hay
|
|
88
|
+
// que poder pasar de ahí a «qué nodes suyos están vivos». Reusa la conexión del
|
|
89
|
+
// middleware: nunca se abre una segunda.
|
|
90
|
+
const beacon = announce && agent.client
|
|
91
|
+
? startAnnounce({ client: agent.client, owner, quiet })
|
|
92
|
+
: null
|
|
93
|
+
// LECTURA PÚBLICA por la red (§16): un tercero con el enlace pide el `cid` en un
|
|
94
|
+
// mensaje suelto y recibe solo lo marcado público. Sin esto, anunciarse no sirve
|
|
95
|
+
// de nada: el enlace nombraría a un node que no contesta a nadie.
|
|
96
|
+
const fetcher = agent.client ? startPublicFetch({ client: agent.client, node, quiet }) : null
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
...agent,
|
|
100
|
+
owner,
|
|
101
|
+
channels: () => beacon?.channels() || [],
|
|
102
|
+
served: () => fetcher?.served() || 0,
|
|
103
|
+
close () {
|
|
104
|
+
try { fetcher?.close() } catch (_) {}
|
|
105
|
+
try { beacon?.close() } catch (_) {}
|
|
106
|
+
agent.close()
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export default { startContentAgent, isLinked, linkDir }
|
package/src/announce.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* announce.js — ANUNCIO del node (DISENO.md §3.1). Es lo que hace que se sepa
|
|
3
|
+
* **dónde** está el contenido de un dueño.
|
|
4
|
+
*
|
|
5
|
+
* La referencia compartible nombra al DUEÑO, no a la máquina (`ownerId + cid`), así
|
|
6
|
+
* que resolverla es contestar «¿qué nodes de este dueño están vivos ahora mismo?».
|
|
7
|
+
* Hay dos caminos, según quién pregunte, y este archivo es el segundo:
|
|
8
|
+
*
|
|
9
|
+
* - **El dueño** pregunta a su bóveda (`listAgentsByLabel(id, 'content')`): sus
|
|
10
|
+
* aparatos y sus direcciones. No necesita ningún anuncio.
|
|
11
|
+
* - **Un tercero con el enlace** no puede consultar la bóveda de nadie — ni debe.
|
|
12
|
+
* Para él, el node se publica en un **canal del proxy** y cualquiera lista quién
|
|
13
|
+
* está dentro. Eso es esto.
|
|
14
|
+
*
|
|
15
|
+
* **El nombre del canal lleva el `ownerId`, no el `cid`.** Un canal por contenido
|
|
16
|
+
* filtraría qué guarda cada quien con solo mirar la lista de canales; y además
|
|
17
|
+
* serían miles. Con uno por dueño, lo que se sabe al listar es «este dueño tiene
|
|
18
|
+
* nodes en línea», que es justo lo que hace falta para pedirle algo.
|
|
19
|
+
*
|
|
20
|
+
* **Por qué el canal va con id de proxio delante.** Hay dos proxios federados y un
|
|
21
|
+
* canal SIN prefijo es local a cada uno: el node anunciado en uno sería invisible
|
|
22
|
+
* para quien esté conectado al otro. Se publica en el canal de **cada nodo
|
|
23
|
+
* conocido** y se lista en todos, en vez de elegir uno "dueño" por una fórmula: la
|
|
24
|
+
* lista de nodos de la malla cambia cuando entra o sale un proxio, y una fórmula
|
|
25
|
+
* que dependa de ella reasigna todos los canales el día que eso pase. Son dos
|
|
26
|
+
* canales hoy; publicar en los dos cuesta dos mensajes y no se rompe nunca.
|
|
27
|
+
*
|
|
28
|
+
* **Anunciarse NO da acceso a nada.** Es una guía de teléfonos: dice a quién
|
|
29
|
+
* llamar. Quién puede pedir qué lo siguen decidiendo la ACL y el certificado, en
|
|
30
|
+
* el sitio de siempre.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { channelFor } from '../lib/public.js'
|
|
34
|
+
|
|
35
|
+
/** Nombre del canal de un dueño dentro de un proxio concreto (vive en la lib: el cliente del tercero lo necesita igual). */
|
|
36
|
+
export { channelFor }
|
|
37
|
+
|
|
38
|
+
/** Cada cuánto se re-publica (el proxio caduca las entradas de canal). */
|
|
39
|
+
export const REPUBLISH_MS = 4 * 60 * 1000
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Publica este node en el canal de su dueño, en todos los proxios conocidos, y lo
|
|
43
|
+
* mantiene publicado. Best-effort a propósito: si el proxy está caído, el node
|
|
44
|
+
* sigue funcionando —sirve en local y atiende a los aparatos del acta— y el
|
|
45
|
+
* siguiente tic lo vuelve a intentar. Un anuncio perdido no rompe nada; lo único
|
|
46
|
+
* que pasa es que un tercero no lo encuentra hasta que vuelva.
|
|
47
|
+
*
|
|
48
|
+
* @param {{ client: any, owner: string, quiet?: boolean, intervalMs?: number }} opts
|
|
49
|
+
* client: el `WebSocketProxyClient` YA conectado del agente (`ra.client`) — no se
|
|
50
|
+
* abre otro: dos conexiones del mismo aparato son dos identidades de transporte.
|
|
51
|
+
* @returns {{ channels: () => string[], close: () => void }}
|
|
52
|
+
*/
|
|
53
|
+
export function startAnnounce ({ client, owner, quiet = false, intervalMs = REPUBLISH_MS }) {
|
|
54
|
+
if (!client) throw new Error('startAnnounce: client is required')
|
|
55
|
+
if (!owner) throw new Error('startAnnounce: owner is required')
|
|
56
|
+
|
|
57
|
+
let stopped = false
|
|
58
|
+
let current = []
|
|
59
|
+
|
|
60
|
+
/** Los proxios donde hay que anunciarse: el que nos atiende y los que conoce. */
|
|
61
|
+
const targets = () => {
|
|
62
|
+
const known = Array.isArray(client.knownNodes) ? client.knownNodes : []
|
|
63
|
+
return known.length ? known : (client.node ? [client.node] : [])
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function publishAll () {
|
|
67
|
+
if (stopped) return
|
|
68
|
+
const names = targets().map((n) => channelFor(n, owner))
|
|
69
|
+
if (!names.length) return // aún sin `connected`: el próximo tic
|
|
70
|
+
const done = []
|
|
71
|
+
for (const name of names) {
|
|
72
|
+
try {
|
|
73
|
+
await client.publish(name, { app: 'content', owner })
|
|
74
|
+
done.push(name)
|
|
75
|
+
} catch (e) {
|
|
76
|
+
if (!quiet) console.error(`[content] could not announce on ${name}: ${e.message}`)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Se avisa cuando CAMBIA en cuántos proxios está anunciado, no solo la primera vez.
|
|
80
|
+
// Antes solo hablaba al pasar de 0 a algo, así que un arranque que solo alcanzó uno de
|
|
81
|
+
// los dos decía «en 1 proxio(s)» y se callaba para siempre — aunque el tic siguiente ya
|
|
82
|
+
// estuviera en los dos. Leyendo ese log parecía una federación rota que no lo estaba.
|
|
83
|
+
const changed = done.length !== current.length
|
|
84
|
+
current = done
|
|
85
|
+
if (changed && !quiet) console.log(`[content] announced as node for ${owner.slice(0, 16)} on ${done.length} proxio(s) · token ${client.token}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
publishAll()
|
|
89
|
+
// Re-publicar al reconectar: el token cambia y el anuncio viejo apunta a una
|
|
90
|
+
// conexión que ya no existe. Sin esto, un corte de red deja al node listado y
|
|
91
|
+
// mudo, que es peor que no estar listado.
|
|
92
|
+
const offToken = client.on('token', () => { current = []; publishAll() })
|
|
93
|
+
const timer = setInterval(publishAll, intervalMs)
|
|
94
|
+
timer.unref?.()
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
channels: () => [...current],
|
|
98
|
+
close () {
|
|
99
|
+
stopped = true
|
|
100
|
+
clearInterval(timer)
|
|
101
|
+
try { offToken?.() } catch (_) {}
|
|
102
|
+
for (const name of current) { try { client.unpublish(name) } catch (_) {} }
|
|
103
|
+
current = []
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Lado del que BUSCA: los tokens de los nodes de un dueño que están en línea, en
|
|
110
|
+
* todos los proxios conocidos, sin repetidos.
|
|
111
|
+
*
|
|
112
|
+
* Lo que devuelve son **candidatos, no autoridades**: cualquiera puede publicarse
|
|
113
|
+
* en el canal de cualquier dueño (el nombre del canal no es un secreto). Quien
|
|
114
|
+
* pregunte tiene que comprobar las dos cosas de siempre — que el node presenta un
|
|
115
|
+
* certificado que encadena a ese `ownerId`, y que los bytes que entrega hashean al
|
|
116
|
+
* `cid` pedido. Con esas dos, un impostor en la lista no consigue nada más que
|
|
117
|
+
* gastar un intento.
|
|
118
|
+
*
|
|
119
|
+
* @param {{ client: any, owner: string }} opts
|
|
120
|
+
* @returns {Promise<string[]>} tokens (direcciones en el proxy)
|
|
121
|
+
*/
|
|
122
|
+
export async function findNodes ({ client, owner }) {
|
|
123
|
+
if (!client || !owner) throw new Error('findNodes: client and owner are required')
|
|
124
|
+
const known = Array.isArray(client.knownNodes) && client.knownNodes.length
|
|
125
|
+
? client.knownNodes
|
|
126
|
+
: (client.node ? [client.node] : [])
|
|
127
|
+
const out = new Set()
|
|
128
|
+
for (const nodeId of known) {
|
|
129
|
+
try {
|
|
130
|
+
for (const token of await client.list(channelFor(nodeId, owner))) out.add(token)
|
|
131
|
+
} catch (_) { /* un proxio que no contesta no invalida al otro */ }
|
|
132
|
+
}
|
|
133
|
+
// El propio que pregunta puede estar en la lista (una PWA que también es node).
|
|
134
|
+
if (client.token) out.delete(client.token)
|
|
135
|
+
return [...out]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export default { startAnnounce, findNodes, channelFor, REPUBLISH_MS }
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* El almacén con BUCKET detrás (DISENO.md §15).
|
|
3
|
+
*
|
|
4
|
+
* Misma interfaz que `BlobStore` —`put`, `read`, `sizeOf`, `remove`— para que ni la API
|
|
5
|
+
* local, ni el plano de control, ni el puerto de vistas previas se enteren de dónde
|
|
6
|
+
* salen los bytes. Lo que cambia es de dónde salen:
|
|
7
|
+
*
|
|
8
|
+
* disco (caché) → si está, sale de aquí, y así es como se sirve casi siempre
|
|
9
|
+
* bucket → si no, se jala mientras se sirve y se deja la copia
|
|
10
|
+
*
|
|
11
|
+
* Tres cosas que no son detalles:
|
|
12
|
+
*
|
|
13
|
+
* · **Se escribe primero en local y se responde.** La subida al bucket va detrás, y
|
|
14
|
+
* hasta que el bucket confirma, ese blob NO es desalojable (`remote`, §15.11). Un
|
|
15
|
+
* pendiente que no logra subir se reintenta y se reporta; no se olvida.
|
|
16
|
+
* · **Público y privado son buckets distintos** y con credenciales distintas (§15.1).
|
|
17
|
+
* Cuál toca lo dice el ACL del blob, y quien lo sabe es el índice — por eso `put` y
|
|
18
|
+
* `upload` reciben `isPublic` en vez de adivinarlo.
|
|
19
|
+
* · **Una lectura parcial NO puebla la caché.** Cachear trozos sueltos dejaría
|
|
20
|
+
* agujeros que luego parecen un blob completo.
|
|
21
|
+
*/
|
|
22
|
+
import { createWriteStream } from 'node:fs'
|
|
23
|
+
import { mkdir, rename, rm } from 'node:fs/promises'
|
|
24
|
+
import path from 'node:path'
|
|
25
|
+
import { Readable } from 'node:stream'
|
|
26
|
+
import { pipeline } from 'node:stream/promises'
|
|
27
|
+
import { PassThrough } from 'node:stream'
|
|
28
|
+
import { BlobStore, isValidCid } from './blobstore.js'
|
|
29
|
+
import { S3Bucket, S3Error } from './s3.js'
|
|
30
|
+
|
|
31
|
+
/** Lo que se le pone a un objeto público: es inmutable por construcción (§15.2). */
|
|
32
|
+
const IMMUTABLE = 'public, max-age=31536000, immutable'
|
|
33
|
+
|
|
34
|
+
/** Del `cid` a la clave del objeto. Sin prefijos: el bucket ya separa público de privado. */
|
|
35
|
+
const keyOf = (cid) => {
|
|
36
|
+
if (!isValidCid(cid)) throw new Error(`invalid cid: ${cid}`)
|
|
37
|
+
return cid
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class S3BlobStore {
|
|
41
|
+
/**
|
|
42
|
+
* @param {object} o
|
|
43
|
+
* @param {string} o.root directorio de la CACHÉ (el mismo árbol `blobs/` de siempre)
|
|
44
|
+
* @param {S3Bucket} o.priv bucket privado (obligatorio: es donde va todo lo cifrado)
|
|
45
|
+
* @param {S3Bucket|null} [o.pub] bucket público; sin él, lo público viaja por la red (§15.3)
|
|
46
|
+
* @param {(m:string)=>void} [o.log]
|
|
47
|
+
*/
|
|
48
|
+
constructor ({ root, priv, pub = null, log = () => {} }) {
|
|
49
|
+
if (!priv) throw new Error('S3BlobStore: the private bucket is missing')
|
|
50
|
+
this.cache = new BlobStore(root)
|
|
51
|
+
this.priv = priv
|
|
52
|
+
this.pub = pub
|
|
53
|
+
this.log = log
|
|
54
|
+
/** Hay una segunda copia detrás: el GC puede desalojar en vez de destruir (§15.11). */
|
|
55
|
+
this.backed = true
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async init () {
|
|
59
|
+
await this.cache.init()
|
|
60
|
+
return this
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** El bucket que le toca a un blob según su ACL. */
|
|
64
|
+
bucketFor (isPublic) {
|
|
65
|
+
if (!isPublic) return this.priv
|
|
66
|
+
if (!this.pub) throw new Error('this node has no public bucket (CONTENT_S3_BUCKET_PUBLIC)')
|
|
67
|
+
return this.pub
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** La URL pública de un blob, o `null` si este node no publica por bucket. */
|
|
71
|
+
urlFor (cid, baseUrl) {
|
|
72
|
+
return baseUrl ? `${String(baseUrl).replace(/\/+$/, '')}/${keyOf(cid)}` : null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
pathFor (cid) { return this.cache.pathFor(cid) }
|
|
76
|
+
|
|
77
|
+
/** Guarda en la caché. La subida al bucket la lanza el node después (`upload`). */
|
|
78
|
+
async put (readable, opts) {
|
|
79
|
+
return this.cache.put(readable, opts)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Sube al bucket que le toca y **confirma**. Quien llama es el único que puede marcar
|
|
84
|
+
* `remote`, y solo con lo que devuelve esto: marcar al lanzar la subida es
|
|
85
|
+
* exactamente el error que deja perder contenido.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} cid
|
|
88
|
+
* @param {{ size: number, mime?: string, public?: boolean }} meta
|
|
89
|
+
*/
|
|
90
|
+
async upload (cid, { size, mime, public: isPublic = false }) {
|
|
91
|
+
const bucket = this.bucketFor(isPublic)
|
|
92
|
+
const body = this.cache.read(cid)
|
|
93
|
+
await bucket.put(keyOf(cid), Readable.toWeb(body), {
|
|
94
|
+
sha256: cid.slice('sha256-'.length),
|
|
95
|
+
size,
|
|
96
|
+
contentType: mime || 'application/octet-stream',
|
|
97
|
+
// Solo lo público lleva cabecera de caché: es lo único que sirve un CDN.
|
|
98
|
+
...(isPublic ? { cacheControl: IMMUTABLE } : {})
|
|
99
|
+
})
|
|
100
|
+
return { cid, remote: true }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** ¿Está en la caché de esta máquina? (no pregunta al bucket: eso cuesta una petición) */
|
|
104
|
+
async sizeOf (cid) {
|
|
105
|
+
return this.cache.sizeOf(cid)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Lee. Si está en la caché sale de ahí; si no, se jala del bucket **mientras se
|
|
110
|
+
* sirve** y se deja la copia — salvo que sea una lectura parcial.
|
|
111
|
+
*
|
|
112
|
+
* Devuelve un stream, como el de disco, para que quien llama no cambie.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} cid
|
|
115
|
+
* @param {{ start: number, end: number }} [range] inclusivo
|
|
116
|
+
* @param {{ public?: boolean }} [opts] de qué bucket, si hay que ir a buscarlo
|
|
117
|
+
*/
|
|
118
|
+
read (cid, range, { public: isPublic = false } = {}) {
|
|
119
|
+
const out = new PassThrough()
|
|
120
|
+
this.cache.sizeOf(cid).then(async (size) => {
|
|
121
|
+
if (size !== null) return pipeline(this.cache.read(cid, range), out)
|
|
122
|
+
|
|
123
|
+
const bucket = this.bucketFor(isPublic)
|
|
124
|
+
const { body } = await bucket.get(keyOf(cid), range || null)
|
|
125
|
+
const src = Readable.fromWeb(/** @type {any} */ (body))
|
|
126
|
+
|
|
127
|
+
// Parcial: se sirve y se olvida. Guardar un trozo dejaría en la caché algo que
|
|
128
|
+
// parece el blob entero y no lo es.
|
|
129
|
+
if (range) { src.pipe(out); return }
|
|
130
|
+
|
|
131
|
+
// Entero: el mismo stream va a DOS destinos —quien pidió y el disco—, y solo al
|
|
132
|
+
// terminar bien se pone en su sitio. Si la descarga se corta, no queda medio blob
|
|
133
|
+
// con el nombre bueno, que luego se serviría como si estuviera completo.
|
|
134
|
+
const dest = this.cache.pathFor(cid)
|
|
135
|
+
const tmp = dest + '.dl'
|
|
136
|
+
await mkdir(path.dirname(dest), { recursive: true })
|
|
137
|
+
const disk = createWriteStream(tmp)
|
|
138
|
+
src.pipe(out)
|
|
139
|
+
src.pipe(disk)
|
|
140
|
+
disk.on('finish', () => { rename(tmp, dest).catch(() => rm(tmp, { force: true })) })
|
|
141
|
+
src.on('error', () => { disk.destroy(); rm(tmp, { force: true }).catch(() => {}) })
|
|
142
|
+
}).catch((e) => out.destroy(e))
|
|
143
|
+
return out
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Los primeros `n` bytes. Es lo que necesita el puerto público para comprobar el tipo
|
|
148
|
+
* REAL de una imagen por sus bytes mágicos (§7.2) — y existe en la interfaz porque
|
|
149
|
+
* con bucket detrás no hay ninguna ruta de disco que abrir.
|
|
150
|
+
*/
|
|
151
|
+
async readHead (cid, n, { public: isPublic = false } = {}) {
|
|
152
|
+
const local = await this.cache.sizeOf(cid)
|
|
153
|
+
if (local !== null) return this.cache.readHead(cid, n)
|
|
154
|
+
return this.bucketFor(isPublic).head(keyOf(cid), n)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Suelta la copia local y deja la del bucket (desalojar, §15.11). */
|
|
158
|
+
async evict (cid) {
|
|
159
|
+
await this.cache.remove(cid)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Borra de verdad: de la caché y del bucket. Caducar y despublicar pasan por aquí. */
|
|
163
|
+
async remove (cid, { public: isPublic = null } = {}) {
|
|
164
|
+
await this.cache.remove(cid)
|
|
165
|
+
// Sin saber su ACL se borra de los dos: es idempotente y borrar lo que no está no
|
|
166
|
+
// es un error. Más vale una petición de más que dejar un objeto huérfano pagando.
|
|
167
|
+
const buckets = isPublic === null ? [this.priv, this.pub] : [this.bucketFor(isPublic)]
|
|
168
|
+
for (const b of buckets) {
|
|
169
|
+
if (!b) continue
|
|
170
|
+
try { await b.remove(keyOf(cid)) } catch (e) {
|
|
171
|
+
if (!(e instanceof S3Error) || e.status !== 404) throw e
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export default { S3BlobStore }
|
package/src/blobstore.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Almacén de blobs direccionado por contenido (Fase 1, DISENO.md §3).
|
|
3
|
+
*
|
|
4
|
+
* - `cid = sha256-<hex>` (prefijo de algoritmo → extensible a blake3 después).
|
|
5
|
+
* Se usa SHA-256 de `node:crypto` porque no requiere dependencias nativas
|
|
6
|
+
* (el `.npmrc` del ecosistema bloquea los build scripts de npm).
|
|
7
|
+
* - Disco: `blobs/<aa>/<bb>/<cid>` (sharding por los 4 primeros hex del hash).
|
|
8
|
+
* - Escritura por streaming: se hashea MIENTRAS se escribe a un tmp y al final
|
|
9
|
+
* se renombra al path definitivo (dedup gratis: si ya existe, se descarta).
|
|
10
|
+
*/
|
|
11
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
12
|
+
import { createWriteStream, createReadStream } from 'node:fs'
|
|
13
|
+
import { mkdir, open, rename, rm, stat } from 'node:fs/promises'
|
|
14
|
+
import { pipeline } from 'node:stream/promises'
|
|
15
|
+
import { Transform } from 'node:stream'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
|
|
18
|
+
const CID_RE = /^sha256-[0-9a-f]{64}$/
|
|
19
|
+
|
|
20
|
+
export function isValidCid (cid) {
|
|
21
|
+
return typeof cid === 'string' && CID_RE.test(cid)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class BlobStore {
|
|
25
|
+
/** @param {string} root directorio raíz de datos (contiene blobs/ y tmp/) */
|
|
26
|
+
constructor (root) {
|
|
27
|
+
this.root = root
|
|
28
|
+
this.blobsDir = path.join(root, 'blobs')
|
|
29
|
+
this.tmpDir = path.join(root, 'tmp')
|
|
30
|
+
/**
|
|
31
|
+
* ¿Hay una segunda copia detrás (un bucket)? Aquí no: el disco es la única
|
|
32
|
+
* copia, así que soltar un blob es destruirlo y el GC se comporta como
|
|
33
|
+
* siempre (DISENO.md §15.11).
|
|
34
|
+
*/
|
|
35
|
+
this.backed = false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Prepara los directorios. Devuelve `this` para poder encadenar, como el resto. */
|
|
39
|
+
async init () {
|
|
40
|
+
await mkdir(this.blobsDir, { recursive: true })
|
|
41
|
+
await mkdir(this.tmpDir, { recursive: true })
|
|
42
|
+
return this
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Path en disco de un cid (no comprueba existencia). */
|
|
46
|
+
pathFor (cid) {
|
|
47
|
+
if (!isValidCid(cid)) throw new Error(`invalid cid: ${cid}`)
|
|
48
|
+
const hex = cid.slice('sha256-'.length)
|
|
49
|
+
return path.join(this.blobsDir, hex.slice(0, 2), hex.slice(2, 4), cid)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** ¿Existe el blob en disco? → size o null. */
|
|
53
|
+
async sizeOf (cid) {
|
|
54
|
+
try {
|
|
55
|
+
const st = await stat(this.pathFor(cid))
|
|
56
|
+
return st.size
|
|
57
|
+
} catch {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Guarda un stream hasheando al vuelo.
|
|
64
|
+
* @param {import('node:stream').Readable} readable
|
|
65
|
+
* @param {{ maxBytes?: number }} [opts] límite duro de tamaño (corta el stream)
|
|
66
|
+
* @returns {Promise<{ cid: string, size: number, existed: boolean }>}
|
|
67
|
+
*/
|
|
68
|
+
async put (readable, opts = {}) {
|
|
69
|
+
const tmp = path.join(this.tmpDir, `up-${randomBytes(8).toString('hex')}`)
|
|
70
|
+
const hash = createHash('sha256')
|
|
71
|
+
let size = 0
|
|
72
|
+
const meter = new Transform({
|
|
73
|
+
transform (chunk, _enc, cb) {
|
|
74
|
+
size += chunk.length
|
|
75
|
+
if (opts.maxBytes && size > opts.maxBytes) {
|
|
76
|
+
cb(Object.assign(new Error('blob too large'), { code: 'ETOOBIG' }))
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
hash.update(chunk)
|
|
80
|
+
cb(null, chunk)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
try {
|
|
84
|
+
await pipeline(readable, meter, createWriteStream(tmp))
|
|
85
|
+
const cid = `sha256-${hash.digest('hex')}`
|
|
86
|
+
const dest = this.pathFor(cid)
|
|
87
|
+
if (await this.sizeOf(cid) !== null) {
|
|
88
|
+
// dedup: ya lo teníamos, descartar el tmp
|
|
89
|
+
await rm(tmp, { force: true })
|
|
90
|
+
return { cid, size, existed: true }
|
|
91
|
+
}
|
|
92
|
+
await mkdir(path.dirname(dest), { recursive: true })
|
|
93
|
+
await rename(tmp, dest)
|
|
94
|
+
return { cid, size, existed: false }
|
|
95
|
+
} catch (err) {
|
|
96
|
+
await rm(tmp, { force: true })
|
|
97
|
+
throw err
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Stream de lectura, con rango opcional [start, end] inclusivo.
|
|
103
|
+
* @returns {import('node:fs').ReadStream}
|
|
104
|
+
*/
|
|
105
|
+
read (cid, range) {
|
|
106
|
+
const p = this.pathFor(cid)
|
|
107
|
+
return range
|
|
108
|
+
? createReadStream(p, { start: range.start, end: range.end })
|
|
109
|
+
: createReadStream(p)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Los primeros `n` bytes de un blob. Existe para que el puerto público compruebe el
|
|
114
|
+
* tipo REAL de una imagen por sus bytes mágicos (§7.2) **sin abrir el archivo por su
|
|
115
|
+
* ruta**: con un bucket detrás no hay ruta que abrir, así que olfatear tiene que ser
|
|
116
|
+
* una operación del almacén y no de quien lo usa.
|
|
117
|
+
*/
|
|
118
|
+
async readHead (cid, n) {
|
|
119
|
+
const fh = await open(this.pathFor(cid), 'r')
|
|
120
|
+
try {
|
|
121
|
+
const buf = Buffer.alloc(n)
|
|
122
|
+
const { bytesRead } = await fh.read(buf, 0, n, 0)
|
|
123
|
+
return buf.subarray(0, bytesRead)
|
|
124
|
+
} finally { await fh.close() }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async remove (cid) {
|
|
128
|
+
await rm(this.pathFor(cid), { force: true })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Suelta la copia LOCAL y deja la de atrás. Sin bucket detrás no hay copia de
|
|
133
|
+
* atrás, así que esto es exactamente `remove` — y por eso el node comprueba
|
|
134
|
+
* `backed` antes de llamarlo: con este backend, desalojar es destruir.
|
|
135
|
+
*/
|
|
136
|
+
async evict (cid) {
|
|
137
|
+
return this.remove(cid)
|
|
138
|
+
}
|
|
139
|
+
}
|