dotrino-content 0.2.2 → 0.3.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 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
- - **Todavía NO lee el contenido de otro usuario**: hoy solo se abre sesión con un
156
- aparato de tu misma acta. Un tercero con tu enlace ve la vista previa (si la
157
- encendiste) y podrá pedir los bytes cuando exista el transporte P2P.
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
@@ -52,10 +52,16 @@ const { values, positionals } = parseArgs({
52
52
  'public-egress-gb': { type: 'string' },
53
53
  'public-url': { type: 'string' },
54
54
  'app-url': { type: 'string' },
55
- 'public-index': { type: 'boolean' }
55
+ 'public-index': { type: 'boolean' },
56
+ help: { type: 'boolean', short: 'h' }
56
57
  }
57
58
  })
58
59
 
60
+ if (values.help) {
61
+ console.log(USAGE)
62
+ process.exit(0)
63
+ }
64
+
59
65
  const cmd = positionals[0] || 'start'
60
66
  if (cmd !== 'start' && cmd !== 'enroll') {
61
67
  console.error(`comando desconocido: ${cmd}\n${USAGE}`)
@@ -126,9 +132,9 @@ if (!isEnrolled()) console.log('sin vault: configuración local (enrola con: dot
126
132
  await vaultConfig.ready
127
133
 
128
134
  const { openStore } = await import('../src/storage.js')
129
- const { store } = await openStore({ dir, log })
135
+ const { store, cfg } = await openStore({ dir, log })
130
136
 
131
- 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()
132
138
  // Lo que quedó sin subir en un arranque anterior. Se lanza y no se espera.
133
139
  node.backupPending().then(({ pending }) => { if (pending) log(`[almacén] ${pending} pendiente(s) de subir al bucket`) })
134
140
  const server = createServer(node)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotrino-content",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
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
- /** Nombre del canal de un dueño dentro de un proxio concreto. */
34
- export const channelFor = (nodeId, ownerId) => `${nodeId}/content_${ownerId}`
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,85 @@
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
+ if (!p.head) {
61
+ if (meta.size > FETCH_MAX_BYTES) {
62
+ if (!url) return fail(from, rid, 'too-large', `${meta.size} bytes do not fit in a message and this node has no public bucket`)
63
+ // Cabe solo por URL: se contesta sin bytes y la app carga por ahí.
64
+ } else {
65
+ const chunks = []
66
+ for await (const c of node.read(p.cid)) chunks.push(c)
67
+ out.data = Buffer.concat(chunks).toString('base64')
68
+ }
69
+ }
70
+ served++
71
+ send(from, out)
72
+ }
73
+
74
+ const off = client.on('message', (from, p) => {
75
+ if (!p || p.type !== MSG.FETCH) return
76
+ handle(from, p).catch((e) => fail(from, p.rid, 'failed', e.message))
77
+ })
78
+
79
+ return {
80
+ served: () => served,
81
+ close () { try { off?.() } catch (_) {} }
82
+ }
83
+ }
84
+
85
+ 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 || this._uploading.has(cid)) return null
102
- const p = this.store.upload(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))
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))