dotrino-content 0.2.3 → 0.3.1
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 +4 -3
- package/bin/cli.js +2 -2
- package/package.json +1 -1
- package/src/agent.js +1 -0
- package/src/announce.js +4 -2
- package/src/fetch.js +88 -0
- package/src/node.js +21 -2
package/README.md
CHANGED
|
@@ -152,9 +152,10 @@ const back = await cc.get(ref) // comprueba el hash antes de devolver nada
|
|
|
152
152
|
`putImageWithThumbnail()`, que sube el original **cifrado y privado** y la miniatura
|
|
153
153
|
**en claro y pública** — que es el reparto que hace que haya tarjeta sin publicar el
|
|
154
154
|
archivo.
|
|
155
|
-
- **
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
- **Lo PÚBLICO de otro usuario ya se lee por la red** (`fetchPublic` en la lib,
|
|
156
|
+
DISENO §16): un tercero con tu enlace encuentra tu node por el proxio y le pide el
|
|
157
|
+
`cid`; el node contesta solo lo marcado público y en claro, y los bytes se
|
|
158
|
+
verifican contra el `cid`. Lo privado sigue siendo de tus aparatos.
|
|
158
159
|
|
|
159
160
|
## Tests
|
|
160
161
|
|
package/bin/cli.js
CHANGED
|
@@ -132,9 +132,9 @@ if (!isEnrolled()) console.log('sin vault: configuración local (enrola con: dot
|
|
|
132
132
|
await vaultConfig.ready
|
|
133
133
|
|
|
134
134
|
const { openStore } = await import('../src/storage.js')
|
|
135
|
-
const { store } = await openStore({ dir, log })
|
|
135
|
+
const { store, cfg } = await openStore({ dir, log })
|
|
136
136
|
|
|
137
|
-
const node = await new ContentNode({ dir, maxBytes, maxBlobBytes, store, log }).init()
|
|
137
|
+
const node = await new ContentNode({ dir, maxBytes, maxBlobBytes, store, log, publicBase: cfg?.pub ? cfg.baseUrl : null }).init()
|
|
138
138
|
// Lo que quedó sin subir en un arranque anterior. Se lanza y no se espera.
|
|
139
139
|
node.backupPending().then(({ pending }) => { if (pending) log(`[almacén] ${pending} pendiente(s) de subir al bucket`) })
|
|
140
140
|
const server = createServer(node)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dotrino-content",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/agent.js
CHANGED
|
@@ -23,6 +23,7 @@ import { pubkeyId } from '@dotrino/identity/capabilities'
|
|
|
23
23
|
import { startRemoteAgent } from '@dotrino/remote-agent/agent'
|
|
24
24
|
import { dataDir, loadLink } from '@dotrino/remote-agent/link'
|
|
25
25
|
import { startAnnounce } from './announce.js'
|
|
26
|
+
import { startPublicFetch } from './fetch.js'
|
|
26
27
|
import { createOps } from './ops.js'
|
|
27
28
|
|
|
28
29
|
/** Carpeta de datos del enlace (NO es la de los blobs: el enlace es del aparato). */
|
package/src/announce.js
CHANGED
|
@@ -30,8 +30,10 @@
|
|
|
30
30
|
* el sitio de siempre.
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
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 }
|
|
35
37
|
|
|
36
38
|
/** Cada cuánto se re-publica (el proxio caduca las entradas de canal). */
|
|
37
39
|
export const REPUBLISH_MS = 4 * 60 * 1000
|
package/src/fetch.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
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 (!allowed(from)) return fail(from, rid, 'rate-limited', 'too many requests')
|
|
54
|
+
if (!isValidCid(p.cid)) return fail(from, rid, 'bad-input', 'invalid cid')
|
|
55
|
+
const meta = node.stat(p.cid)
|
|
56
|
+
// Mismo 'not-found' para «no existe» y «no es público»: el mismo motivo que §7.2.
|
|
57
|
+
if (!meta || meta.acl !== 'public' || meta.enc) return fail(from, rid, 'not-found', 'no such public cid')
|
|
58
|
+
const url = node.publicUrl(p.cid)
|
|
59
|
+
const out = { type: MSG.FETCH_OK, rid, cid: p.cid, mime: meta.mime, size: meta.size, url }
|
|
60
|
+
// Con atajo, los bytes NO viajan por el proxio salvo que el que pide insista
|
|
61
|
+
// (`full`): cargar por URL es gratis para el node y para la red, y la app ya
|
|
62
|
+
// sabe volver aquí si la URL le falla. Sin atajo, van en el mensaje si caben.
|
|
63
|
+
if (!p.head && (!url || p.full)) {
|
|
64
|
+
if (meta.size > FETCH_MAX_BYTES) {
|
|
65
|
+
if (!url) return fail(from, rid, 'too-large', `${meta.size} bytes do not fit in a message and this node has no public bucket`)
|
|
66
|
+
// Cabe solo por URL: se contesta sin bytes y la app carga por ahí.
|
|
67
|
+
} else {
|
|
68
|
+
const chunks = []
|
|
69
|
+
for await (const c of node.read(p.cid)) chunks.push(c)
|
|
70
|
+
out.data = Buffer.concat(chunks).toString('base64')
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
served++
|
|
74
|
+
send(from, out)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const off = client.on('message', (from, p) => {
|
|
78
|
+
if (!p || p.type !== MSG.FETCH) return
|
|
79
|
+
handle(from, p).catch((e) => fail(from, p.rid, 'failed', e.message))
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
served: () => served,
|
|
84
|
+
close () { try { off?.() } catch (_) {} }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export default { startPublicFetch, MSG, FETCH_MAX_BYTES }
|
package/src/node.js
CHANGED
|
@@ -30,6 +30,9 @@ export class ContentNode {
|
|
|
30
30
|
// El almacén se puede INYECTAR (`storage.js` monta el de bucket cuando toca). Por
|
|
31
31
|
// defecto, disco: sin configuración, un node funciona con lo que hay en la máquina.
|
|
32
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
|
|
33
36
|
this.index = null
|
|
34
37
|
/** Subidas al bucket en curso, por cid: para no lanzar dos veces la misma. */
|
|
35
38
|
this._uploading = new Map()
|
|
@@ -73,6 +76,18 @@ export class ContentNode {
|
|
|
73
76
|
return isValidCid(cid) ? this.index.get(cid) : null
|
|
74
77
|
}
|
|
75
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
|
+
|
|
76
91
|
/**
|
|
77
92
|
* Marca un blob como público o privado (`acl`). Es lo único que autoriza a que
|
|
78
93
|
* los bytes salgan del node cuando esté encendido el modo público (DISENO.md
|
|
@@ -98,8 +113,12 @@ export class ContentNode {
|
|
|
98
113
|
* subió sigue siendo la cola pendiente del índice, y se reintenta al arrancar.
|
|
99
114
|
*/
|
|
100
115
|
backup (cid, meta) {
|
|
101
|
-
if (!this.store.backed
|
|
102
|
-
|
|
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))
|
|
103
122
|
.then(() => { this.index.setRemote(cid, true) })
|
|
104
123
|
.catch((e) => { this.log(`[almacén] no se pudo subir ${cid.slice(0, 14)}…: ${e.message}`) })
|
|
105
124
|
.finally(() => this._uploading.delete(cid))
|