bunderstack 0.15.2 → 0.17.0-beta.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/README.md +25 -138
- package/package.json +22 -14
- package/src/access.ts +24 -1
- package/src/api/api-types.types.ts +106 -0
- package/src/api/builder.ts +52 -0
- package/src/api/context.ts +83 -0
- package/src/api/crud-router.ts +321 -0
- package/src/api/openapi.ts +184 -0
- package/src/api/realtime-router.ts +75 -0
- package/src/api/registry.ts +338 -0
- package/src/api/router.ts +34 -0
- package/src/api/storage-router.ts +224 -0
- package/src/api/types.ts +84 -0
- package/src/auth.ts +5 -0
- package/src/blueprint.ts +88 -105
- package/src/config.ts +73 -77
- package/src/cron.ts +2 -1
- package/src/crud-operations.ts +488 -0
- package/src/dialect.ts +1 -1
- package/src/env.ts +28 -21
- package/src/errors.ts +90 -23
- package/src/handler.ts +16 -44
- package/src/index.ts +283 -294
- package/src/internal-tables-pg.ts +1 -17
- package/src/internal-tables.ts +0 -31
- package/src/jobs/define.ts +75 -21
- package/src/jobs/index.ts +3 -9
- package/src/jobs/queue.ts +14 -6
- package/src/jobs/slots.ts +52 -0
- package/src/jobs/worker.ts +142 -42
- package/src/manifest.ts +84 -93
- package/src/realtime/facade.ts +16 -13
- package/src/realtime/filter.ts +77 -0
- package/src/realtime/heartbeat.ts +80 -0
- package/src/realtime/publisher.ts +46 -0
- package/src/standard-schema.ts +59 -0
- package/src/storage/index.ts +8 -0
- package/src/storage/operations.ts +398 -0
- package/src/crud.ts +0 -408
- package/src/jobs/cron-auth.ts +0 -28
- package/src/jobs/cron-router.ts +0 -135
- package/src/jobs/cron-runner.ts +0 -224
- package/src/jobs/local-cron.ts +0 -78
- package/src/realtime/index.ts +0 -250
- package/src/realtime/redis.ts +0 -228
- package/src/storage/router.ts +0 -531
- package/src/trpc.ts +0 -57
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export const REALTIME_HEARTBEAT_INTERVAL_MS = 5_000
|
|
2
|
+
|
|
3
|
+
export type RealtimeHeartbeat = { type: 'heartbeat' }
|
|
4
|
+
|
|
5
|
+
type SourceState<T> =
|
|
6
|
+
| { status: 'pending' }
|
|
7
|
+
| { status: 'ready'; result: IteratorResult<T> }
|
|
8
|
+
| { status: 'error'; error: unknown }
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Emits a transport-only event whenever the source has been idle for an
|
|
12
|
+
* interval. Heartbeats are deliberately not published, persisted, or assigned
|
|
13
|
+
* event IDs, so they do not affect replay and resume semantics.
|
|
14
|
+
*/
|
|
15
|
+
export async function* withRealtimeHeartbeat<T>(
|
|
16
|
+
source: AsyncIterable<T>,
|
|
17
|
+
options: { intervalMs?: number; signal?: AbortSignal },
|
|
18
|
+
): AsyncGenerator<T | RealtimeHeartbeat, void, void> {
|
|
19
|
+
const intervalMs = Math.max(
|
|
20
|
+
1,
|
|
21
|
+
options.intervalMs ?? REALTIME_HEARTBEAT_INTERVAL_MS,
|
|
22
|
+
)
|
|
23
|
+
const iterator = source[Symbol.asyncIterator]()
|
|
24
|
+
let state: SourceState<T> = { status: 'pending' }
|
|
25
|
+
let wake: (() => void) | undefined
|
|
26
|
+
const getState = (): SourceState<T> => state
|
|
27
|
+
|
|
28
|
+
const requestNext = () => {
|
|
29
|
+
state = { status: 'pending' }
|
|
30
|
+
void iterator.next().then(
|
|
31
|
+
(result) => {
|
|
32
|
+
state = { status: 'ready', result }
|
|
33
|
+
wake?.()
|
|
34
|
+
},
|
|
35
|
+
(error: unknown) => {
|
|
36
|
+
state = { status: 'error', error }
|
|
37
|
+
wake?.()
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
requestNext()
|
|
43
|
+
try {
|
|
44
|
+
while (!options.signal?.aborted) {
|
|
45
|
+
let current = getState()
|
|
46
|
+
if (current.status === 'pending') {
|
|
47
|
+
await new Promise<void>((resolve) => {
|
|
48
|
+
let settled = false
|
|
49
|
+
const finish = () => {
|
|
50
|
+
if (settled) return
|
|
51
|
+
settled = true
|
|
52
|
+
clearTimeout(timer)
|
|
53
|
+
options.signal?.removeEventListener('abort', finish)
|
|
54
|
+
resolve()
|
|
55
|
+
}
|
|
56
|
+
const timer = setTimeout(finish, intervalMs)
|
|
57
|
+
options.signal?.addEventListener('abort', finish, { once: true })
|
|
58
|
+
wake = finish
|
|
59
|
+
})
|
|
60
|
+
wake = undefined
|
|
61
|
+
|
|
62
|
+
if (options.signal?.aborted) break
|
|
63
|
+
current = getState()
|
|
64
|
+
if (current.status === 'pending') {
|
|
65
|
+
yield { type: 'heartbeat' }
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (current.status === 'error') throw current.error
|
|
71
|
+
if (current.result.done) break
|
|
72
|
+
|
|
73
|
+
yield current.result.value
|
|
74
|
+
requestNext()
|
|
75
|
+
}
|
|
76
|
+
} finally {
|
|
77
|
+
wake = undefined
|
|
78
|
+
await iterator.return?.()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Publisher } from '@orpc/publisher'
|
|
2
|
+
import type { RedisClient } from 'bun'
|
|
3
|
+
|
|
4
|
+
import { BunRedisPublisher } from '@orpc/bun'
|
|
5
|
+
import { MemoryPublisher } from '@orpc/publisher/memory'
|
|
6
|
+
|
|
7
|
+
export type RealtimeAction = 'create' | 'update' | 'delete'
|
|
8
|
+
|
|
9
|
+
export interface RealtimeChange {
|
|
10
|
+
table: string
|
|
11
|
+
action: RealtimeAction
|
|
12
|
+
record: Record<string, unknown>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RealtimeEvents extends Record<string, object> {
|
|
16
|
+
change: RealtimeChange
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type RealtimePublisher = Publisher<RealtimeEvents>
|
|
20
|
+
|
|
21
|
+
export interface RealtimePublisherOptions {
|
|
22
|
+
maxBufferedEvents?: number
|
|
23
|
+
resumeSeconds?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createMemoryRealtimePublisher(
|
|
27
|
+
options: RealtimePublisherOptions = {},
|
|
28
|
+
): RealtimePublisher {
|
|
29
|
+
return new MemoryPublisher<RealtimeEvents>({
|
|
30
|
+
maxBufferedEvents: options.maxBufferedEvents,
|
|
31
|
+
resume: { enabled: true, seconds: options.resumeSeconds ?? 300 },
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createRedisRealtimePublisher(
|
|
36
|
+
redis: RedisClient,
|
|
37
|
+
subscriber: RedisClient | Promise<RedisClient>,
|
|
38
|
+
options: RealtimePublisherOptions & { prefix?: string } = {},
|
|
39
|
+
): RealtimePublisher {
|
|
40
|
+
return new BunRedisPublisher<RealtimeEvents>(redis, {
|
|
41
|
+
subscriber,
|
|
42
|
+
prefix: options.prefix ?? 'bunderstack:',
|
|
43
|
+
maxBufferedEvents: options.maxBufferedEvents,
|
|
44
|
+
resume: { enabled: true, seconds: options.resumeSeconds ?? 300 },
|
|
45
|
+
})
|
|
46
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
2
|
+
|
|
3
|
+
export type StandardSchema = StandardSchemaV1
|
|
4
|
+
export type InferStandardOutput<TSchema extends StandardSchemaV1> =
|
|
5
|
+
StandardSchemaV1.InferOutput<TSchema>
|
|
6
|
+
|
|
7
|
+
export type StandardSchemaIssue = {
|
|
8
|
+
path: PropertyKey[]
|
|
9
|
+
message: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class StandardSchemaValidationError extends Error {
|
|
13
|
+
readonly issues: StandardSchemaIssue[]
|
|
14
|
+
|
|
15
|
+
constructor(label: string, issues: readonly StandardSchemaV1.Issue[]) {
|
|
16
|
+
const normalized = issues.map((issue) => ({
|
|
17
|
+
path: [...(issue.path ?? [])].map((segment) =>
|
|
18
|
+
typeof segment === 'object' ? segment.key : segment,
|
|
19
|
+
),
|
|
20
|
+
message: issue.message,
|
|
21
|
+
}))
|
|
22
|
+
super(
|
|
23
|
+
normalized
|
|
24
|
+
.map((issue) => {
|
|
25
|
+
const path = issue.path.map(String).join('.')
|
|
26
|
+
return `${label}${path ? `.${path}` : ''}: ${issue.message}`
|
|
27
|
+
})
|
|
28
|
+
.join('\n'),
|
|
29
|
+
)
|
|
30
|
+
this.name = 'StandardSchemaValidationError'
|
|
31
|
+
this.issues = normalized
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
36
|
+
return (
|
|
37
|
+
typeof value === 'object' &&
|
|
38
|
+
value !== null &&
|
|
39
|
+
'then' in value &&
|
|
40
|
+
typeof value.then === 'function'
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function validateStandardSchema<TSchema extends StandardSchemaV1>(
|
|
45
|
+
schema: TSchema,
|
|
46
|
+
value: unknown,
|
|
47
|
+
label: string,
|
|
48
|
+
): StandardSchemaV1.InferOutput<TSchema> {
|
|
49
|
+
const result = schema['~standard'].validate(value)
|
|
50
|
+
if (isPromiseLike(result)) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`[bunderstack] ${label} schema validation must be synchronous`,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
if (result.issues) {
|
|
56
|
+
throw new StandardSchemaValidationError(label, result.issues)
|
|
57
|
+
}
|
|
58
|
+
return result.value
|
|
59
|
+
}
|
package/src/storage/index.ts
CHANGED
|
@@ -3,6 +3,14 @@ import { LocalStorageAdapter } from './local'
|
|
|
3
3
|
import { S3StorageAdapter } from './s3'
|
|
4
4
|
|
|
5
5
|
export type { LocalStorageAdapter, S3StorageAdapter }
|
|
6
|
+
export { createStorageOperations } from './operations'
|
|
7
|
+
export type {
|
|
8
|
+
PrepareUploadResult,
|
|
9
|
+
StorageDownload,
|
|
10
|
+
StorageExecutionContext,
|
|
11
|
+
StorageOperations,
|
|
12
|
+
StorageOperationsOptions,
|
|
13
|
+
} from './operations'
|
|
6
14
|
|
|
7
15
|
export interface PresignPutOptions {
|
|
8
16
|
contentType?: string
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { extname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { AccessContext, OperationRule } from '../access'
|
|
5
|
+
import type { AnyDb } from '../dialect'
|
|
6
|
+
import type { BucketStorageRegistry } from './registry'
|
|
7
|
+
|
|
8
|
+
import { checkAccess } from '../access'
|
|
9
|
+
import { BunderstackError } from '../errors'
|
|
10
|
+
import { deleteFileWithDerivatives } from './delete'
|
|
11
|
+
import {
|
|
12
|
+
deleteFileMetaRow,
|
|
13
|
+
fileMatchesScope,
|
|
14
|
+
getFileMeta,
|
|
15
|
+
insertPendingFile,
|
|
16
|
+
insertReadyFile,
|
|
17
|
+
markFileReady,
|
|
18
|
+
scopeToJson,
|
|
19
|
+
sumReadySize,
|
|
20
|
+
type FileMetaRow,
|
|
21
|
+
} from './file-meta'
|
|
22
|
+
import { parseTransformSpec, transformHash, transformImage } from './thumbnails'
|
|
23
|
+
|
|
24
|
+
export interface StorageExecutionContext {
|
|
25
|
+
request: Request
|
|
26
|
+
user: AccessContext['user']
|
|
27
|
+
session: { activeOrganizationId: string | null }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StorageOperationsOptions {
|
|
31
|
+
registry: BucketStorageRegistry
|
|
32
|
+
db: AnyDb
|
|
33
|
+
presignExpiresSec?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type PrepareUploadResult =
|
|
37
|
+
| { mode: 'proxy'; uploadUrl: string }
|
|
38
|
+
| {
|
|
39
|
+
mode: 'presign'
|
|
40
|
+
fileId: string
|
|
41
|
+
uploadUrl: string
|
|
42
|
+
method: 'PUT'
|
|
43
|
+
confirmUrl: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type StorageDownload =
|
|
47
|
+
| { kind: 'redirect'; status: 302; url: string }
|
|
48
|
+
| {
|
|
49
|
+
kind: 'body'
|
|
50
|
+
status: number
|
|
51
|
+
body: ConstructorParameters<typeof Response>[0]
|
|
52
|
+
headers: Headers
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const FILE_OWNER_COLUMN = 'ownerId'
|
|
56
|
+
|
|
57
|
+
function matchMime(type: string, accept?: string[]): boolean {
|
|
58
|
+
if (!accept || accept.length === 0) return true
|
|
59
|
+
if (!type) return false
|
|
60
|
+
return accept.some(
|
|
61
|
+
(pattern) =>
|
|
62
|
+
pattern === type ||
|
|
63
|
+
(pattern.endsWith('/*') && type.startsWith(pattern.slice(0, -1))),
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function accessContext(
|
|
68
|
+
context: StorageExecutionContext,
|
|
69
|
+
extra: { row?: FileMetaRow; body?: Record<string, unknown> } = {},
|
|
70
|
+
): AccessContext {
|
|
71
|
+
return {
|
|
72
|
+
request: context.request,
|
|
73
|
+
user: context.user,
|
|
74
|
+
session: context.session,
|
|
75
|
+
row: extra.row,
|
|
76
|
+
body: extra.body,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function gate(rule: OperationRule, context: AccessContext) {
|
|
81
|
+
const result = await checkAccess(rule, context, FILE_OWNER_COLUMN)
|
|
82
|
+
if (!result.allowed) {
|
|
83
|
+
throw new BunderstackError(
|
|
84
|
+
result.status === 401 ? 'UNAUTHORIZED' : 'FORBIDDEN',
|
|
85
|
+
result.status === 401 ? 'Authentication required' : 'Forbidden',
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function notFound(message = 'Not found'): never {
|
|
91
|
+
throw new BunderstackError('NOT_FOUND', message)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function sanitizeFilename(name: string): string {
|
|
95
|
+
return name.replace(/["\\\r\n]/g, '')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function quotaExceeded(
|
|
99
|
+
db: AnyDb,
|
|
100
|
+
bucket: string,
|
|
101
|
+
quota: { perUserBytes?: number; perScopeBytes?: number },
|
|
102
|
+
ownerId: string | undefined,
|
|
103
|
+
scopeJson: string | null,
|
|
104
|
+
incoming: number,
|
|
105
|
+
): Promise<boolean> {
|
|
106
|
+
if (quota.perUserBytes !== undefined && ownerId !== undefined) {
|
|
107
|
+
const current = await sumReadySize(db, { bucket, ownerId })
|
|
108
|
+
if (current + incoming > quota.perUserBytes) return true
|
|
109
|
+
}
|
|
110
|
+
if (quota.perScopeBytes !== undefined && scopeJson != null) {
|
|
111
|
+
const current = await sumReadySize(db, { bucket, scopeJson })
|
|
112
|
+
if (current + incoming > quota.perScopeBytes) return true
|
|
113
|
+
}
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function createStorageOperations(options: StorageOperationsOptions) {
|
|
118
|
+
const { registry, db } = options
|
|
119
|
+
const presignExpiresSec = options.presignExpiresSec ?? 60
|
|
120
|
+
|
|
121
|
+
const bucketEntry = (name: string) => {
|
|
122
|
+
const entry = registry.get(name)
|
|
123
|
+
if (!entry) notFound('Unknown bucket')
|
|
124
|
+
return entry
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
async prepareUpload(
|
|
129
|
+
bucketName: string,
|
|
130
|
+
body: { filename?: string; contentType?: string },
|
|
131
|
+
context: StorageExecutionContext,
|
|
132
|
+
): Promise<PrepareUploadResult> {
|
|
133
|
+
const { bucket, adapter } = bucketEntry(bucketName)
|
|
134
|
+
const ctx = accessContext(context, { body })
|
|
135
|
+
await gate(bucket.access.create, ctx)
|
|
136
|
+
|
|
137
|
+
if (!adapter.presignPut) {
|
|
138
|
+
return { mode: 'proxy', uploadUrl: `/api/files/${bucket.name}` }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const scopeJson = scopeToJson(bucket.writeScope?.(ctx))
|
|
142
|
+
if (
|
|
143
|
+
bucket.quota &&
|
|
144
|
+
(await quotaExceeded(
|
|
145
|
+
db,
|
|
146
|
+
bucket.name,
|
|
147
|
+
bucket.quota,
|
|
148
|
+
context.user?.id,
|
|
149
|
+
scopeJson,
|
|
150
|
+
bucket.upload?.maxSizeBytes ?? 0,
|
|
151
|
+
))
|
|
152
|
+
) {
|
|
153
|
+
throw new BunderstackError('PAYLOAD_TOO_LARGE', 'Quota exceeded')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const fileId = `${bucket.name}/${randomUUID()}${extname(body.filename ?? '')}`
|
|
157
|
+
await insertPendingFile(db, {
|
|
158
|
+
fileId,
|
|
159
|
+
bucket: bucket.name,
|
|
160
|
+
ownerId: context.user?.id ?? null,
|
|
161
|
+
scopeJson,
|
|
162
|
+
filename: body.filename ?? null,
|
|
163
|
+
contentType: body.contentType ?? null,
|
|
164
|
+
})
|
|
165
|
+
const uploadUrl = await adapter.presignPut(fileId, {
|
|
166
|
+
contentType: body.contentType,
|
|
167
|
+
expiresIn: presignExpiresSec,
|
|
168
|
+
})
|
|
169
|
+
const id = fileId.slice(`${bucket.name}/`.length)
|
|
170
|
+
return {
|
|
171
|
+
mode: 'presign',
|
|
172
|
+
fileId,
|
|
173
|
+
uploadUrl,
|
|
174
|
+
method: 'PUT',
|
|
175
|
+
confirmUrl: `/api/files/${bucket.name}/${id}/confirm`,
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
async upload(
|
|
180
|
+
bucketName: string,
|
|
181
|
+
file: File,
|
|
182
|
+
context: StorageExecutionContext,
|
|
183
|
+
) {
|
|
184
|
+
const { bucket, adapter } = bucketEntry(bucketName)
|
|
185
|
+
const ctx = accessContext(context)
|
|
186
|
+
await gate(bucket.access.create, ctx)
|
|
187
|
+
|
|
188
|
+
if (!matchMime(file.type, bucket.upload?.accept)) {
|
|
189
|
+
throw new BunderstackError(
|
|
190
|
+
'VALIDATION_ERROR',
|
|
191
|
+
`Content type ${file.type || '(none)'} not allowed`,
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
if (
|
|
195
|
+
bucket.upload?.maxSizeBytes !== undefined &&
|
|
196
|
+
file.size > bucket.upload.maxSizeBytes
|
|
197
|
+
) {
|
|
198
|
+
throw new BunderstackError('PAYLOAD_TOO_LARGE', 'File too large')
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const scopeJson = scopeToJson(bucket.readScope?.(ctx))
|
|
202
|
+
if (
|
|
203
|
+
bucket.quota &&
|
|
204
|
+
(await quotaExceeded(
|
|
205
|
+
db,
|
|
206
|
+
bucket.name,
|
|
207
|
+
bucket.quota,
|
|
208
|
+
context.user?.id,
|
|
209
|
+
scopeJson,
|
|
210
|
+
file.size,
|
|
211
|
+
))
|
|
212
|
+
) {
|
|
213
|
+
throw new BunderstackError('PAYLOAD_TOO_LARGE', 'Quota exceeded')
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const fileId = `${bucket.name}/${randomUUID()}${extname(file.name)}`
|
|
217
|
+
await adapter.upload(fileId, await file.arrayBuffer(), file.type)
|
|
218
|
+
await insertReadyFile(db, {
|
|
219
|
+
fileId,
|
|
220
|
+
bucket: bucket.name,
|
|
221
|
+
ownerId: context.user?.id ?? null,
|
|
222
|
+
scopeJson,
|
|
223
|
+
filename: file.name || null,
|
|
224
|
+
contentType: file.type || null,
|
|
225
|
+
size: file.size,
|
|
226
|
+
})
|
|
227
|
+
const id = fileId.slice(`${bucket.name}/`.length)
|
|
228
|
+
return {
|
|
229
|
+
status: 201 as const,
|
|
230
|
+
fileId,
|
|
231
|
+
url: `/api/files/${bucket.name}/${id}`,
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
async confirmUpload(
|
|
236
|
+
bucketName: string,
|
|
237
|
+
id: string,
|
|
238
|
+
context: StorageExecutionContext,
|
|
239
|
+
) {
|
|
240
|
+
const { bucket, adapter } = bucketEntry(bucketName)
|
|
241
|
+
const fileId = `${bucketName}/${id}`
|
|
242
|
+
const row = await getFileMeta(db, fileId)
|
|
243
|
+
if (!row || row.bucket !== bucketName) notFound()
|
|
244
|
+
if (row.ownerId != null && row.ownerId !== (context.user?.id ?? null)) {
|
|
245
|
+
throw new BunderstackError('FORBIDDEN', 'Forbidden')
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const result = { fileId, url: `/api/files/${bucketName}/${id}` }
|
|
249
|
+
if (row.status === 'ready') return result
|
|
250
|
+
|
|
251
|
+
const info = await adapter.stat?.(fileId)
|
|
252
|
+
if (!info) notFound()
|
|
253
|
+
|
|
254
|
+
const rejectUpload = async (message: string, validation = false) => {
|
|
255
|
+
await adapter.delete(fileId)
|
|
256
|
+
await deleteFileMetaRow(db, fileId)
|
|
257
|
+
throw new BunderstackError(
|
|
258
|
+
validation ? 'VALIDATION_ERROR' : 'PAYLOAD_TOO_LARGE',
|
|
259
|
+
message,
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
if (
|
|
263
|
+
bucket.upload?.maxSizeBytes !== undefined &&
|
|
264
|
+
info.size > bucket.upload.maxSizeBytes
|
|
265
|
+
) {
|
|
266
|
+
await rejectUpload('File too large')
|
|
267
|
+
}
|
|
268
|
+
if (!matchMime(info.contentType, bucket.upload?.accept)) {
|
|
269
|
+
await rejectUpload(
|
|
270
|
+
`Content type ${info.contentType || '(none)'} not allowed`,
|
|
271
|
+
true,
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
if (
|
|
275
|
+
bucket.quota &&
|
|
276
|
+
(await quotaExceeded(
|
|
277
|
+
db,
|
|
278
|
+
bucket.name,
|
|
279
|
+
bucket.quota,
|
|
280
|
+
row.ownerId ?? undefined,
|
|
281
|
+
row.scopeJson,
|
|
282
|
+
info.size,
|
|
283
|
+
))
|
|
284
|
+
) {
|
|
285
|
+
await rejectUpload('Quota exceeded')
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
await markFileReady(db, fileId, info)
|
|
289
|
+
return result
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
async download(
|
|
293
|
+
bucketName: string,
|
|
294
|
+
id: string,
|
|
295
|
+
query: Record<string, string>,
|
|
296
|
+
context: StorageExecutionContext,
|
|
297
|
+
): Promise<StorageDownload> {
|
|
298
|
+
const { bucket, adapter } = bucketEntry(bucketName)
|
|
299
|
+
const fileId = `${bucketName}/${id}`
|
|
300
|
+
const row = await getFileMeta(db, fileId)
|
|
301
|
+
if (!row || row.status !== 'ready' || row.bucket !== bucketName) notFound()
|
|
302
|
+
|
|
303
|
+
const ctx = accessContext(context, { row })
|
|
304
|
+
await gate(bucket.access.get, ctx)
|
|
305
|
+
if (!fileMatchesScope(row, bucket.readScope?.(ctx))) notFound()
|
|
306
|
+
|
|
307
|
+
const spec = parseTransformSpec(query)
|
|
308
|
+
if (spec) {
|
|
309
|
+
if (!bucket.transforms) {
|
|
310
|
+
throw new BunderstackError(
|
|
311
|
+
'VALIDATION_ERROR',
|
|
312
|
+
'Transforms not enabled for this bucket',
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
const ext = spec.format ? `.${spec.format}` : extname(fileId) || '.jpg'
|
|
316
|
+
const cacheKey = `${fileId}__transforms/${transformHash(spec)}${ext}`
|
|
317
|
+
if (await adapter.exists(cacheKey)) {
|
|
318
|
+
const cached = await adapter.get(cacheKey)
|
|
319
|
+
const headers = new Headers(cached.headers)
|
|
320
|
+
headers.set('Cache-Control', 'public, max-age=31536000')
|
|
321
|
+
return {
|
|
322
|
+
kind: 'body',
|
|
323
|
+
status: cached.status,
|
|
324
|
+
body: cached.body,
|
|
325
|
+
headers,
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const original = await adapter.get(fileId)
|
|
330
|
+
if (original.status === 404) notFound()
|
|
331
|
+
const transformed = await transformImage(
|
|
332
|
+
Buffer.from(await original.arrayBuffer()),
|
|
333
|
+
spec,
|
|
334
|
+
)
|
|
335
|
+
const contentType = spec.format
|
|
336
|
+
? `image/${spec.format}`
|
|
337
|
+
: (original.headers.get('Content-Type') ?? 'image/jpeg')
|
|
338
|
+
const body = Uint8Array.from(transformed).buffer
|
|
339
|
+
await adapter.upload(cacheKey, body, contentType)
|
|
340
|
+
return {
|
|
341
|
+
kind: 'body',
|
|
342
|
+
status: 200,
|
|
343
|
+
body,
|
|
344
|
+
headers: new Headers({
|
|
345
|
+
'Content-Type': contentType,
|
|
346
|
+
'Cache-Control': 'public, max-age=31536000',
|
|
347
|
+
}),
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (bucket.visibility === 'public' && adapter.publicUrlFor) {
|
|
352
|
+
const url = adapter.publicUrlFor(fileId)
|
|
353
|
+
if (url) return { kind: 'redirect', status: 302, url }
|
|
354
|
+
}
|
|
355
|
+
if (bucket.visibility === 'private' && adapter.presignGet) {
|
|
356
|
+
return {
|
|
357
|
+
kind: 'redirect',
|
|
358
|
+
status: 302,
|
|
359
|
+
url: await adapter.presignGet(fileId, {
|
|
360
|
+
expiresIn: presignExpiresSec,
|
|
361
|
+
}),
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const response = await adapter.get(fileId)
|
|
366
|
+
const headers = new Headers(response.headers)
|
|
367
|
+
if (response.status !== 404 && row.filename) {
|
|
368
|
+
headers.set(
|
|
369
|
+
'Content-Disposition',
|
|
370
|
+
`inline; filename="${sanitizeFilename(row.filename)}"`,
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
kind: 'body',
|
|
375
|
+
status: response.status,
|
|
376
|
+
body: response.body,
|
|
377
|
+
headers,
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
async delete(
|
|
382
|
+
bucketName: string,
|
|
383
|
+
id: string,
|
|
384
|
+
context: StorageExecutionContext,
|
|
385
|
+
): Promise<void> {
|
|
386
|
+
const { bucket, adapter } = bucketEntry(bucketName)
|
|
387
|
+
const fileId = `${bucketName}/${id}`
|
|
388
|
+
const row = await getFileMeta(db, fileId)
|
|
389
|
+
if (!row || row.bucket !== bucketName) notFound()
|
|
390
|
+
const ctx = accessContext(context, { row })
|
|
391
|
+
await gate(bucket.access.delete, ctx)
|
|
392
|
+
if (!fileMatchesScope(row, bucket.readScope?.(ctx))) notFound()
|
|
393
|
+
await deleteFileWithDerivatives(adapter, db, fileId)
|
|
394
|
+
},
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export type StorageOperations = ReturnType<typeof createStorageOperations>
|