lopata 0.14.2 → 0.15.0
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/package.json +5 -1
- package/src/bindings/r2.ts +289 -167
- package/src/cli/dev.ts +44 -0
- package/src/s3/chunked.ts +129 -0
- package/src/s3/headers.ts +131 -0
- package/src/s3/proxy.ts +699 -0
- package/src/s3/xml.ts +348 -0
package/src/cli/dev.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from '../api'
|
|
20
20
|
import { QueuePullConsumer } from '../bindings/queue'
|
|
21
21
|
import type { AckRequest, PullRequest } from '../bindings/queue'
|
|
22
|
+
import type { FileR2Bucket } from '../bindings/r2'
|
|
22
23
|
import { CFWebSocket } from '../bindings/websocket-pair'
|
|
23
24
|
import { autoLoadConfig, loadConfig } from '../config'
|
|
24
25
|
import { handleDashboardRequest } from '../dashboard-serve'
|
|
@@ -28,6 +29,7 @@ import { GenerationManager } from '../generation-manager'
|
|
|
28
29
|
import { loadLopataConfig } from '../lopata-config'
|
|
29
30
|
import { addCfProperty } from '../request-cf'
|
|
30
31
|
import { extractHostname, RouteDispatcher } from '../route-matcher'
|
|
32
|
+
import { handleS3Request, matchS3Path } from '../s3/proxy'
|
|
31
33
|
import { getTraceStore } from '../tracing/store'
|
|
32
34
|
import type { TraceEvent } from '../tracing/types'
|
|
33
35
|
import { WorkerRegistry } from '../worker-registry'
|
|
@@ -260,6 +262,41 @@ export async function run(ctx: CliContext, args: string[]) {
|
|
|
260
262
|
return handleDashboardRequest(request)
|
|
261
263
|
}
|
|
262
264
|
|
|
265
|
+
// S3-compatible proxy: /__s3/{bucket}/{key...} → R2 binding on active worker
|
|
266
|
+
const s3Match = matchS3Path(url.pathname)
|
|
267
|
+
if (s3Match) {
|
|
268
|
+
const targetManager = resolveWorkerParam(url, registry, manager)
|
|
269
|
+
const gen = targetManager.active
|
|
270
|
+
const binding = gen?.env[s3Match.bucket] as FileR2Bucket | undefined
|
|
271
|
+
const resolveBucket = (name: string) => gen?.env[name] as FileR2Bucket | undefined
|
|
272
|
+
const listAllBuckets = () => {
|
|
273
|
+
if (!gen) return []
|
|
274
|
+
const out: Array<{ name: string; creationDate: Date }> = []
|
|
275
|
+
for (const [name, value] of Object.entries(gen.env)) {
|
|
276
|
+
// Duck-typed R2Bucket check — instrumentBinding wraps in a Proxy, so instanceof
|
|
277
|
+
// isn't reliable. R2 bindings are distinguished by having these methods.
|
|
278
|
+
if (
|
|
279
|
+
value
|
|
280
|
+
&& typeof (value as { put?: unknown }).put === 'function'
|
|
281
|
+
&& typeof (value as { head?: unknown }).head === 'function'
|
|
282
|
+
&& typeof (value as { createMultipartUpload?: unknown }).createMultipartUpload === 'function'
|
|
283
|
+
) {
|
|
284
|
+
out.push({ name, creationDate: new Date(0) })
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return out
|
|
288
|
+
}
|
|
289
|
+
const rewritten = new URL(request.url)
|
|
290
|
+
rewritten.pathname = '/' + s3Match.keyPath
|
|
291
|
+
const virtualReq = new Request(rewritten.toString(), {
|
|
292
|
+
method: request.method,
|
|
293
|
+
headers: request.headers,
|
|
294
|
+
body: request.body,
|
|
295
|
+
duplex: 'half',
|
|
296
|
+
})
|
|
297
|
+
return handleS3Request(virtualReq, s3Match.bucket, binding, resolveBucket, listAllBuckets)
|
|
298
|
+
}
|
|
299
|
+
|
|
263
300
|
// Queue pull consumer endpoints: POST /cdn-cgi/handler/queues/<name>/messages/pull and /ack
|
|
264
301
|
const queuePullMatch = url.pathname.match(/^\/cdn-cgi\/handler\/queues\/([^/]+)\/messages\/(pull|ack)$/)
|
|
265
302
|
if (queuePullMatch && request.method === 'POST') {
|
|
@@ -471,6 +508,13 @@ export async function run(ctx: CliContext, args: string[]) {
|
|
|
471
508
|
console.log(`[lopata] Server running at http://${hostname}:${port}`)
|
|
472
509
|
console.log(`[lopata] Dashboard: http://${hostname}:${port}/__dashboard`)
|
|
473
510
|
|
|
511
|
+
if (hostname === '0.0.0.0') {
|
|
512
|
+
console.warn(
|
|
513
|
+
`[lopata] WARNING: S3 endpoint at /__s3/<bucket> is UNAUTHENTICATED and reachable from the network.`,
|
|
514
|
+
)
|
|
515
|
+
console.warn('[lopata] Anyone on your LAN can read and write local R2 buckets.')
|
|
516
|
+
}
|
|
517
|
+
|
|
474
518
|
// Graceful shutdown
|
|
475
519
|
const shutdown = () => {
|
|
476
520
|
console.log('\n[lopata] Shutting down…')
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS sigv4 streaming payload decoder.
|
|
3
|
+
*
|
|
4
|
+
* When an S3 client signs with STREAMING-AWS4-HMAC-SHA256-PAYLOAD (the default
|
|
5
|
+
* in aws-sdk-js v3), the request body is wrapped in chunk framing:
|
|
6
|
+
*
|
|
7
|
+
* <hex-size>;chunk-signature=<hex-sig>\r\n
|
|
8
|
+
* <data bytes, length = hex-size>\r\n
|
|
9
|
+
* ...
|
|
10
|
+
* 0;chunk-signature=<hex-sig>\r\n
|
|
11
|
+
* \r\n
|
|
12
|
+
*
|
|
13
|
+
* This decoder strips the framing and emits only the data bytes. It does NOT
|
|
14
|
+
* verify signatures (lopata's S3 proxy is unauthenticated dev-only).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export function isAwsChunked(headers: Headers): boolean {
|
|
18
|
+
const h = headers.get('x-amz-content-sha256')
|
|
19
|
+
if (!h) return false
|
|
20
|
+
return h === 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD'
|
|
21
|
+
|| h === 'STREAMING-UNSIGNED-PAYLOAD-TRAILER'
|
|
22
|
+
|| h === 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Decode an aws-chunked ReadableStream into a stream of just the data bytes.
|
|
27
|
+
* Buffers incoming bytes, consumes framing lines, and emits data chunks.
|
|
28
|
+
*/
|
|
29
|
+
export function decodeAwsChunked(stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
|
30
|
+
const reader = stream.getReader()
|
|
31
|
+
let buffer = new Uint8Array(0)
|
|
32
|
+
// expecting 'header' (chunk-size;chunk-signature=...) or 'data'
|
|
33
|
+
type State =
|
|
34
|
+
| { kind: 'header' }
|
|
35
|
+
| { kind: 'data'; remaining: number }
|
|
36
|
+
| { kind: 'crlf-after-data' }
|
|
37
|
+
| { kind: 'done' }
|
|
38
|
+
let state: State = { kind: 'header' }
|
|
39
|
+
|
|
40
|
+
function append(chunk: Uint8Array) {
|
|
41
|
+
const merged = new Uint8Array(buffer.length + chunk.length)
|
|
42
|
+
merged.set(buffer, 0)
|
|
43
|
+
merged.set(chunk, buffer.length)
|
|
44
|
+
buffer = merged
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function indexOfCRLF(buf: Uint8Array): number {
|
|
48
|
+
for (let i = 0; i < buf.length - 1; i++) {
|
|
49
|
+
if (buf[i] === 0x0d && buf[i + 1] === 0x0a) return i
|
|
50
|
+
}
|
|
51
|
+
return -1
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return new ReadableStream({
|
|
55
|
+
async pull(controller) {
|
|
56
|
+
while (true) {
|
|
57
|
+
if (state.kind === 'done') {
|
|
58
|
+
controller.close()
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (state.kind === 'header') {
|
|
63
|
+
const crlf = indexOfCRLF(buffer)
|
|
64
|
+
if (crlf === -1) {
|
|
65
|
+
const { done, value } = await reader.read()
|
|
66
|
+
if (done) {
|
|
67
|
+
// Stream ended without proper termination — that's ok if we're empty.
|
|
68
|
+
controller.close()
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
append(value)
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
const line = new TextDecoder().decode(buffer.subarray(0, crlf))
|
|
75
|
+
buffer = buffer.subarray(crlf + 2)
|
|
76
|
+
const sizeHex = line.split(';')[0]!.trim()
|
|
77
|
+
const size = parseInt(sizeHex, 16)
|
|
78
|
+
if (Number.isNaN(size)) {
|
|
79
|
+
controller.error(new Error(`Invalid chunk size: ${line}`))
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
if (size === 0) {
|
|
83
|
+
state = { kind: 'done' }
|
|
84
|
+
controller.close()
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
state = { kind: 'data', remaining: size }
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (state.kind === 'data') {
|
|
92
|
+
if (buffer.length === 0) {
|
|
93
|
+
const { done, value } = await reader.read()
|
|
94
|
+
if (done) {
|
|
95
|
+
controller.error(new Error('Unexpected end of aws-chunked stream inside data'))
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
append(value)
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
const take = Math.min(state.remaining, buffer.length)
|
|
102
|
+
controller.enqueue(buffer.subarray(0, take))
|
|
103
|
+
buffer = buffer.subarray(take)
|
|
104
|
+
state = { kind: 'data', remaining: state.remaining - take }
|
|
105
|
+
if (state.remaining === 0) {
|
|
106
|
+
state = { kind: 'crlf-after-data' }
|
|
107
|
+
}
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (state.kind === 'crlf-after-data') {
|
|
112
|
+
while (buffer.length < 2) {
|
|
113
|
+
const { done, value } = await reader.read()
|
|
114
|
+
if (done) {
|
|
115
|
+
controller.error(new Error('Unexpected end of aws-chunked stream before CRLF'))
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
append(value)
|
|
119
|
+
}
|
|
120
|
+
buffer = buffer.subarray(2)
|
|
121
|
+
state = { kind: 'header' }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
cancel(reason) {
|
|
126
|
+
return reader.cancel(reason)
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { HTTP_METADATA_FIELDS, type R2HTTPMetadata, type R2Object, type R2Range } from '../bindings/r2'
|
|
2
|
+
|
|
3
|
+
export function extractPutOptions(req: Request): {
|
|
4
|
+
httpMetadata: R2HTTPMetadata
|
|
5
|
+
customMetadata: Record<string, string>
|
|
6
|
+
} {
|
|
7
|
+
const h = req.headers
|
|
8
|
+
const httpMetadata: R2HTTPMetadata = {}
|
|
9
|
+
for (const [header, field] of HTTP_METADATA_FIELDS) {
|
|
10
|
+
const v = h.get(header)
|
|
11
|
+
if (!v) continue
|
|
12
|
+
if (field === 'cacheExpiry') {
|
|
13
|
+
const d = new Date(v)
|
|
14
|
+
if (!Number.isNaN(d.getTime())) httpMetadata.cacheExpiry = d
|
|
15
|
+
} else {
|
|
16
|
+
;(httpMetadata[field] as string) = v
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const customMetadata: Record<string, string> = {}
|
|
21
|
+
for (const [k, v] of h) {
|
|
22
|
+
if (k.toLowerCase().startsWith('x-amz-meta-')) {
|
|
23
|
+
customMetadata[k.slice('x-amz-meta-'.length)] = v
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { httpMetadata, customMetadata }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function applyObjectHeaders(obj: R2Object, headers = new Headers()): Headers {
|
|
30
|
+
headers.set('ETag', `"${obj.etag}"`)
|
|
31
|
+
headers.set('Last-Modified', obj.uploaded.toUTCString())
|
|
32
|
+
headers.set('Content-Length', String(obj.size))
|
|
33
|
+
obj.writeHttpMetadata(headers)
|
|
34
|
+
for (const [k, v] of Object.entries(obj.customMetadata)) {
|
|
35
|
+
headers.set(`x-amz-meta-${k}`, v)
|
|
36
|
+
}
|
|
37
|
+
return headers
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse a Range header: bytes=<start>-<end> | bytes=<start>- | bytes=-<suffix>.
|
|
42
|
+
* Returns null if the header is absent or malformed.
|
|
43
|
+
*/
|
|
44
|
+
export function parseRange(header: string | null): R2Range | null {
|
|
45
|
+
if (!header) return null
|
|
46
|
+
const m = header.match(/^bytes=(\d*)-(\d*)$/)
|
|
47
|
+
if (!m) return null
|
|
48
|
+
const startStr = m[1]!
|
|
49
|
+
const endStr = m[2]!
|
|
50
|
+
if (startStr === '' && endStr === '') return null
|
|
51
|
+
if (startStr === '') {
|
|
52
|
+
return { suffix: Number(endStr) }
|
|
53
|
+
}
|
|
54
|
+
const offset = Number(startStr)
|
|
55
|
+
if (endStr === '') return { offset }
|
|
56
|
+
return { offset, length: Number(endStr) - offset + 1 }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Conditional {
|
|
60
|
+
ifMatch?: string[]
|
|
61
|
+
ifNoneMatch?: string[]
|
|
62
|
+
ifModifiedSince?: Date
|
|
63
|
+
ifUnmodifiedSince?: Date
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parseConditional(headers: Headers): Conditional {
|
|
67
|
+
const c: Conditional = {}
|
|
68
|
+
const ifMatch = headers.get('if-match')
|
|
69
|
+
if (ifMatch) c.ifMatch = ifMatch.split(',').map((s) => s.trim().replace(/^"|"$/g, ''))
|
|
70
|
+
const ifNoneMatch = headers.get('if-none-match')
|
|
71
|
+
if (ifNoneMatch) c.ifNoneMatch = ifNoneMatch.split(',').map((s) => s.trim().replace(/^"|"$/g, ''))
|
|
72
|
+
const ims = headers.get('if-modified-since')
|
|
73
|
+
if (ims) {
|
|
74
|
+
const d = new Date(ims)
|
|
75
|
+
if (!Number.isNaN(d.getTime())) c.ifModifiedSince = d
|
|
76
|
+
}
|
|
77
|
+
const iums = headers.get('if-unmodified-since')
|
|
78
|
+
if (iums) {
|
|
79
|
+
const d = new Date(iums)
|
|
80
|
+
if (!Number.isNaN(d.getTime())) c.ifUnmodifiedSince = d
|
|
81
|
+
}
|
|
82
|
+
return c
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Evaluate a conditional against an existing object.
|
|
87
|
+
* Returns 'match' (preconditions met, proceed), 'not-modified' (GET/HEAD 304),
|
|
88
|
+
* or 'precondition-failed' (412).
|
|
89
|
+
*
|
|
90
|
+
* Per RFC 7232 & S3 semantics:
|
|
91
|
+
* If-Match fails → 412
|
|
92
|
+
* If-Unmodified-Since fails → 412
|
|
93
|
+
* If-None-Match matches → 304 for GET/HEAD, 412 for others
|
|
94
|
+
* If-Modified-Since not modified → 304 for GET/HEAD, ignored otherwise
|
|
95
|
+
*/
|
|
96
|
+
export type ConditionalResult = 'match' | 'not-modified' | 'precondition-failed'
|
|
97
|
+
|
|
98
|
+
export function evaluateConditional(
|
|
99
|
+
cond: Conditional,
|
|
100
|
+
obj: { etag: string; uploaded: Date },
|
|
101
|
+
method: 'read' | 'write',
|
|
102
|
+
): ConditionalResult {
|
|
103
|
+
const etag = obj.etag
|
|
104
|
+
// Normalise "uploaded" to second precision — HTTP dates have 1-second resolution.
|
|
105
|
+
const uploadedSec = Math.floor(obj.uploaded.getTime() / 1000) * 1000
|
|
106
|
+
|
|
107
|
+
if (cond.ifMatch) {
|
|
108
|
+
const matches = cond.ifMatch.some((t) => t === '*' || t === etag)
|
|
109
|
+
if (!matches) return 'precondition-failed'
|
|
110
|
+
}
|
|
111
|
+
if (cond.ifUnmodifiedSince) {
|
|
112
|
+
if (uploadedSec > cond.ifUnmodifiedSince.getTime()) return 'precondition-failed'
|
|
113
|
+
}
|
|
114
|
+
if (cond.ifNoneMatch) {
|
|
115
|
+
const matches = cond.ifNoneMatch.some((t) => t === '*' || t === etag)
|
|
116
|
+
if (matches) return method === 'read' ? 'not-modified' : 'precondition-failed'
|
|
117
|
+
}
|
|
118
|
+
if (cond.ifModifiedSince && method === 'read') {
|
|
119
|
+
if (uploadedSec <= cond.ifModifiedSince.getTime()) return 'not-modified'
|
|
120
|
+
}
|
|
121
|
+
return 'match'
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function corsHeaders(origin: string | null): Headers {
|
|
125
|
+
const h = new Headers()
|
|
126
|
+
h.set('Access-Control-Allow-Origin', origin ?? '*')
|
|
127
|
+
h.set('Access-Control-Allow-Methods', 'GET, PUT, POST, HEAD, DELETE, OPTIONS')
|
|
128
|
+
h.set('Access-Control-Allow-Headers', '*')
|
|
129
|
+
h.set('Access-Control-Expose-Headers', '*')
|
|
130
|
+
return h
|
|
131
|
+
}
|