@dotrino/content 0.3.13
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 +183 -0
- package/bin/cli.js +210 -0
- package/lib/public.js +127 -0
- package/lib/ref.js +69 -0
- package/package.json +59 -0
- package/src/agent.js +111 -0
- package/src/announce.js +138 -0
- package/src/blobstore-s3.js +177 -0
- package/src/blobstore.js +139 -0
- package/src/db.js +237 -0
- package/src/fetch.js +89 -0
- package/src/index.js +3 -0
- package/src/node.js +262 -0
- package/src/ops.js +220 -0
- package/src/public.js +400 -0
- package/src/s3.js +257 -0
- package/src/server.js +142 -0
- package/src/storage.js +175 -0
- package/src/vaultEnv.js +158 -0
package/src/s3.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cliente S3 mínimo, sin SDK (DISENO.md §15.10).
|
|
3
|
+
*
|
|
4
|
+
* Habla el protocolo, no un proveedor: **R2, Backblaze, Hetzner, Storj y el propio
|
|
5
|
+
* S3 de Amazon son el mismo código** con otro `endpoint`. Por eso no hay una
|
|
6
|
+
* "integración de Cloudflare" y otra "de S3": es una sola.
|
|
7
|
+
*
|
|
8
|
+
* Sin dependencias, como toda la Fase 1: `node:crypto` para firmar y `fetch` para
|
|
9
|
+
* hablar. El `.npmrc` del ecosistema bloquea los scripts de instalación, y el SDK
|
|
10
|
+
* oficial son decenas de paquetes para hacer un `PUT`.
|
|
11
|
+
*
|
|
12
|
+
* **El detalle que hace esto barato:** S3 exige mandar el SHA-256 del cuerpo en
|
|
13
|
+
* `x-amz-content-sha256`… y aquí el `cid` YA ES ese hash (`sha256-<hex>`), porque los
|
|
14
|
+
* bytes que se suben son exactamente los que el `cid` direcciona. Así que se firma sin
|
|
15
|
+
* leer el cuerpo dos veces y sin tenerlo entero en memoria: se sube en streaming.
|
|
16
|
+
*/
|
|
17
|
+
import { createHmac, createHash } from 'node:crypto'
|
|
18
|
+
|
|
19
|
+
const ALGO = 'AWS4-HMAC-SHA256'
|
|
20
|
+
const EMPTY_SHA256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
|
|
21
|
+
|
|
22
|
+
const sha256hex = (data) => createHash('sha256').update(data).digest('hex')
|
|
23
|
+
const hmac = (key, data) => createHmac('sha256', key).update(data, 'utf8').digest()
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Codificación de URI de AWS: es la de RFC 3986, que NO es la de `encodeURIComponent`
|
|
27
|
+
* (deja pasar `!'()*`). Una sola diferencia hace que la firma no cuadre y el error que
|
|
28
|
+
* devuelve el servidor no dice cuál.
|
|
29
|
+
*/
|
|
30
|
+
export function uriEncode (str, encodeSlash = true) {
|
|
31
|
+
let out = ''
|
|
32
|
+
for (const ch of Buffer.from(String(str), 'utf8')) {
|
|
33
|
+
const c = String.fromCharCode(ch)
|
|
34
|
+
if (/[A-Za-z0-9\-._~]/.test(c)) out += c
|
|
35
|
+
else if (c === '/') out += encodeSlash ? '%2F' : '/'
|
|
36
|
+
else out += '%' + ch.toString(16).toUpperCase().padStart(2, '0')
|
|
37
|
+
}
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `20150830T123600Z` y `20150830` a partir de una fecha. */
|
|
42
|
+
export function amzDate (date) {
|
|
43
|
+
const iso = date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
|
|
44
|
+
return { amz: iso, day: iso.slice(0, 8) }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Firma una petición con SigV4 y devuelve las cabeceras que hay que mandar.
|
|
49
|
+
*
|
|
50
|
+
* @param {object} o
|
|
51
|
+
* @param {string} o.method
|
|
52
|
+
* @param {string|URL} o.url
|
|
53
|
+
* @param {Record<string,string>} [o.headers] las que ya lleva (se firman todas)
|
|
54
|
+
* @param {string} [o.payloadHash] hex del SHA-256 del cuerpo; por defecto, cuerpo vacío
|
|
55
|
+
* @param {string} o.accessKeyId
|
|
56
|
+
* @param {string} o.secretAccessKey
|
|
57
|
+
* @param {string} o.region
|
|
58
|
+
* @param {string} [o.service] `s3` salvo en los vectores de prueba de AWS
|
|
59
|
+
* @param {Date} [o.now]
|
|
60
|
+
* @returns {Record<string,string>} cabeceras completas, con `authorization`
|
|
61
|
+
*/
|
|
62
|
+
export function signRequest ({
|
|
63
|
+
method, url, headers = {}, payloadHash = EMPTY_SHA256,
|
|
64
|
+
accessKeyId, secretAccessKey, region, service = 's3', now = new Date()
|
|
65
|
+
}) {
|
|
66
|
+
const u = new URL(url)
|
|
67
|
+
const { amz, day } = amzDate(now)
|
|
68
|
+
|
|
69
|
+
// Las cabeceras firmadas van en minúsculas y ordenadas; `host` y la fecha son
|
|
70
|
+
// obligatorias, y en S3 también el hash del cuerpo.
|
|
71
|
+
const all = { ...headers, host: u.host, 'x-amz-date': amz }
|
|
72
|
+
if (service === 's3') all['x-amz-content-sha256'] = payloadHash
|
|
73
|
+
const names = Object.keys(all).map((k) => k.toLowerCase()).sort()
|
|
74
|
+
const lower = Object.fromEntries(Object.entries(all).map(([k, v]) => [k.toLowerCase(), String(v).trim()]))
|
|
75
|
+
|
|
76
|
+
const canonicalHeaders = names.map((n) => `${n}:${lower[n]}\n`).join('')
|
|
77
|
+
const signedHeaders = names.join(';')
|
|
78
|
+
|
|
79
|
+
const query = [...u.searchParams.entries()]
|
|
80
|
+
.map(([k, v]) => [uriEncode(k), uriEncode(v)])
|
|
81
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
82
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
83
|
+
.join('&')
|
|
84
|
+
|
|
85
|
+
const canonicalRequest = [
|
|
86
|
+
method.toUpperCase(),
|
|
87
|
+
uriEncode(decodeURIComponent(u.pathname), false),
|
|
88
|
+
query,
|
|
89
|
+
canonicalHeaders,
|
|
90
|
+
signedHeaders,
|
|
91
|
+
payloadHash
|
|
92
|
+
].join('\n')
|
|
93
|
+
|
|
94
|
+
const scope = `${day}/${region}/${service}/aws4_request`
|
|
95
|
+
const stringToSign = [ALGO, amz, scope, sha256hex(canonicalRequest)].join('\n')
|
|
96
|
+
|
|
97
|
+
const kDate = hmac(`AWS4${secretAccessKey}`, day)
|
|
98
|
+
const kRegion = hmac(kDate, region)
|
|
99
|
+
const kService = hmac(kRegion, service)
|
|
100
|
+
const kSigning = hmac(kService, 'aws4_request')
|
|
101
|
+
const signature = createHmac('sha256', kSigning).update(stringToSign, 'utf8').digest('hex')
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
...all,
|
|
105
|
+
authorization: `${ALGO} Credential=${accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
|
106
|
+
// Se devuelven para poder diagnosticar una firma que no cuadra: el servidor
|
|
107
|
+
// contesta "SignatureDoesNotMatch" y nada más, así que sin esto no hay por dónde.
|
|
108
|
+
_canonicalRequest: canonicalRequest,
|
|
109
|
+
_stringToSign: stringToSign
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Error de S3 con el código que devolvió el servidor (`NoSuchKey`, `AccessDenied`…). */
|
|
114
|
+
export class S3Error extends Error {
|
|
115
|
+
constructor (status, code, message, key) {
|
|
116
|
+
super(`S3 ${status} ${code}${key ? ` (${key})` : ''}: ${message}`)
|
|
117
|
+
this.name = 'S3Error'
|
|
118
|
+
this.status = status
|
|
119
|
+
this.code = code
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** El cuerpo de error de S3 es XML; solo interesan dos etiquetas. */
|
|
124
|
+
const parseError = (xml) => ({
|
|
125
|
+
code: /<Code>([^<]+)<\/Code>/.exec(xml)?.[1] || 'Unknown',
|
|
126
|
+
message: /<Message>([^<]+)<\/Message>/.exec(xml)?.[1] || xml.slice(0, 200)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Un bucket. Se crea uno por bucket (público y privado tienen credenciales
|
|
131
|
+
* distintas a propósito, §15.1), y no guarda estado: cada petición se firma sola.
|
|
132
|
+
*/
|
|
133
|
+
export class S3Bucket {
|
|
134
|
+
/**
|
|
135
|
+
* @param {object} o
|
|
136
|
+
* @param {string} o.endpoint p.ej. `https://<id>.r2.cloudflarestorage.com`
|
|
137
|
+
* @param {string} o.bucket
|
|
138
|
+
* @param {string} o.accessKeyId
|
|
139
|
+
* @param {string} o.secretAccessKey
|
|
140
|
+
* @param {string} [o.region] `auto` en R2
|
|
141
|
+
* @param {typeof fetch} [o.fetch] inyectable para la prueba de integración
|
|
142
|
+
*/
|
|
143
|
+
constructor ({ endpoint, bucket, accessKeyId, secretAccessKey, region = 'auto', fetch: f = fetch }) {
|
|
144
|
+
if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) {
|
|
145
|
+
throw new Error('S3Bucket: missing endpoint, bucket or credentials')
|
|
146
|
+
}
|
|
147
|
+
this.base = `${String(endpoint).replace(/\/+$/, '')}/${bucket}`
|
|
148
|
+
this.bucket = bucket
|
|
149
|
+
this.region = region
|
|
150
|
+
this.accessKeyId = accessKeyId
|
|
151
|
+
this.secretAccessKey = secretAccessKey
|
|
152
|
+
this.fetch = f
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** URL de un objeto (path-style: es lo que entienden todos los proveedores). */
|
|
156
|
+
urlFor (key) {
|
|
157
|
+
return `${this.base}/${key.split('/').map((s) => uriEncode(s)).join('/')}`
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @private
|
|
162
|
+
* @param {string} method
|
|
163
|
+
* @param {string} key
|
|
164
|
+
* @param {{ headers?: Record<string,string>, body?: any, payloadHash?: string,
|
|
165
|
+
* contentLength?: number|null }} [opts]
|
|
166
|
+
*/
|
|
167
|
+
async request (method, key, { headers = {}, body = null, payloadHash = '', contentLength = null } = {}) {
|
|
168
|
+
const url = this.urlFor(key)
|
|
169
|
+
const h = signRequest({
|
|
170
|
+
method,
|
|
171
|
+
url,
|
|
172
|
+
headers,
|
|
173
|
+
payloadHash: payloadHash || EMPTY_SHA256,
|
|
174
|
+
accessKeyId: this.accessKeyId,
|
|
175
|
+
secretAccessKey: this.secretAccessKey,
|
|
176
|
+
region: this.region
|
|
177
|
+
})
|
|
178
|
+
delete h._canonicalRequest
|
|
179
|
+
delete h._stringToSign
|
|
180
|
+
if (contentLength != null) h['content-length'] = String(contentLength)
|
|
181
|
+
|
|
182
|
+
const res = await this.fetch(url, {
|
|
183
|
+
method,
|
|
184
|
+
headers: h,
|
|
185
|
+
body,
|
|
186
|
+
// Node lo exige para mandar un cuerpo en streaming; sin esto hay que
|
|
187
|
+
// tener el archivo entero en memoria, que es justo lo que se evita.
|
|
188
|
+
...(body && typeof body !== 'string' ? { duplex: 'half' } : {})
|
|
189
|
+
})
|
|
190
|
+
return res
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Sube un objeto. `sha256` es el hex del contenido — que aquí es el `cid` sin su
|
|
195
|
+
* prefijo, así que no hay que calcular nada.
|
|
196
|
+
* @param {string} key
|
|
197
|
+
* @param {any} body stream o buffer
|
|
198
|
+
* @param {{ sha256?: string, size?: number, contentType?: string, cacheControl?: string }} [o]
|
|
199
|
+
*/
|
|
200
|
+
async put (key, body, { sha256 = '', size = 0, contentType = '', cacheControl = '' } = {}) {
|
|
201
|
+
if (!sha256) throw new Error('S3Bucket.put: the sha256 of the content is required')
|
|
202
|
+
/** @type {Record<string,string>} */
|
|
203
|
+
const headers = {}
|
|
204
|
+
if (contentType) headers['content-type'] = contentType
|
|
205
|
+
if (cacheControl) headers['cache-control'] = cacheControl
|
|
206
|
+
const res = await this.request('PUT', key, { headers, body, payloadHash: sha256, contentLength: size })
|
|
207
|
+
if (!res.ok) {
|
|
208
|
+
const { code, message } = parseError(await res.text())
|
|
209
|
+
throw new S3Error(res.status, code, message, key)
|
|
210
|
+
}
|
|
211
|
+
return { etag: res.headers.get('etag') }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Descarga un objeto. `range` es `{ start, end }` inclusivo, igual que en el
|
|
216
|
+
* BlobStore, para que el 206 de la API local siga funcionando igual.
|
|
217
|
+
* @returns {Promise<{ body: ReadableStream, size: number|null, contentType: string|null }>}
|
|
218
|
+
*/
|
|
219
|
+
async get (key, range = null) {
|
|
220
|
+
const headers = range ? { range: `bytes=${range.start}-${range.end}` } : {}
|
|
221
|
+
const res = await this.request('GET', key, { headers })
|
|
222
|
+
if (!res.ok) {
|
|
223
|
+
const { code, message } = parseError(await res.text())
|
|
224
|
+
throw new S3Error(res.status, code, message, key)
|
|
225
|
+
}
|
|
226
|
+
const len = res.headers.get('content-length')
|
|
227
|
+
return { body: res.body, size: len == null ? null : Number(len), contentType: res.headers.get('content-type') }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Los primeros `n` bytes, que es lo que necesita olfatear un tipo de imagen (§7.2). */
|
|
231
|
+
async head (key, n) {
|
|
232
|
+
const { body } = await this.get(key, { start: 0, end: n - 1 })
|
|
233
|
+
const chunks = []
|
|
234
|
+
for await (const c of body) chunks.push(Buffer.from(c))
|
|
235
|
+
return Buffer.concat(chunks)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Tamaño del objeto, o `null` si no está. No baja el cuerpo. */
|
|
239
|
+
async size (key) {
|
|
240
|
+
const res = await this.request('HEAD', key)
|
|
241
|
+
if (res.status === 404) return null
|
|
242
|
+
if (!res.ok) throw new S3Error(res.status, 'HeadFailed', res.statusText, key)
|
|
243
|
+
const len = res.headers.get('content-length')
|
|
244
|
+
return len == null ? null : Number(len)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Borra. Borrar lo que no está NO es un error (igual que `rm -f`). */
|
|
248
|
+
async remove (key) {
|
|
249
|
+
const res = await this.request('DELETE', key)
|
|
250
|
+
if (!res.ok && res.status !== 404) {
|
|
251
|
+
const { code, message } = parseError(await res.text())
|
|
252
|
+
throw new S3Error(res.status, code, message, key)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export default { S3Bucket, signRequest, uriEncode, amzDate, S3Error }
|
package/src/server.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API HTTP local (Fase 1, DISENO.md §6). SOLO localhost: sin auth todavía
|
|
3
|
+
* (la auth por vault llega en Fase 2; la exposición al mundo en Fase 3).
|
|
4
|
+
*
|
|
5
|
+
* POST /c subir (streaming; Content-Type = mime; ?ttl=<ms>)
|
|
6
|
+
* GET /c/<cid> descargar/streamear (Range → 206; ETag = cid, immutable)
|
|
7
|
+
* HEAD /c/<cid> size/mime/etag sin cuerpo
|
|
8
|
+
* DELETE /c/<cid> borrar
|
|
9
|
+
* GET /list índice
|
|
10
|
+
* POST /pin/<cid> retención (evita GC) POST /unpin/<cid>
|
|
11
|
+
* POST /public/<cid> publicar (al bucket público si lo hay) POST /private/<cid>
|
|
12
|
+
* GET /stats uso de disco, nº blobs
|
|
13
|
+
*/
|
|
14
|
+
import http from 'node:http'
|
|
15
|
+
import { pipeline } from 'node:stream/promises'
|
|
16
|
+
import { isValidCid } from './node.js'
|
|
17
|
+
|
|
18
|
+
/** Parsea `Range: bytes=a-b|a-|-n` contra `size`. null = sin rango; false = inválido. */
|
|
19
|
+
export function parseRange (header, size) {
|
|
20
|
+
if (!header) return null
|
|
21
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim())
|
|
22
|
+
if (!m || (m[1] === '' && m[2] === '')) return false
|
|
23
|
+
let start, end
|
|
24
|
+
if (m[1] === '') { // sufijo: últimos n bytes
|
|
25
|
+
const n = Number(m[2])
|
|
26
|
+
if (n === 0) return false
|
|
27
|
+
start = Math.max(0, size - n)
|
|
28
|
+
end = size - 1
|
|
29
|
+
} else {
|
|
30
|
+
start = Number(m[1])
|
|
31
|
+
end = m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1)
|
|
32
|
+
}
|
|
33
|
+
if (start >= size || start > end) return false
|
|
34
|
+
return { start, end }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const json = (res, code, obj) => {
|
|
38
|
+
const body = JSON.stringify(obj)
|
|
39
|
+
res.writeHead(code, { 'content-type': 'application/json' }).end(body)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {import('./node.js').ContentNode} node
|
|
44
|
+
* @returns {http.Server}
|
|
45
|
+
*/
|
|
46
|
+
export function createServer (node) {
|
|
47
|
+
return http.createServer(async (req, res) => {
|
|
48
|
+
try {
|
|
49
|
+
await route(node, req, res)
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (res.headersSent) { res.destroy(); return }
|
|
52
|
+
if (err.code === 'ETOOBIG') return json(res, 413, { error: 'blob too large' })
|
|
53
|
+
if (err.code === 'ENOSPC') return json(res, 507, { error: 'disk quota exceeded' })
|
|
54
|
+
json(res, 500, { error: err.message })
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function route (node, req, res) {
|
|
60
|
+
const url = new URL(req.url, 'http://localhost')
|
|
61
|
+
const [, top, cid] = url.pathname.split('/')
|
|
62
|
+
|
|
63
|
+
if (req.method === 'POST' && top === 'c' && !cid) {
|
|
64
|
+
const ttlMs = Number(url.searchParams.get('ttl')) || 0
|
|
65
|
+
const out = await node.put(req, {
|
|
66
|
+
mime: req.headers['content-type'] || 'application/octet-stream',
|
|
67
|
+
enc: url.searchParams.get('enc') === '1' ? 1 : 0,
|
|
68
|
+
ttl: ttlMs > 0 ? Date.now() + ttlMs : null
|
|
69
|
+
})
|
|
70
|
+
return json(res, out.existed ? 200 : 201, out)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (top === 'c' && cid) {
|
|
74
|
+
if (!isValidCid(cid)) return json(res, 400, { error: 'invalid cid' })
|
|
75
|
+
const meta = node.stat(cid)
|
|
76
|
+
if (!meta) return json(res, 404, { error: 'not found' })
|
|
77
|
+
|
|
78
|
+
if (req.method === 'DELETE') {
|
|
79
|
+
await node.remove(cid)
|
|
80
|
+
return json(res, 200, { ok: true })
|
|
81
|
+
}
|
|
82
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
83
|
+
return json(res, 405, { error: 'method not allowed' })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const headers = {
|
|
87
|
+
'content-type': meta.mime,
|
|
88
|
+
etag: `"${cid}"`,
|
|
89
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
90
|
+
'accept-ranges': 'bytes'
|
|
91
|
+
}
|
|
92
|
+
if (req.headers['if-none-match'] === `"${cid}"`) {
|
|
93
|
+
res.writeHead(304, headers).end()
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
const range = parseRange(req.headers.range, meta.size)
|
|
97
|
+
if (range === false) {
|
|
98
|
+
res.writeHead(416, { 'content-range': `bytes */${meta.size}` }).end()
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
if (range) {
|
|
102
|
+
headers['content-range'] = `bytes ${range.start}-${range.end}/${meta.size}`
|
|
103
|
+
headers['content-length'] = range.end - range.start + 1
|
|
104
|
+
res.writeHead(206, headers)
|
|
105
|
+
} else {
|
|
106
|
+
headers['content-length'] = meta.size
|
|
107
|
+
res.writeHead(200, headers)
|
|
108
|
+
}
|
|
109
|
+
if (req.method === 'HEAD') return res.end()
|
|
110
|
+
await pipeline(node.read(cid, range ?? undefined), res)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (req.method === 'GET' && top === 'list') return json(res, 200, node.list())
|
|
115
|
+
if (req.method === 'GET' && top === 'stats') return json(res, 200, node.stats())
|
|
116
|
+
|
|
117
|
+
if (req.method === 'POST' && (top === 'pin' || top === 'unpin') && cid) {
|
|
118
|
+
if (!isValidCid(cid)) return json(res, 400, { error: 'invalid cid' })
|
|
119
|
+
const ok = node.pin(cid, top === 'pin')
|
|
120
|
+
return ok ? json(res, 200, { ok: true }) : json(res, 404, { error: 'not found' })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// PUBLICAR / despublicar. Existía solo por el plano de control, y eso dejaba sin poder
|
|
124
|
+
// publicar a las herramientas de la propia máquina —que es donde vive el dueño—, y sin
|
|
125
|
+
// poder probar el camino público sin montar medio ecosistema.
|
|
126
|
+
//
|
|
127
|
+
// Con bucket detrás, marcar público MUEVE los bytes al bucket público, que es otro
|
|
128
|
+
// (§15.1): por eso pasa por `node.setAcl` y no por el índice a secas.
|
|
129
|
+
if (req.method === 'POST' && (top === 'public' || top === 'private') && cid) {
|
|
130
|
+
if (!isValidCid(cid)) return json(res, 400, { error: 'invalid cid' })
|
|
131
|
+
const meta = node.stat(cid)
|
|
132
|
+
if (!meta) return json(res, 404, { error: 'not found' })
|
|
133
|
+
// Un blob CIFRADO no puede ser público, y se rechaza aquí además de en el índice:
|
|
134
|
+
// es la misma frontera del §7.2, y una frontera que solo se comprueba en un sitio
|
|
135
|
+
// acaba teniendo un camino que no pasa por ese sitio.
|
|
136
|
+
if (top === 'public' && meta.enc) return json(res, 400, { error: 'an encrypted blob cannot be public' })
|
|
137
|
+
const ok = node.setAcl(cid, top === 'public' ? 'public' : null)
|
|
138
|
+
return ok ? json(res, 200, { ok: true, acl: top === 'public' ? 'public' : null }) : json(res, 404, { error: 'not found' })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
json(res, 404, { error: 'unknown route' })
|
|
142
|
+
}
|
package/src/storage.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Qué almacén usa este node, y las comprobaciones antes de fiarse de él
|
|
3
|
+
* (DISENO.md §15.14 y §15.15).
|
|
4
|
+
*
|
|
5
|
+
* Una variable pública manda —`CONTENT_STORAGE`— y las demás solo tienen sentido si
|
|
6
|
+
* nombra un proveedor. El valor nombra a QUIÉN está detrás (`r2`, `b2`, `hetzner`,
|
|
7
|
+
* `storj`, `s3`), no al protocolo: todos hablan S3 y usan el mismo backend, pero
|
|
8
|
+
* saberlo permite poner sus valores por omisión y, sobre todo, que quien mire la
|
|
9
|
+
* consola entienda lo que lee.
|
|
10
|
+
*
|
|
11
|
+
* **Nunca arranca a medias.** Si falta algo o una comprobación falla, se queda en
|
|
12
|
+
* `local` y lo DICE. Un almacén mal configurado que parece funcionar es la peor de las
|
|
13
|
+
* tres opciones: se descubre el día que hace falta lo que se creía guardado.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from 'node:crypto'
|
|
16
|
+
import { BlobStore } from './blobstore.js'
|
|
17
|
+
import { S3BlobStore } from './blobstore-s3.js'
|
|
18
|
+
import { S3Bucket } from './s3.js'
|
|
19
|
+
|
|
20
|
+
/** Proveedores que hablan S3. Solo cambian el endpoint y algún valor por omisión. */
|
|
21
|
+
export const PROVIDERS = Object.freeze({
|
|
22
|
+
r2: { region: 'auto' },
|
|
23
|
+
b2: { region: 'us-west-004' },
|
|
24
|
+
hetzner: { region: 'eu-central' },
|
|
25
|
+
storj: { region: 'us-east-1' },
|
|
26
|
+
s3: { region: 'us-east-1' }
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
/** La sonda del camino público: nueve bytes que se suben y se leen por el dominio. */
|
|
30
|
+
const PROBE = 'dotrino!'
|
|
31
|
+
|
|
32
|
+
/** Lo que hace falta para hablar con un bucket, más lo que hace falta para el público. */
|
|
33
|
+
const REQUIRED = ['CONTENT_S3_ENDPOINT', 'CONTENT_S3_BUCKET_PRIVATE', 'CONTENT_S3_KEY_ID', 'CONTENT_S3_SECRET']
|
|
34
|
+
const REQUIRED_PUBLIC = ['CONTENT_S3_PUBLIC_KEY_ID', 'CONTENT_S3_PUBLIC_SECRET', 'CONTENT_PUBLIC_BASE_URL']
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Lee la configuración del entorno (que la sirve el vault, §15.14).
|
|
38
|
+
* @param {Record<string,string|undefined>} [env]
|
|
39
|
+
* @returns {{ kind: string, provider: string|null, missing: string[], region: string,
|
|
40
|
+
* endpoint: string, priv: string, pub: string|null, baseUrl: string|null,
|
|
41
|
+
* keyId: string, secret: string, pubKeyId: string, pubSecret: string }}
|
|
42
|
+
*/
|
|
43
|
+
export function storageConfig (env = process.env) {
|
|
44
|
+
const kind = (env.CONTENT_STORAGE || 'local').trim().toLowerCase()
|
|
45
|
+
if (kind === 'local') return { kind: 'local', provider: null, missing: [] , region: '', endpoint: '', priv: '', pub: null, baseUrl: null, keyId: '', secret: '', pubKeyId: '', pubSecret: '' }
|
|
46
|
+
|
|
47
|
+
const provider = PROVIDERS[kind] ? kind : null
|
|
48
|
+
const missing = REQUIRED.filter((k) => !env[k])
|
|
49
|
+
|
|
50
|
+
// El bucket público es OPCIONAL a propósito: un node puede tener bucket solo para lo
|
|
51
|
+
// privado (durabilidad) y seguir sirviendo lo público por la red (§15.3). Pero si lo
|
|
52
|
+
// declara a medias, eso sí es un error — no se publica «casi».
|
|
53
|
+
const wantsPublic = !!env.CONTENT_S3_BUCKET_PUBLIC
|
|
54
|
+
if (wantsPublic) missing.push(...REQUIRED_PUBLIC.filter((k) => !env[k]))
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
kind,
|
|
58
|
+
provider,
|
|
59
|
+
missing,
|
|
60
|
+
region: env.CONTENT_S3_REGION || PROVIDERS[kind]?.region || 'us-east-1',
|
|
61
|
+
endpoint: env.CONTENT_S3_ENDPOINT || '',
|
|
62
|
+
priv: env.CONTENT_S3_BUCKET_PRIVATE || '',
|
|
63
|
+
pub: wantsPublic ? env.CONTENT_S3_BUCKET_PUBLIC : null,
|
|
64
|
+
baseUrl: env.CONTENT_PUBLIC_BASE_URL || null,
|
|
65
|
+
keyId: env.CONTENT_S3_KEY_ID || '',
|
|
66
|
+
secret: env.CONTENT_S3_SECRET || '',
|
|
67
|
+
pubKeyId: env.CONTENT_S3_PUBLIC_KEY_ID || '',
|
|
68
|
+
pubSecret: env.CONTENT_S3_PUBLIC_SECRET || ''
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Las tres comprobaciones del §15.15, las que se rompen SIN DAR ERROR.
|
|
74
|
+
*
|
|
75
|
+
* @param {ReturnType<typeof storageConfig>} cfg
|
|
76
|
+
* @param {{ priv: S3Bucket, pub: S3Bucket|null }} buckets
|
|
77
|
+
* @param {typeof fetch} [f]
|
|
78
|
+
* @returns {Promise<{ ok: boolean, fatal: string[], warn: string[] }>}
|
|
79
|
+
*/
|
|
80
|
+
export async function checkBuckets (cfg, { priv, pub }, f = fetch) {
|
|
81
|
+
const fatal = []
|
|
82
|
+
const warn = []
|
|
83
|
+
|
|
84
|
+
// 1. El mismo bucket para las dos cosas: lo privado acabaría en el que tiene dominio.
|
|
85
|
+
if (cfg.pub && cfg.pub === cfg.priv) {
|
|
86
|
+
fatal.push('the private and public buckets are the same: encrypted blobs would land in the one with a domain')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 2. El privado NO puede responder sin credenciales. Se pide un objeto que no existe:
|
|
90
|
+
// un bucket cerrado contesta 400/401/403 (falta la firma o no autoriza), y uno
|
|
91
|
+
// ABIERTO contesta 404 (no está) — que es lo que delata que cualquiera puede leer
|
|
92
|
+
// los que sí están.
|
|
93
|
+
//
|
|
94
|
+
// LO QUE ESTO NO VE, y hay que decirlo: si alguien conecta un DOMINIO al bucket
|
|
95
|
+
// privado, este sondeo no se entera — pregunta al endpoint de S3, que sigue
|
|
96
|
+
// exigiendo firma aunque el bucket tenga dominio público. Contra eso no hay API:
|
|
97
|
+
// es responsabilidad de quien crea los buckets (§15.15).
|
|
98
|
+
try {
|
|
99
|
+
const r = await f(priv.urlFor('sha256-' + '0'.repeat(64)), { method: 'GET' })
|
|
100
|
+
if (r.status === 404 || r.ok) {
|
|
101
|
+
fatal.push(`the private bucket "${cfg.priv}" answers without credentials (HTTP ${r.status}): it is open to the world`)
|
|
102
|
+
}
|
|
103
|
+
} catch (e) {
|
|
104
|
+
warn.push(`no se pudo comprobar si el bucket privado está cerrado: ${e.message}`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 3. El dominio del público tiene que servir DE VERDAD, y eso no se deduce mirando
|
|
108
|
+
// una respuesta de error: se comprueba subiendo algo y leyéndolo por el dominio.
|
|
109
|
+
//
|
|
110
|
+
// Se intentó antes por las malas —«si el 404 viene en HTML, el dominio no es el
|
|
111
|
+
// bucket»— y se cayó sola: GitHub Pages contesta un 404 en HTML, pero R2 TAMBIÉN
|
|
112
|
+
// contesta el suyo en HTML. Dos cosas indistinguibles por su página de error.
|
|
113
|
+
//
|
|
114
|
+
// La sonda es un objeto de nueve bytes, siempre el mismo (su nombre es su hash, así
|
|
115
|
+
// que subirla dos veces no ensucia nada) y su presencia documenta que el enlace
|
|
116
|
+
// funciona. Cuesta dos peticiones al arrancar.
|
|
117
|
+
if (pub && cfg.baseUrl) {
|
|
118
|
+
try {
|
|
119
|
+
const bytes = Buffer.from(PROBE)
|
|
120
|
+
const cid = 'sha256-' + createHash('sha256').update(bytes).digest('hex')
|
|
121
|
+
await pub.put(cid, bytes, { sha256: cid.slice(7), size: bytes.length, contentType: 'text/plain' })
|
|
122
|
+
const r = await f(`${cfg.baseUrl.replace(/\/+$/, '')}/${cid}`, { method: 'GET' })
|
|
123
|
+
const body = r.ok ? (await r.text()).trim() : ''
|
|
124
|
+
if (body !== PROBE) {
|
|
125
|
+
warn.push(`${cfg.baseUrl} no sirve lo que hay en «${cfg.pub}» (HTTP ${r.status}): revisa que el dominio esté conectado AL BUCKET desde el panel`)
|
|
126
|
+
}
|
|
127
|
+
} catch (e) {
|
|
128
|
+
warn.push(`no se pudo comprobar el camino público: ${e.message}`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { ok: fatal.length === 0, fatal, warn }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Monta el almacén que toca. Devuelve SIEMPRE algo utilizable: si el bucket no está o
|
|
137
|
+
* no pasa las comprobaciones, devuelve el de disco y explica por qué.
|
|
138
|
+
*
|
|
139
|
+
* @param {{ dir: string, env?: any, log?: (m:string)=>void, check?: boolean, fetch?: typeof fetch }} o
|
|
140
|
+
*/
|
|
141
|
+
export async function openStore ({ dir, env = process.env, log = () => {}, check = true, fetch: f = fetch }) {
|
|
142
|
+
const cfg = storageConfig(env)
|
|
143
|
+
if (cfg.kind === 'local') return { store: await new BlobStore(dir).init(), cfg }
|
|
144
|
+
|
|
145
|
+
if (!cfg.provider) {
|
|
146
|
+
log(`[storage] CONTENT_STORAGE="${cfg.kind}" is not a known provider (${Object.keys(PROVIDERS).join(', ')}); staying on LOCAL`)
|
|
147
|
+
return { store: await new BlobStore(dir).init(), cfg }
|
|
148
|
+
}
|
|
149
|
+
if (cfg.missing.length) {
|
|
150
|
+
log(`[storage] ${cfg.kind} requested but these variables are missing: ${cfg.missing.join(', ')}`)
|
|
151
|
+
log('[storage] staying on LOCAL: a half-configured store is worse than none')
|
|
152
|
+
return { store: await new BlobStore(dir).init(), cfg }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const common = { endpoint: cfg.endpoint, region: cfg.region, fetch: f }
|
|
156
|
+
const priv = new S3Bucket({ ...common, bucket: cfg.priv, accessKeyId: cfg.keyId, secretAccessKey: cfg.secret })
|
|
157
|
+
const pub = cfg.pub
|
|
158
|
+
? new S3Bucket({ ...common, bucket: cfg.pub, accessKeyId: cfg.pubKeyId, secretAccessKey: cfg.pubSecret })
|
|
159
|
+
: null
|
|
160
|
+
|
|
161
|
+
if (check) {
|
|
162
|
+
const { ok, fatal, warn } = await checkBuckets(cfg, { priv, pub }, f)
|
|
163
|
+
for (const w of warn) log(`[storage] ⚠ ${w}`)
|
|
164
|
+
if (!ok) {
|
|
165
|
+
for (const e of fatal) log(`[storage] ✖ ${e}`)
|
|
166
|
+
log('[storage] staying on LOCAL until that is fixed')
|
|
167
|
+
return { store: await new BlobStore(dir).init(), cfg }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
log(`[storage] ${cfg.kind}: private "${cfg.priv}"${cfg.pub ? `, public "${cfg.pub}" at ${cfg.baseUrl}` : ' (no public bucket: public reads go over the network)'}`)
|
|
172
|
+
return { store: await new S3BlobStore({ root: dir, priv, pub, log }).init(), cfg }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export default { storageConfig, checkBuckets, openStore, PROVIDERS }
|