dotrino-content 0.3.1 → 0.3.2

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.
Files changed (3) hide show
  1. package/lib/public.js +123 -0
  2. package/lib/ref.js +69 -0
  3. package/package.json +3 -1
package/lib/public.js ADDED
@@ -0,0 +1,123 @@
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
+ let last = null
110
+ for (const token of tokens) {
111
+ try {
112
+ return await fetchFrom({ client, token, cid, head, full, timeoutMs })
113
+ } catch (e) {
114
+ last = e
115
+ // Si el node CONTESTÓ que no (privado, no existe), otro node del mismo dueño
116
+ // dirá lo mismo: no tiene sentido insistir. Solo se reintenta lo que fue ruido.
117
+ if (e.code && e.code !== 'timeout' && e.code !== 'corrupt') throw e
118
+ }
119
+ }
120
+ throw last || err('no-node', 'no node answered')
121
+ }
122
+
123
+ 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.2",
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": {