bunderstack 0.1.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/LICENSE +21 -0
- package/README.md +44 -0
- package/package.json +69 -0
- package/src/access.ts +516 -0
- package/src/auth.ts +102 -0
- package/src/config.ts +152 -0
- package/src/crud.ts +389 -0
- package/src/db.ts +10 -0
- package/src/email.ts +200 -0
- package/src/env.ts +153 -0
- package/src/errors.ts +51 -0
- package/src/handler.ts +53 -0
- package/src/idempotency.ts +102 -0
- package/src/index.ts +368 -0
- package/src/internal-tables.ts +87 -0
- package/src/list-query.ts +413 -0
- package/src/provision.ts +46 -0
- package/src/rate-limit.ts +71 -0
- package/src/realtime/index.ts +245 -0
- package/src/realtime/redis.ts +204 -0
- package/src/schema-export.ts +4 -0
- package/src/scope.ts +17 -0
- package/src/storage/buckets.ts +273 -0
- package/src/storage/delete.ts +27 -0
- package/src/storage/file-meta.ts +224 -0
- package/src/storage/index.ts +31 -0
- package/src/storage/local.ts +69 -0
- package/src/storage/registry.ts +40 -0
- package/src/storage/router.ts +532 -0
- package/src/storage/s3.ts +93 -0
- package/src/storage/sweep.ts +26 -0
- package/src/storage/thumbnails.ts +65 -0
- package/src/trpc.ts +52 -0
- package/src/typeid.ts +116 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
|
2
|
+
// src/storage/router.ts
|
|
3
|
+
import type { Context } from 'hono'
|
|
4
|
+
|
|
5
|
+
import { Hono } from 'hono'
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
import { extname } from 'node:path'
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
AccessContext,
|
|
11
|
+
AuthSessionResolver,
|
|
12
|
+
OperationRule,
|
|
13
|
+
} from '../access'
|
|
14
|
+
import type { BucketStorageRegistry } from './registry'
|
|
15
|
+
|
|
16
|
+
import { checkAccess, resolveSession } from '../access'
|
|
17
|
+
import { ErrorCode, apiError } from '../errors'
|
|
18
|
+
import { deleteFileWithDerivatives } from './delete'
|
|
19
|
+
import {
|
|
20
|
+
deleteFileMetaRow,
|
|
21
|
+
fileMatchesScope,
|
|
22
|
+
getFileMeta,
|
|
23
|
+
insertPendingFile,
|
|
24
|
+
insertReadyFile,
|
|
25
|
+
markFileReady,
|
|
26
|
+
scopeToJson,
|
|
27
|
+
sumReadySize,
|
|
28
|
+
type FileMetaRow,
|
|
29
|
+
} from './file-meta'
|
|
30
|
+
import {
|
|
31
|
+
parseTransformSpec,
|
|
32
|
+
transformHash,
|
|
33
|
+
transformImage,
|
|
34
|
+
} from './thumbnails'
|
|
35
|
+
|
|
36
|
+
export interface BucketStorageRouterOptions {
|
|
37
|
+
registry: BucketStorageRegistry
|
|
38
|
+
db: LibSQLDatabase<Record<string, unknown>>
|
|
39
|
+
auth: AuthSessionResolver | undefined
|
|
40
|
+
/** Default presign TTL (seconds) for PUT/GET URLs. */
|
|
41
|
+
presignExpiresSec?: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const FILE_OWNER_COLUMN = 'ownerId'
|
|
45
|
+
|
|
46
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Wildcard-aware MIME match. No accept list → always true. Matches an exact
|
|
50
|
+
* type or a `prefix/*` wildcard (e.g. `image/*`). An empty `type` fails a
|
|
51
|
+
* non-empty accept list.
|
|
52
|
+
*/
|
|
53
|
+
function matchMime(type: string, accept?: string[]): boolean {
|
|
54
|
+
if (!accept || accept.length === 0) return true
|
|
55
|
+
if (!type) return false
|
|
56
|
+
for (const pattern of accept) {
|
|
57
|
+
if (pattern === type) return true
|
|
58
|
+
if (pattern.endsWith('/*')) {
|
|
59
|
+
const prefix = pattern.slice(0, -1) // keep trailing slash, e.g. "image/"
|
|
60
|
+
if (type.startsWith(prefix)) return true
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Run an access rule. Returns the apiError Response when denied (401 if the
|
|
68
|
+
* rule failed for lack of a session, else 403), or null when allowed.
|
|
69
|
+
*/
|
|
70
|
+
async function gate(
|
|
71
|
+
rule: OperationRule,
|
|
72
|
+
ctx: AccessContext,
|
|
73
|
+
c: Context,
|
|
74
|
+
): Promise<Response | null> {
|
|
75
|
+
const result = await checkAccess(rule, ctx, FILE_OWNER_COLUMN)
|
|
76
|
+
if (result.allowed) return null
|
|
77
|
+
return apiError(
|
|
78
|
+
c,
|
|
79
|
+
ErrorCode.FORBIDDEN,
|
|
80
|
+
'Forbidden',
|
|
81
|
+
result.status === 401 ? 401 : 403,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Build the AccessContext for a request. */
|
|
86
|
+
function buildCtx(
|
|
87
|
+
c: Context,
|
|
88
|
+
user: AccessContext['user'],
|
|
89
|
+
activeOrganizationId: string | null,
|
|
90
|
+
extra: { row?: FileMetaRow; body?: Record<string, unknown> } = {},
|
|
91
|
+
): AccessContext {
|
|
92
|
+
return {
|
|
93
|
+
user,
|
|
94
|
+
request: c.req.raw,
|
|
95
|
+
row: extra.row,
|
|
96
|
+
body: extra.body,
|
|
97
|
+
session: { activeOrganizationId },
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Strip dangerous quote chars from a filename used in a header. */
|
|
102
|
+
function sanitizeFilename(name: string): string {
|
|
103
|
+
return name.replace(/["\\\r\n]/g, '')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
107
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Factory ────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
export function buildBucketStorageRouter(
|
|
113
|
+
opts: BucketStorageRouterOptions,
|
|
114
|
+
): Hono {
|
|
115
|
+
const { registry, db, auth } = opts
|
|
116
|
+
const presignExpiresSec = opts.presignExpiresSec ?? 60
|
|
117
|
+
const router = new Hono()
|
|
118
|
+
|
|
119
|
+
// DECISION — upload path is auto-selected by backend capability, not a user
|
|
120
|
+
// toggle. Presign-capable backends (S3, R2) advertise `presignPut`, so
|
|
121
|
+
// `POST /:bucket/presign` returns `mode:'presign'`: the client PUTs bytes
|
|
122
|
+
// directly to object storage, which avoids buffering the whole file in app
|
|
123
|
+
// memory and offloads upload bandwidth from the app server. Backends without
|
|
124
|
+
// presign (local/dev) return `mode:'proxy'` so `POST /:bucket` (the
|
|
125
|
+
// proxy-upload route below) works with zero setup — at the cost of loading
|
|
126
|
+
// the whole file into memory (see the `file.arrayBuffer()` call there). The
|
|
127
|
+
// two-phase presign flow (presign → direct PUT → confirm) intentionally
|
|
128
|
+
// leaves `pending` rows; the T6 orphan sweep reaps any that never confirm.
|
|
129
|
+
// If two-phase proves too fragile in practice the fallback is presign-only
|
|
130
|
+
// (design model B) — do NOT collapse the dual path here. See design §5.
|
|
131
|
+
|
|
132
|
+
// ─── POST /:bucket/presign — upload init / auto-selection ─────────────────
|
|
133
|
+
router.post('/:bucket/presign', async (c) => {
|
|
134
|
+
const entry = registry.get(c.req.param('bucket'))
|
|
135
|
+
if (!entry) return apiError(c, ErrorCode.NOT_FOUND, 'Unknown bucket', 404)
|
|
136
|
+
const { bucket, adapter } = entry
|
|
137
|
+
|
|
138
|
+
const { user, activeOrganizationId } = await resolveSession(
|
|
139
|
+
auth,
|
|
140
|
+
c.req.raw.headers,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
let body: Record<string, unknown> = {}
|
|
144
|
+
try {
|
|
145
|
+
const parsed = await c.req.json()
|
|
146
|
+
if (isRecord(parsed)) body = parsed
|
|
147
|
+
} catch {
|
|
148
|
+
body = {}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const ctx = buildCtx(c, user, activeOrganizationId, { body })
|
|
152
|
+
const denied = await gate(bucket.access.create, ctx, c)
|
|
153
|
+
if (denied) return denied
|
|
154
|
+
|
|
155
|
+
// No presign capability (e.g. local) → tell client to proxy-upload.
|
|
156
|
+
if (!adapter.presignPut) {
|
|
157
|
+
return c.json(
|
|
158
|
+
{ mode: 'proxy', uploadUrl: `/api/files/${bucket.name}` },
|
|
159
|
+
200,
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const filename =
|
|
164
|
+
typeof body.filename === 'string' ? body.filename : undefined
|
|
165
|
+
const contentType =
|
|
166
|
+
typeof body.contentType === 'string' ? body.contentType : undefined
|
|
167
|
+
|
|
168
|
+
const requesterScope = bucket.scope?.(ctx)
|
|
169
|
+
const scopeJson = scopeToJson(requesterScope)
|
|
170
|
+
|
|
171
|
+
// Quota pre-check: reserve the configured max upload size.
|
|
172
|
+
if (bucket.quota) {
|
|
173
|
+
const reservation = bucket.upload?.maxSizeBytes ?? 0
|
|
174
|
+
const over = await quotaExceeded(
|
|
175
|
+
db,
|
|
176
|
+
bucket.name,
|
|
177
|
+
bucket.quota,
|
|
178
|
+
user?.id,
|
|
179
|
+
scopeJson,
|
|
180
|
+
reservation,
|
|
181
|
+
)
|
|
182
|
+
if (over) {
|
|
183
|
+
return apiError(c, ErrorCode.VALIDATION_ERROR, 'Quota exceeded', 413)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const fileId = `${bucket.name}/${randomUUID()}${extname(filename ?? '')}`
|
|
188
|
+
await insertPendingFile(db, {
|
|
189
|
+
fileId,
|
|
190
|
+
bucket: bucket.name,
|
|
191
|
+
ownerId: user?.id ?? null,
|
|
192
|
+
scopeJson,
|
|
193
|
+
filename: filename ?? null,
|
|
194
|
+
contentType: contentType ?? null,
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const uploadUrl = await adapter.presignPut(fileId, {
|
|
198
|
+
contentType,
|
|
199
|
+
expiresIn: presignExpiresSec,
|
|
200
|
+
})
|
|
201
|
+
const id = fileId.slice(`${bucket.name}/`.length)
|
|
202
|
+
|
|
203
|
+
return c.json(
|
|
204
|
+
{
|
|
205
|
+
mode: 'presign',
|
|
206
|
+
fileId,
|
|
207
|
+
uploadUrl,
|
|
208
|
+
method: 'PUT',
|
|
209
|
+
confirmUrl: `/api/files/${bucket.name}/${id}/confirm`,
|
|
210
|
+
},
|
|
211
|
+
200,
|
|
212
|
+
)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
// ─── POST /:bucket — proxy upload ─────────────────────────────────────────
|
|
216
|
+
router.post('/:bucket', async (c) => {
|
|
217
|
+
const entry = registry.get(c.req.param('bucket'))
|
|
218
|
+
if (!entry) return apiError(c, ErrorCode.NOT_FOUND, 'Unknown bucket', 404)
|
|
219
|
+
const { bucket, adapter } = entry
|
|
220
|
+
|
|
221
|
+
const { user, activeOrganizationId } = await resolveSession(
|
|
222
|
+
auth,
|
|
223
|
+
c.req.raw.headers,
|
|
224
|
+
)
|
|
225
|
+
const ctx = buildCtx(c, user, activeOrganizationId)
|
|
226
|
+
const denied = await gate(bucket.access.create, ctx, c)
|
|
227
|
+
if (denied) return denied
|
|
228
|
+
|
|
229
|
+
const parsed = await c.req.parseBody()
|
|
230
|
+
const file = parsed['file']
|
|
231
|
+
if (!(file instanceof File)) {
|
|
232
|
+
return apiError(
|
|
233
|
+
c,
|
|
234
|
+
ErrorCode.VALIDATION_ERROR,
|
|
235
|
+
'No file field in request',
|
|
236
|
+
400,
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (bucket.upload?.accept && !matchMime(file.type, bucket.upload.accept)) {
|
|
241
|
+
return apiError(
|
|
242
|
+
c,
|
|
243
|
+
ErrorCode.VALIDATION_ERROR,
|
|
244
|
+
`Content type ${file.type || '(none)'} not allowed`,
|
|
245
|
+
422,
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
if (
|
|
249
|
+
bucket.upload?.maxSizeBytes !== undefined &&
|
|
250
|
+
file.size > bucket.upload.maxSizeBytes
|
|
251
|
+
) {
|
|
252
|
+
return apiError(c, ErrorCode.VALIDATION_ERROR, 'File too large', 422)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const requesterScope = bucket.scope?.(ctx)
|
|
256
|
+
const scopeJson = scopeToJson(requesterScope)
|
|
257
|
+
|
|
258
|
+
if (bucket.quota) {
|
|
259
|
+
const over = await quotaExceeded(
|
|
260
|
+
db,
|
|
261
|
+
bucket.name,
|
|
262
|
+
bucket.quota,
|
|
263
|
+
user?.id,
|
|
264
|
+
scopeJson,
|
|
265
|
+
file.size,
|
|
266
|
+
)
|
|
267
|
+
if (over) {
|
|
268
|
+
return apiError(c, ErrorCode.VALIDATION_ERROR, 'Quota exceeded', 413)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const fileId = `${bucket.name}/${randomUUID()}${extname(file.name)}`
|
|
273
|
+
// NOTE: this loads the whole file into app memory — exactly why presign
|
|
274
|
+
// exists for capable backends (see the DECISION block above).
|
|
275
|
+
await adapter.upload(fileId, await file.arrayBuffer(), file.type)
|
|
276
|
+
await insertReadyFile(db, {
|
|
277
|
+
fileId,
|
|
278
|
+
bucket: bucket.name,
|
|
279
|
+
ownerId: user?.id ?? null,
|
|
280
|
+
scopeJson,
|
|
281
|
+
filename: file.name || null,
|
|
282
|
+
contentType: file.type || null,
|
|
283
|
+
size: file.size,
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
const id = fileId.slice(`${bucket.name}/`.length)
|
|
287
|
+
return c.json({ fileId, url: `/api/files/${bucket.name}/${id}` }, 201)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
// ─── POST /:bucket/:id/confirm ────────────────────────────────────────────
|
|
291
|
+
router.post('/:bucket/:id/confirm', async (c) => {
|
|
292
|
+
const bucketName = c.req.param('bucket')
|
|
293
|
+
const entry = registry.get(bucketName)
|
|
294
|
+
if (!entry) return apiError(c, ErrorCode.NOT_FOUND, 'Unknown bucket', 404)
|
|
295
|
+
const { bucket, adapter } = entry
|
|
296
|
+
|
|
297
|
+
const id = c.req.param('id')
|
|
298
|
+
const fileId = `${bucketName}/${id}`
|
|
299
|
+
|
|
300
|
+
const { user } = await resolveSession(auth, c.req.raw.headers)
|
|
301
|
+
|
|
302
|
+
const row = await getFileMeta(db, fileId)
|
|
303
|
+
if (!row || row.bucket !== bucketName) {
|
|
304
|
+
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ownerId-only gate: confirm finalizes a pending upload, so it only checks
|
|
308
|
+
// that the caller owns the row (matching who presigned it). The full
|
|
309
|
+
// `access.create`/`get` rules ran at presign and run again on read.
|
|
310
|
+
if (row.ownerId != null && row.ownerId !== (user?.id ?? null)) {
|
|
311
|
+
return apiError(c, ErrorCode.FORBIDDEN, 'Forbidden', 403)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const url = `/api/files/${bucketName}/${id}`
|
|
315
|
+
|
|
316
|
+
// Idempotent: already confirmed.
|
|
317
|
+
if (row.status === 'ready') {
|
|
318
|
+
return c.json({ fileId, url }, 200)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const info = await adapter.stat?.(fileId)
|
|
322
|
+
if (info == null) {
|
|
323
|
+
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (
|
|
327
|
+
bucket.upload?.maxSizeBytes !== undefined &&
|
|
328
|
+
info.size > bucket.upload.maxSizeBytes
|
|
329
|
+
) {
|
|
330
|
+
await adapter.delete(fileId)
|
|
331
|
+
await deleteFileMetaRow(db, fileId)
|
|
332
|
+
return apiError(c, ErrorCode.VALIDATION_ERROR, 'File too large', 413)
|
|
333
|
+
}
|
|
334
|
+
if (
|
|
335
|
+
bucket.upload?.accept &&
|
|
336
|
+
!matchMime(info.contentType, bucket.upload.accept)
|
|
337
|
+
) {
|
|
338
|
+
await adapter.delete(fileId)
|
|
339
|
+
await deleteFileMetaRow(db, fileId)
|
|
340
|
+
return apiError(
|
|
341
|
+
c,
|
|
342
|
+
ErrorCode.VALIDATION_ERROR,
|
|
343
|
+
`Content type ${info.contentType || '(none)'} not allowed`,
|
|
344
|
+
422,
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Quota reconciliation: presign reserved the worst-case `maxSize`; now we
|
|
349
|
+
// know the real size. The pending row isn't counted by `sumReadySize`
|
|
350
|
+
// (ready-only), so this compares existing usage + the actual bytes.
|
|
351
|
+
if (bucket.quota) {
|
|
352
|
+
const over = await quotaExceeded(
|
|
353
|
+
db,
|
|
354
|
+
bucket.name,
|
|
355
|
+
bucket.quota,
|
|
356
|
+
row.ownerId ?? undefined,
|
|
357
|
+
row.scopeJson,
|
|
358
|
+
info.size,
|
|
359
|
+
)
|
|
360
|
+
if (over) {
|
|
361
|
+
await adapter.delete(fileId)
|
|
362
|
+
await deleteFileMetaRow(db, fileId)
|
|
363
|
+
return apiError(c, ErrorCode.VALIDATION_ERROR, 'Quota exceeded', 413)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
await markFileReady(db, fileId, {
|
|
368
|
+
size: info.size,
|
|
369
|
+
contentType: info.contentType,
|
|
370
|
+
})
|
|
371
|
+
return c.json({ fileId, url }, 200)
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
// ─── GET /:bucket/:id ─────────────────────────────────────────────────────
|
|
375
|
+
router.get('/:bucket/:id', async (c) => {
|
|
376
|
+
const bucketName = c.req.param('bucket')
|
|
377
|
+
const entry = registry.get(bucketName)
|
|
378
|
+
if (!entry) return apiError(c, ErrorCode.NOT_FOUND, 'Unknown bucket', 404)
|
|
379
|
+
const { bucket, adapter } = entry
|
|
380
|
+
|
|
381
|
+
const id = c.req.param('id')
|
|
382
|
+
const fileId = `${bucketName}/${id}`
|
|
383
|
+
|
|
384
|
+
const row = await getFileMeta(db, fileId)
|
|
385
|
+
if (!row || row.status !== 'ready' || row.bucket !== bucketName) {
|
|
386
|
+
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const { user, activeOrganizationId } = await resolveSession(
|
|
390
|
+
auth,
|
|
391
|
+
c.req.raw.headers,
|
|
392
|
+
)
|
|
393
|
+
const ctx = buildCtx(c, user, activeOrganizationId, { row })
|
|
394
|
+
|
|
395
|
+
const denied = await gate(bucket.access.get, ctx, c)
|
|
396
|
+
if (denied) return denied
|
|
397
|
+
|
|
398
|
+
const requesterScope = bucket.scope?.(ctx)
|
|
399
|
+
if (!fileMatchesScope(row, requesterScope)) {
|
|
400
|
+
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const spec = parseTransformSpec(c.req.query())
|
|
404
|
+
if (spec) {
|
|
405
|
+
if (!bucket.transforms) {
|
|
406
|
+
return apiError(
|
|
407
|
+
c,
|
|
408
|
+
ErrorCode.VALIDATION_ERROR,
|
|
409
|
+
'Transforms not enabled for this bucket',
|
|
410
|
+
400,
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
// Proxy-transform path regardless of visibility (we must read + write
|
|
414
|
+
// bytes through the app). Mirrors the legacy on-the-fly transform logic.
|
|
415
|
+
const ext = spec.format ? `.${spec.format}` : extname(fileId) || '.jpg'
|
|
416
|
+
const cacheKey = `${fileId}__transforms/${transformHash(spec)}${ext}`
|
|
417
|
+
|
|
418
|
+
if (await adapter.exists(cacheKey)) {
|
|
419
|
+
const cached = await adapter.get(cacheKey)
|
|
420
|
+
// Re-attach caching headers so cached derivatives match fresh serves
|
|
421
|
+
// (the adapter may not preserve Cache-Control on read).
|
|
422
|
+
const headers = new Headers(cached.headers)
|
|
423
|
+
headers.set('Cache-Control', 'public, max-age=31536000')
|
|
424
|
+
return new Response(cached.body, { status: cached.status, headers })
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const original = await adapter.get(fileId)
|
|
428
|
+
if (original.status === 404) {
|
|
429
|
+
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const inputBuffer = Buffer.from(await original.clone().arrayBuffer())
|
|
433
|
+
const transformed = await transformImage(inputBuffer, spec)
|
|
434
|
+
const contentType = spec.format
|
|
435
|
+
? `image/${spec.format}`
|
|
436
|
+
: (original.headers.get('Content-Type') ?? 'image/jpeg')
|
|
437
|
+
const transformedAb = Uint8Array.from(transformed).buffer
|
|
438
|
+
await adapter.upload(cacheKey, transformedAb, contentType)
|
|
439
|
+
return new Response(transformedAb, {
|
|
440
|
+
headers: {
|
|
441
|
+
'Content-Type': contentType,
|
|
442
|
+
'Cache-Control': 'public, max-age=31536000',
|
|
443
|
+
},
|
|
444
|
+
})
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// No spec — serve by visibility.
|
|
448
|
+
if (bucket.visibility === 'public' && adapter.publicUrlFor) {
|
|
449
|
+
const url = adapter.publicUrlFor(fileId)
|
|
450
|
+
if (url) return c.redirect(url, 302)
|
|
451
|
+
}
|
|
452
|
+
if (bucket.visibility === 'private' && adapter.presignGet) {
|
|
453
|
+
const url = await adapter.presignGet(fileId, {
|
|
454
|
+
expiresIn: presignExpiresSec,
|
|
455
|
+
})
|
|
456
|
+
return c.redirect(url, 302)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Proxy fallback (e.g. local).
|
|
460
|
+
const res = await adapter.get(fileId)
|
|
461
|
+
if (res.status === 404 || !row.filename) return res
|
|
462
|
+
const headers = new Headers(res.headers)
|
|
463
|
+
headers.set(
|
|
464
|
+
'Content-Disposition',
|
|
465
|
+
`inline; filename="${sanitizeFilename(row.filename)}"`,
|
|
466
|
+
)
|
|
467
|
+
return new Response(res.body, { status: res.status, headers })
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
// ─── DELETE /:bucket/:id ──────────────────────────────────────────────────
|
|
471
|
+
router.delete('/:bucket/:id', async (c) => {
|
|
472
|
+
const bucketName = c.req.param('bucket')
|
|
473
|
+
const entry = registry.get(bucketName)
|
|
474
|
+
if (!entry) return apiError(c, ErrorCode.NOT_FOUND, 'Unknown bucket', 404)
|
|
475
|
+
const { bucket, adapter } = entry
|
|
476
|
+
|
|
477
|
+
const id = c.req.param('id')
|
|
478
|
+
const fileId = `${bucketName}/${id}`
|
|
479
|
+
|
|
480
|
+
const row = await getFileMeta(db, fileId)
|
|
481
|
+
if (!row || row.bucket !== bucketName) {
|
|
482
|
+
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const { user, activeOrganizationId } = await resolveSession(
|
|
486
|
+
auth,
|
|
487
|
+
c.req.raw.headers,
|
|
488
|
+
)
|
|
489
|
+
const ctx = buildCtx(c, user, activeOrganizationId, { row })
|
|
490
|
+
|
|
491
|
+
const denied = await gate(bucket.access.delete, ctx, c)
|
|
492
|
+
if (denied) return denied
|
|
493
|
+
|
|
494
|
+
const requesterScope = bucket.scope?.(ctx)
|
|
495
|
+
if (!fileMatchesScope(row, requesterScope)) {
|
|
496
|
+
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Removes the original, any transform-cache derivatives, and the meta row.
|
|
500
|
+
await deleteFileWithDerivatives(adapter, db, fileId)
|
|
501
|
+
return new Response(null, { status: 204 })
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
return router
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ─── Quota helper ─────────────────────────────────────────────────────────────
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Returns true when adding `incoming` bytes would breach any configured quota
|
|
511
|
+
* dimension (perUser uses ownerId; perScope uses scopeJson).
|
|
512
|
+
*/
|
|
513
|
+
async function quotaExceeded(
|
|
514
|
+
db: LibSQLDatabase<Record<string, unknown>>,
|
|
515
|
+
bucket: string,
|
|
516
|
+
quota: { perUserBytes?: number; perScopeBytes?: number },
|
|
517
|
+
ownerId: string | undefined,
|
|
518
|
+
scopeJson: string | null,
|
|
519
|
+
incoming: number,
|
|
520
|
+
): Promise<boolean> {
|
|
521
|
+
// Anonymous uploads (no ownerId) aren't attributed to a per-user bucket, so
|
|
522
|
+
// they intentionally bypass `perUser` here. `perScope` (below) still applies.
|
|
523
|
+
if (quota.perUserBytes !== undefined && ownerId !== undefined) {
|
|
524
|
+
const current = await sumReadySize(db, { bucket, ownerId })
|
|
525
|
+
if (current + incoming > quota.perUserBytes) return true
|
|
526
|
+
}
|
|
527
|
+
if (quota.perScopeBytes !== undefined && scopeJson != null) {
|
|
528
|
+
const current = await sumReadySize(db, { bucket, scopeJson })
|
|
529
|
+
if (current + incoming > quota.perScopeBytes) return true
|
|
530
|
+
}
|
|
531
|
+
return false
|
|
532
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// src/storage/s3.ts
|
|
2
|
+
import type {
|
|
3
|
+
PresignGetOptions,
|
|
4
|
+
PresignPutOptions,
|
|
5
|
+
StorageAdapter,
|
|
6
|
+
} from './index'
|
|
7
|
+
|
|
8
|
+
interface S3Config {
|
|
9
|
+
bucket: string
|
|
10
|
+
region: string
|
|
11
|
+
accessKeyId: string
|
|
12
|
+
secretAccessKey: string
|
|
13
|
+
endpoint?: string
|
|
14
|
+
publicUrl?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class S3StorageAdapter implements StorageAdapter {
|
|
18
|
+
private client: InstanceType<typeof Bun.S3Client>
|
|
19
|
+
private readonly publicUrl?: string
|
|
20
|
+
|
|
21
|
+
constructor(cfg: S3Config) {
|
|
22
|
+
this.client = new Bun.S3Client({
|
|
23
|
+
bucket: cfg.bucket,
|
|
24
|
+
region: cfg.region,
|
|
25
|
+
accessKeyId: cfg.accessKeyId,
|
|
26
|
+
secretAccessKey: cfg.secretAccessKey,
|
|
27
|
+
...(cfg.endpoint && { endpoint: cfg.endpoint }),
|
|
28
|
+
})
|
|
29
|
+
this.publicUrl = cfg.publicUrl
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async upload(
|
|
33
|
+
fileId: string,
|
|
34
|
+
data: Blob | ArrayBuffer,
|
|
35
|
+
contentType: string,
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
const bytes = data instanceof Blob ? await data.arrayBuffer() : data
|
|
38
|
+
await this.client.write(fileId, bytes, { type: contentType })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async get(fileId: string): Promise<Response> {
|
|
42
|
+
const exists = await this.client.exists(fileId)
|
|
43
|
+
if (!exists) return new Response('Not found', { status: 404 })
|
|
44
|
+
const file = this.client.file(fileId)
|
|
45
|
+
return new Response(file.stream(), {
|
|
46
|
+
headers: { 'Content-Type': file.type ?? 'application/octet-stream' },
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async delete(fileId: string): Promise<void> {
|
|
51
|
+
await this.client.delete(fileId)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async exists(fileId: string): Promise<boolean> {
|
|
55
|
+
return this.client.exists(fileId)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async presignPut(key: string, opts: PresignPutOptions): Promise<string> {
|
|
59
|
+
return this.client.presign(key, {
|
|
60
|
+
method: 'PUT',
|
|
61
|
+
expiresIn: opts.expiresIn,
|
|
62
|
+
...(opts.contentType && { type: opts.contentType }),
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async presignGet(key: string, opts: PresignGetOptions): Promise<string> {
|
|
67
|
+
return this.client.presign(key, {
|
|
68
|
+
method: 'GET',
|
|
69
|
+
expiresIn: opts.expiresIn,
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async stat(
|
|
74
|
+
key: string,
|
|
75
|
+
): Promise<{ size: number; contentType: string } | null> {
|
|
76
|
+
try {
|
|
77
|
+
const s = await this.client.stat(key)
|
|
78
|
+
return { size: s.size, contentType: s.type }
|
|
79
|
+
} catch {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
publicUrlFor(key: string): string | undefined {
|
|
85
|
+
if (!this.publicUrl) return undefined
|
|
86
|
+
return `${this.publicUrl.replace(/\/$/, '')}/${key}`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async list(prefix: string): Promise<string[]> {
|
|
90
|
+
const res = await this.client.list({ prefix })
|
|
91
|
+
return res.contents?.map((c) => c.key) ?? []
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// src/storage/sweep.ts
|
|
2
|
+
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
|
3
|
+
|
|
4
|
+
import type { BucketStorageRegistry } from './registry'
|
|
5
|
+
|
|
6
|
+
import { deleteFileMetaRow, listStalePendingFiles } from './file-meta'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Reap stale `pending` file-meta rows (two-phase uploads that never confirmed)
|
|
10
|
+
* older than `olderThanMs`. For each, best-effort delete the object (it may
|
|
11
|
+
* never have landed) then remove the meta row. Returns the number reaped.
|
|
12
|
+
*/
|
|
13
|
+
export async function sweepOrphans(
|
|
14
|
+
registry: BucketStorageRegistry,
|
|
15
|
+
db: LibSQLDatabase<Record<string, unknown>>,
|
|
16
|
+
olderThanMs: number,
|
|
17
|
+
): Promise<number> {
|
|
18
|
+
const cutoff = Date.now() - olderThanMs
|
|
19
|
+
const rows = await listStalePendingFiles(db, cutoff)
|
|
20
|
+
for (const row of rows) {
|
|
21
|
+
const adapter = registry.get(row.bucket)?.adapter
|
|
22
|
+
await adapter?.delete(row.fileId).catch(() => {})
|
|
23
|
+
await deleteFileMetaRow(db, row.fileId)
|
|
24
|
+
}
|
|
25
|
+
return rows.length
|
|
26
|
+
}
|