dotrino-content 0.3.1 → 0.3.3

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/lib/public.js ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * public.js — leer contenido PÚBLICO de otro dueño por la red de Dotrino.
3
+ *
4
+ * Es el camino del tercero (DISENO.md §16): quien recibe una referencia
5
+ * `ownerId + cid` no tiene sesión con ese node ni la va a tener —las sesiones son de
6
+ * los aparatos del acta—. Lo que sí puede hacer es lo mismo que con un teléfono:
7
+ * buscar al dueño en la guía (el canal `content_<owner>` del proxio, §3.1) y
8
+ * pedirle el `cid` en un mensaje suelto. El node contesta SOLO lo marcado público y
9
+ * en claro, y el que pide comprueba que los bytes cuadren con el `cid`: el node no
10
+ * es autoridad de nada, el hash sí.
11
+ *
12
+ * Si el node tiene bucket, contesta además la URL pública (§15.13): es un ATAJO
13
+ * para cargar la imagen con un `<img>` sin mover los bytes por el proxio. Nunca es
14
+ * el enlace —el enlace sigue siendo `app/#owner/cid`— y si la URL falla, se vuelve
15
+ * a pedir por la red.
16
+ *
17
+ * Isomórfico: el `client` es un `WebSocketProxyClient` ya conectado (el que la app
18
+ * ya tiene; no se abre otro). Sin dependencias.
19
+ */
20
+
21
+ import { isValidCid } from './ref.js'
22
+
23
+ /** Nombre del canal de un dueño dentro de un proxio (espejo de src/announce.js). */
24
+ export const channelFor = (nodeId, ownerId) => `${nodeId}/content_${ownerId}`
25
+
26
+ export const MSG = Object.freeze({
27
+ FETCH: 'content.fetch',
28
+ FETCH_OK: 'content.fetch.ok',
29
+ FETCH_ERR: 'content.fetch.err'
30
+ })
31
+
32
+ /** Lo que cabe en un mensaje del proxio: el mismo tope que el plano de control. */
33
+ export const FETCH_MAX_BYTES = 256 * 1024
34
+
35
+ const b64ToBytes = (s) => /** @type {Uint8Array<ArrayBuffer>} */ (Uint8Array.from(atob(s), (c) => c.charCodeAt(0)))
36
+
37
+ async function cidOf (bytes) {
38
+ const digest = await crypto.subtle.digest('SHA-256', bytes)
39
+ return 'sha256-' + [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
40
+ }
41
+
42
+ const err = (code, msg) => Object.assign(new Error(msg), { code })
43
+
44
+ /**
45
+ * Los nodes de un dueño que están en línea ahora mismo, como tokens del proxio.
46
+ * Se mira en todos los proxios de la malla: un canal sin prefijo es local a cada
47
+ * uno, y el node se anuncia en todos. Quitarse a uno mismo evita que una app con
48
+ * node propio se pregunte a sí misma.
49
+ * @param {{ client: any, owner: string }} opts
50
+ * @returns {Promise<string[]>}
51
+ */
52
+ export async function findNodes ({ client, owner }) {
53
+ if (!client || !owner) throw new Error('findNodes: client and owner are required')
54
+ const known = Array.isArray(client.knownNodes) && client.knownNodes.length
55
+ ? client.knownNodes
56
+ : (client.node ? [client.node] : [])
57
+ const out = new Set()
58
+ for (const nodeId of known) {
59
+ try {
60
+ for (const token of await client.list(channelFor(nodeId, owner))) out.add(token)
61
+ } catch (_) { /* un proxio que no contesta no invalida al otro */ }
62
+ }
63
+ if (client.token) out.delete(client.token)
64
+ return [...out]
65
+ }
66
+
67
+ let rid = 0
68
+
69
+ /**
70
+ * Pide un `cid` público a un node concreto (por su token) y espera la respuesta.
71
+ * @returns {Promise<{ cid: string, mime: string, size: number, url: string|null, bytes: Uint8Array<ArrayBuffer>|null }>}
72
+ */
73
+ export async function fetchFrom ({ client, token, cid, head = false, full = false, timeoutMs = 8000 }) {
74
+ const id = `f${++rid}`
75
+ const reply = new Promise((resolve, reject) => {
76
+ const done = (fn, v) => { off(); clearTimeout(t); fn(v) }
77
+ const off = client.on('message', (_from, p) => {
78
+ if (!p || p.rid !== id) return
79
+ if (p.type === MSG.FETCH_OK) done(resolve, p)
80
+ else if (p.type === MSG.FETCH_ERR) done(reject, err(p.code || 'failed', p.error || 'fetch failed'))
81
+ })
82
+ const t = setTimeout(() => done(reject, err('timeout', 'the node did not answer')), timeoutMs)
83
+ })
84
+ client.send(token, { type: MSG.FETCH, rid: id, cid, head: !!head, full: !!full })
85
+ const p = await reply
86
+ let bytes = null
87
+ if (typeof p.data === 'string') {
88
+ bytes = b64ToBytes(p.data)
89
+ // El node no es autoridad: lo que diga que es el cid tiene que SERLO.
90
+ if (await cidOf(bytes) !== cid) throw err('corrupt', 'the bytes do not match the cid')
91
+ }
92
+ return { cid, mime: p.mime || 'application/octet-stream', size: Number(p.size) || (bytes?.length ?? 0), url: p.url || null, bytes }
93
+ }
94
+
95
+ /**
96
+ * Resuelve una referencia pública `ownerId + cid` por la red: encuentra un node
97
+ * vivo del dueño y le pide el blob. Prueba los que haya hasta que uno conteste.
98
+ *
99
+ * @param {{ client: any, owner: string, cid: string, head?: boolean, full?: boolean, timeoutMs?: number }} opts
100
+ * head: solo metadatos y URL, sin bytes.
101
+ * full: los bytes aunque haya URL (por defecto, con URL el node no los manda:
102
+ * cargas por la URL y solo vuelves aquí con `full` si te falló).
103
+ * @throws {Error & {code:'no-node'|'not-found'|'private'|'too-large'|'corrupt'|'timeout'}}
104
+ */
105
+ export async function fetchPublic ({ client, owner, cid, head = false, full = false, timeoutMs = 8000 }) {
106
+ if (!isValidCid(cid)) throw err('bad-input', 'invalid cid')
107
+ const tokens = await findNodes({ client, owner })
108
+ if (!tokens.length) throw err('no-node', 'no node of that owner is online')
109
+ // A TODOS a la vez, y se queda con el primero que conteste bien. En serie, cada
110
+ // token de un node ya apagado —el proxio los conserva un rato— costaba un timeout
111
+ // entero antes de llegar al que sí está vivo. Un `not-found` es una respuesta de
112
+ // verdad (otro node del mismo dueño diría lo mismo) y gana sobre los silencios.
113
+ return new Promise((resolve, reject) => {
114
+ let pending = tokens.length
115
+ let answered = null // el primer error "de verdad" (el node contestó que no)
116
+ let noise = null // timeout / corrupt: no dicen nada del contenido
117
+ for (const token of tokens) {
118
+ fetchFrom({ client, token, cid, head, full, timeoutMs }).then(resolve, (e) => {
119
+ if (e.code && e.code !== 'timeout' && e.code !== 'corrupt') answered = answered || e
120
+ else noise = noise || e
121
+ if (--pending === 0) reject(answered || noise || err('no-node', 'no node answered'))
122
+ })
123
+ }
124
+ })
125
+ }
126
+
127
+ export default { channelFor, findNodes, fetchFrom, fetchPublic, MSG, FETCH_MAX_BYTES }
package/lib/ref.js ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * ref.js — la REFERENCIA compartible (DISENO.md §3 y §7). Isomórfico y sin
3
+ * dependencias: lo usan las apps, el node y las pruebas.
4
+ *
5
+ * Una referencia es `ownerId + cid` y, si el contenido va cifrado, la LLAVE:
6
+ *
7
+ * https://eco.dotrino.com/#<ownerId>/<cid>/<llave>
8
+ * └──────── el #fragment ────────┘
9
+ *
10
+ * Las dos mitades hacen falta y ninguna sobra:
11
+ * - el **`cid`** es el hash del contenido: lo vuelve inmutable, deduplicable y
12
+ * **verificable** (quien lo recibe comprueba que los bytes son los pedidos);
13
+ * - el **`ownerId`** es la huella de la maestra del dueño, y es lo que permite
14
+ * **rutear** (`ownerId` → sus nodes) y comprobar que quien sirvió los bytes es
15
+ * un aparato suyo. Un `cid` suelto es ambiguo: cualquiera podría reclamarlo.
16
+ *
17
+ * **La llave va en el fragmento y por eso NUNCA llega a un servidor**: el
18
+ * navegador no manda el `#` en la petición. Es lo mismo que hace el resto del
19
+ * ecosistema, y es lo que permite compartir un enlace de contenido cifrado sin
20
+ * que quien lo hospeda pueda leerlo.
21
+ */
22
+
23
+ const CID_RE = /^sha256-[0-9a-f]{64}$/
24
+
25
+ /** ¿Tiene forma de cid? (no dice si existe, solo si es un cid) */
26
+ export const isValidCid = (cid) => typeof cid === 'string' && CID_RE.test(cid)
27
+
28
+ /**
29
+ * Arma la parte de fragmento de una referencia (sin el `#`).
30
+ * @param {{ owner: string, cid: string, key?: string|null }} ref
31
+ * @returns {string} `<owner>/<cid>` o `<owner>/<cid>/<llave>`
32
+ */
33
+ export function buildRef ({ owner, cid, key = null }) {
34
+ if (!owner) throw new Error('buildRef: falta owner')
35
+ if (!isValidCid(cid)) throw new Error(`buildRef: cid inválido: ${cid}`)
36
+ return key ? `${owner}/${cid}/${key}` : `${owner}/${cid}`
37
+ }
38
+
39
+ /**
40
+ * Arma el enlace completo hacia una app del ecosistema.
41
+ * @param {{ owner: string, cid: string, key?: string|null }} ref
42
+ * @param {string} [appUrl]
43
+ */
44
+ export function buildUrl (ref, appUrl = 'https://eco.dotrino.com/') {
45
+ return `${appUrl.replace(/\/+$/, '')}/#${buildRef(ref)}`
46
+ }
47
+
48
+ /**
49
+ * Lee una referencia de un fragmento, una URL o la barra de direcciones.
50
+ * Devuelve `null` si eso no es una referencia — que es lo normal: las apps del
51
+ * ecosistema usan el fragmento para muchas cosas (`#room=`, `#vault`…), así que
52
+ * esto tiene que poder decir "no es mío" sin ruido.
53
+ * @param {string} input
54
+ * @returns {{ owner: string, cid: string, key: string|null }|null}
55
+ */
56
+ export function parseRef (input) {
57
+ if (typeof input !== 'string' || !input) return null
58
+ let frag = input
59
+ const hash = frag.indexOf('#')
60
+ if (hash >= 0) frag = frag.slice(hash + 1)
61
+ frag = frag.replace(/^\/+/, '')
62
+ const parts = frag.split('/')
63
+ if (parts.length < 2 || parts.length > 3) return null
64
+ const [owner, cid, key = null] = parts
65
+ if (!owner || !isValidCid(cid)) return null
66
+ return { owner, cid, key: key || null }
67
+ }
68
+
69
+ export default { buildRef, buildUrl, parseRef, isValidCid }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotrino-content",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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": {
@@ -49,6 +49,8 @@
49
49
  "files": [
50
50
  "bin",
51
51
  "src",
52
+ "lib/public.js",
53
+ "lib/ref.js",
52
54
  "README.md"
53
55
  ],
54
56
  "publishConfig": {
package/src/agent.js CHANGED
@@ -90,12 +90,18 @@ export async function startContentAgent ({
90
90
  const beacon = announce && agent.client
91
91
  ? startAnnounce({ client: agent.client, owner, quiet })
92
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
93
97
 
94
98
  return {
95
99
  ...agent,
96
100
  owner,
97
101
  channels: () => beacon?.channels() || [],
102
+ served: () => fetcher?.served() || 0,
98
103
  close () {
104
+ try { fetcher?.close() } catch (_) {}
99
105
  try { beacon?.close() } catch (_) {}
100
106
  agent.close()
101
107
  }
package/src/announce.js CHANGED
@@ -78,7 +78,7 @@ export function startAnnounce ({ client, owner, quiet = false, intervalMs = REPU
78
78
  }
79
79
  const first = !current.length && done.length
80
80
  current = done
81
- if (first && !quiet) console.log(`[content] anunciado como node de ${owner.slice(0, 16)} en ${done.length} proxio(s)`)
81
+ if (first && !quiet) console.log(`[content] anunciado como node de ${owner.slice(0, 16)} en ${done.length} proxio(s) · token ${client.token}`)
82
82
  }
83
83
 
84
84
  publishAll()
package/src/fetch.js CHANGED
@@ -50,6 +50,7 @@ export function startPublicFetch ({ client, node, ratePerMin = 60, quiet = false
50
50
  const handle = async (from, p) => {
51
51
  const rid = typeof p.rid === 'string' ? p.rid : null
52
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)' : ''}`)
53
54
  if (!allowed(from)) return fail(from, rid, 'rate-limited', 'too many requests')
54
55
  if (!isValidCid(p.cid)) return fail(from, rid, 'bad-input', 'invalid cid')
55
56
  const meta = node.stat(p.cid)