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.
- package/lib/public.js +123 -0
- package/lib/ref.js +69 -0
- 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.
|
|
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": {
|