@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/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 }