graphile-presigned-url-plugin 0.3.0 → 0.4.1
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/download-url-field.js +39 -14
- package/esm/download-url-field.js +39 -14
- package/esm/index.d.ts +2 -2
- package/esm/index.js +1 -1
- package/esm/plugin.js +113 -55
- package/esm/storage-module-cache.d.ts +16 -2
- package/esm/storage-module-cache.js +37 -3
- package/esm/types.d.ts +42 -0
- package/index.d.ts +2 -2
- package/index.js +3 -1
- package/package.json +2 -2
- package/plugin.js +112 -54
- package/storage-module-cache.d.ts +16 -2
- package/storage-module-cache.js +39 -3
- package/types.d.ts +42 -0
package/download-url-field.js
CHANGED
|
@@ -40,6 +40,25 @@ function resolveS3(options) {
|
|
|
40
40
|
}
|
|
41
41
|
return options.s3;
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Build a per-database S3Config by overlaying storage_module overrides
|
|
45
|
+
* onto the global S3Config. Same logic as plugin.ts resolveS3ForDatabase.
|
|
46
|
+
*/
|
|
47
|
+
function resolveS3ForDatabase(options, storageConfig, databaseId) {
|
|
48
|
+
const globalS3 = resolveS3(options);
|
|
49
|
+
const bucket = options.resolveBucketName
|
|
50
|
+
? options.resolveBucketName(databaseId)
|
|
51
|
+
: globalS3.bucket;
|
|
52
|
+
const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix;
|
|
53
|
+
if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
54
|
+
return globalS3;
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
...globalS3,
|
|
58
|
+
bucket,
|
|
59
|
+
...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
43
62
|
function createDownloadUrlPlugin(options) {
|
|
44
63
|
return {
|
|
45
64
|
name: 'PresignedUrlDownloadPlugin',
|
|
@@ -76,35 +95,41 @@ function createDownloadUrlPlugin(options) {
|
|
|
76
95
|
if (status !== 'ready' && status !== 'processed') {
|
|
77
96
|
return null;
|
|
78
97
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
// Public file: return direct URL
|
|
82
|
-
return `${s3.publicUrlPrefix}/${key}`;
|
|
83
|
-
}
|
|
84
|
-
// Resolve download URL expiry from storage module config (per-database)
|
|
98
|
+
// Resolve per-database config (bucket, publicUrlPrefix, expiry)
|
|
99
|
+
let s3ForDb = resolveS3(options); // fallback to global
|
|
85
100
|
let downloadUrlExpirySeconds = 3600; // fallback default
|
|
86
101
|
try {
|
|
87
102
|
const withPgClient = context.pgSettings
|
|
88
103
|
? context.withPgClient
|
|
89
104
|
: null;
|
|
90
105
|
if (withPgClient) {
|
|
91
|
-
const
|
|
92
|
-
const dbResult = await pgClient.query(
|
|
106
|
+
const resolved = await withPgClient(null, async (pgClient) => {
|
|
107
|
+
const dbResult = await pgClient.query({
|
|
108
|
+
text: `SELECT jwt_private.current_database_id() AS id`,
|
|
109
|
+
});
|
|
93
110
|
const databaseId = dbResult.rows[0]?.id;
|
|
94
111
|
if (!databaseId)
|
|
95
112
|
return null;
|
|
96
|
-
|
|
113
|
+
const config = await (0, storage_module_cache_1.getStorageModuleConfig)(pgClient, databaseId);
|
|
114
|
+
if (!config)
|
|
115
|
+
return null;
|
|
116
|
+
return { config, databaseId };
|
|
97
117
|
});
|
|
98
|
-
if (
|
|
99
|
-
downloadUrlExpirySeconds = config.downloadUrlExpirySeconds;
|
|
118
|
+
if (resolved) {
|
|
119
|
+
downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds;
|
|
120
|
+
s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.databaseId);
|
|
100
121
|
}
|
|
101
122
|
}
|
|
102
123
|
}
|
|
103
124
|
catch {
|
|
104
|
-
// Fall back to
|
|
125
|
+
// Fall back to global config if lookup fails
|
|
126
|
+
}
|
|
127
|
+
if (isPublic && s3ForDb.publicUrlPrefix) {
|
|
128
|
+
// Public file: return direct CDN URL (per-database prefix)
|
|
129
|
+
return `${s3ForDb.publicUrlPrefix}/${key}`;
|
|
105
130
|
}
|
|
106
|
-
// Private file: generate presigned GET URL
|
|
107
|
-
return (0, s3_signer_1.generatePresignedGetUrl)(
|
|
131
|
+
// Private file: generate presigned GET URL (per-database bucket)
|
|
132
|
+
return (0, s3_signer_1.generatePresignedGetUrl)(s3ForDb, key, downloadUrlExpirySeconds, filename || undefined);
|
|
108
133
|
},
|
|
109
134
|
}),
|
|
110
135
|
}, 'PresignedUrlDownloadPlugin adding downloadUrl field');
|
|
@@ -37,6 +37,25 @@ function resolveS3(options) {
|
|
|
37
37
|
}
|
|
38
38
|
return options.s3;
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Build a per-database S3Config by overlaying storage_module overrides
|
|
42
|
+
* onto the global S3Config. Same logic as plugin.ts resolveS3ForDatabase.
|
|
43
|
+
*/
|
|
44
|
+
function resolveS3ForDatabase(options, storageConfig, databaseId) {
|
|
45
|
+
const globalS3 = resolveS3(options);
|
|
46
|
+
const bucket = options.resolveBucketName
|
|
47
|
+
? options.resolveBucketName(databaseId)
|
|
48
|
+
: globalS3.bucket;
|
|
49
|
+
const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix;
|
|
50
|
+
if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
51
|
+
return globalS3;
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
...globalS3,
|
|
55
|
+
bucket,
|
|
56
|
+
...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
40
59
|
export function createDownloadUrlPlugin(options) {
|
|
41
60
|
return {
|
|
42
61
|
name: 'PresignedUrlDownloadPlugin',
|
|
@@ -73,35 +92,41 @@ export function createDownloadUrlPlugin(options) {
|
|
|
73
92
|
if (status !== 'ready' && status !== 'processed') {
|
|
74
93
|
return null;
|
|
75
94
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
// Public file: return direct URL
|
|
79
|
-
return `${s3.publicUrlPrefix}/${key}`;
|
|
80
|
-
}
|
|
81
|
-
// Resolve download URL expiry from storage module config (per-database)
|
|
95
|
+
// Resolve per-database config (bucket, publicUrlPrefix, expiry)
|
|
96
|
+
let s3ForDb = resolveS3(options); // fallback to global
|
|
82
97
|
let downloadUrlExpirySeconds = 3600; // fallback default
|
|
83
98
|
try {
|
|
84
99
|
const withPgClient = context.pgSettings
|
|
85
100
|
? context.withPgClient
|
|
86
101
|
: null;
|
|
87
102
|
if (withPgClient) {
|
|
88
|
-
const
|
|
89
|
-
const dbResult = await pgClient.query(
|
|
103
|
+
const resolved = await withPgClient(null, async (pgClient) => {
|
|
104
|
+
const dbResult = await pgClient.query({
|
|
105
|
+
text: `SELECT jwt_private.current_database_id() AS id`,
|
|
106
|
+
});
|
|
90
107
|
const databaseId = dbResult.rows[0]?.id;
|
|
91
108
|
if (!databaseId)
|
|
92
109
|
return null;
|
|
93
|
-
|
|
110
|
+
const config = await getStorageModuleConfig(pgClient, databaseId);
|
|
111
|
+
if (!config)
|
|
112
|
+
return null;
|
|
113
|
+
return { config, databaseId };
|
|
94
114
|
});
|
|
95
|
-
if (
|
|
96
|
-
downloadUrlExpirySeconds = config.downloadUrlExpirySeconds;
|
|
115
|
+
if (resolved) {
|
|
116
|
+
downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds;
|
|
117
|
+
s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.databaseId);
|
|
97
118
|
}
|
|
98
119
|
}
|
|
99
120
|
}
|
|
100
121
|
catch {
|
|
101
|
-
// Fall back to
|
|
122
|
+
// Fall back to global config if lookup fails
|
|
123
|
+
}
|
|
124
|
+
if (isPublic && s3ForDb.publicUrlPrefix) {
|
|
125
|
+
// Public file: return direct CDN URL (per-database prefix)
|
|
126
|
+
return `${s3ForDb.publicUrlPrefix}/${key}`;
|
|
102
127
|
}
|
|
103
|
-
// Private file: generate presigned GET URL
|
|
104
|
-
return generatePresignedGetUrl(
|
|
128
|
+
// Private file: generate presigned GET URL (per-database bucket)
|
|
129
|
+
return generatePresignedGetUrl(s3ForDb, key, downloadUrlExpirySeconds, filename || undefined);
|
|
105
130
|
},
|
|
106
131
|
}),
|
|
107
132
|
}, 'PresignedUrlDownloadPlugin adding downloadUrl field');
|
package/esm/index.d.ts
CHANGED
|
@@ -29,6 +29,6 @@
|
|
|
29
29
|
export { PresignedUrlPlugin, createPresignedUrlPlugin } from './plugin';
|
|
30
30
|
export { createDownloadUrlPlugin } from './download-url-field';
|
|
31
31
|
export { PresignedUrlPreset } from './preset';
|
|
32
|
-
export { getStorageModuleConfig, getBucketConfig, clearStorageModuleCache, clearBucketCache } from './storage-module-cache';
|
|
32
|
+
export { getStorageModuleConfig, getBucketConfig, clearStorageModuleCache, clearBucketCache, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
|
|
33
33
|
export { generatePresignedPutUrl, generatePresignedGetUrl, headObject } from './s3-signer';
|
|
34
|
-
export type { BucketConfig, StorageModuleConfig, RequestUploadUrlInput, RequestUploadUrlPayload, ConfirmUploadInput, ConfirmUploadPayload, S3Config, S3ConfigOrGetter, PresignedUrlPluginOptions, } from './types';
|
|
34
|
+
export type { BucketConfig, StorageModuleConfig, RequestUploadUrlInput, RequestUploadUrlPayload, ConfirmUploadInput, ConfirmUploadPayload, S3Config, S3ConfigOrGetter, PresignedUrlPluginOptions, BucketNameResolver, EnsureBucketProvisioned, } from './types';
|
package/esm/index.js
CHANGED
|
@@ -29,5 +29,5 @@
|
|
|
29
29
|
export { PresignedUrlPlugin, createPresignedUrlPlugin } from './plugin';
|
|
30
30
|
export { createDownloadUrlPlugin } from './download-url-field';
|
|
31
31
|
export { PresignedUrlPreset } from './preset';
|
|
32
|
-
export { getStorageModuleConfig, getBucketConfig, clearStorageModuleCache, clearBucketCache } from './storage-module-cache';
|
|
32
|
+
export { getStorageModuleConfig, getBucketConfig, clearStorageModuleCache, clearBucketCache, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
|
|
33
33
|
export { generatePresignedPutUrl, generatePresignedGetUrl, headObject } from './s3-signer';
|
package/esm/plugin.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { context as grafastContext, lambda, object } from 'grafast';
|
|
20
20
|
import { extendSchema, gql } from 'graphile-utils';
|
|
21
21
|
import { Logger } from '@pgpmjs/logger';
|
|
22
|
-
import { getStorageModuleConfig, getBucketConfig } from './storage-module-cache';
|
|
22
|
+
import { getStorageModuleConfig, getBucketConfig, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
|
|
23
23
|
import { generatePresignedPutUrl, headObject } from './s3-signer';
|
|
24
24
|
const log = new Logger('graphile-presigned-url:plugin');
|
|
25
25
|
// --- Protocol-level constants (not configurable) ---
|
|
@@ -48,7 +48,9 @@ function buildS3Key(contentHash) {
|
|
|
48
48
|
* metaschema query needed.
|
|
49
49
|
*/
|
|
50
50
|
async function resolveDatabaseId(pgClient) {
|
|
51
|
-
const result = await pgClient.query(
|
|
51
|
+
const result = await pgClient.query({
|
|
52
|
+
text: `SELECT jwt_private.current_database_id() AS id`,
|
|
53
|
+
});
|
|
52
54
|
return result.rows[0]?.id ?? null;
|
|
53
55
|
}
|
|
54
56
|
// --- Plugin factory ---
|
|
@@ -66,6 +68,49 @@ function resolveS3(options) {
|
|
|
66
68
|
}
|
|
67
69
|
return options.s3;
|
|
68
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Build a per-database S3Config by overlaying storage_module overrides
|
|
73
|
+
* onto the global S3Config.
|
|
74
|
+
*
|
|
75
|
+
* - Bucket name: from resolveBucketName(databaseId) if provided, else global
|
|
76
|
+
* - publicUrlPrefix: from storageConfig.publicUrlPrefix if set, else global
|
|
77
|
+
* - S3 client (credentials, endpoint): always global (shared IAM key)
|
|
78
|
+
*/
|
|
79
|
+
function resolveS3ForDatabase(options, storageConfig, databaseId) {
|
|
80
|
+
const globalS3 = resolveS3(options);
|
|
81
|
+
const bucket = options.resolveBucketName
|
|
82
|
+
? options.resolveBucketName(databaseId)
|
|
83
|
+
: globalS3.bucket;
|
|
84
|
+
const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix;
|
|
85
|
+
if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
86
|
+
return globalS3;
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
...globalS3,
|
|
90
|
+
bucket,
|
|
91
|
+
...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Ensure the S3 bucket for a database exists, provisioning it lazily if needed.
|
|
96
|
+
*
|
|
97
|
+
* Checks an in-memory Set of known-provisioned bucket names. On the first
|
|
98
|
+
* request for an unseen bucket, calls the `ensureBucketProvisioned` callback
|
|
99
|
+
* (which creates the bucket with correct CORS, policies, etc.), then marks
|
|
100
|
+
* it as provisioned so subsequent requests skip the check entirely.
|
|
101
|
+
*
|
|
102
|
+
* If no `ensureBucketProvisioned` callback is configured, this is a no-op.
|
|
103
|
+
*/
|
|
104
|
+
async function ensureS3BucketExists(options, s3BucketName, bucket, databaseId, allowedOrigins) {
|
|
105
|
+
if (!options.ensureBucketProvisioned)
|
|
106
|
+
return;
|
|
107
|
+
if (isS3BucketProvisioned(s3BucketName))
|
|
108
|
+
return;
|
|
109
|
+
log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
|
|
110
|
+
await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
|
|
111
|
+
markS3BucketProvisioned(s3BucketName);
|
|
112
|
+
log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
|
|
113
|
+
}
|
|
69
114
|
export function createPresignedUrlPlugin(options) {
|
|
70
115
|
return extendSchema(() => ({
|
|
71
116
|
typeDefs: gql `
|
|
@@ -157,14 +202,13 @@ export function createPresignedUrlPlugin(options) {
|
|
|
157
202
|
throw new Error('INVALID_CONTENT_TYPE');
|
|
158
203
|
}
|
|
159
204
|
return withPgClient(pgSettings, async (pgClient) => {
|
|
160
|
-
|
|
161
|
-
try {
|
|
205
|
+
return pgClient.withTransaction(async (txClient) => {
|
|
162
206
|
// --- Resolve storage module config (all limits come from here) ---
|
|
163
|
-
const databaseId = await resolveDatabaseId(
|
|
207
|
+
const databaseId = await resolveDatabaseId(txClient);
|
|
164
208
|
if (!databaseId) {
|
|
165
209
|
throw new Error('DATABASE_NOT_FOUND');
|
|
166
210
|
}
|
|
167
|
-
const storageConfig = await getStorageModuleConfig(
|
|
211
|
+
const storageConfig = await getStorageModuleConfig(txClient, databaseId);
|
|
168
212
|
if (!storageConfig) {
|
|
169
213
|
throw new Error('STORAGE_MODULE_NOT_PROVISIONED');
|
|
170
214
|
}
|
|
@@ -178,7 +222,7 @@ export function createPresignedUrlPlugin(options) {
|
|
|
178
222
|
}
|
|
179
223
|
}
|
|
180
224
|
// --- Look up the bucket (cached; first miss queries via RLS) ---
|
|
181
|
-
const bucket = await getBucketConfig(
|
|
225
|
+
const bucket = await getBucketConfig(txClient, storageConfig, databaseId, bucketKey);
|
|
182
226
|
if (!bucket) {
|
|
183
227
|
throw new Error('BUCKET_NOT_FOUND');
|
|
184
228
|
}
|
|
@@ -204,20 +248,25 @@ export function createPresignedUrlPlugin(options) {
|
|
|
204
248
|
}
|
|
205
249
|
const s3Key = buildS3Key(contentHash);
|
|
206
250
|
// --- Dedup check: look for existing file with same content_hash in this bucket ---
|
|
207
|
-
const dedupResult = await
|
|
251
|
+
const dedupResult = await txClient.query({
|
|
252
|
+
text: `SELECT id, status
|
|
208
253
|
FROM ${storageConfig.filesQualifiedName}
|
|
209
254
|
WHERE content_hash = $1
|
|
210
255
|
AND bucket_id = $2
|
|
211
256
|
AND status IN ('ready', 'processed')
|
|
212
|
-
LIMIT 1`,
|
|
257
|
+
LIMIT 1`,
|
|
258
|
+
values: [contentHash, bucket.id],
|
|
259
|
+
});
|
|
213
260
|
if (dedupResult.rows.length > 0) {
|
|
214
261
|
const existingFile = dedupResult.rows[0];
|
|
215
262
|
log.info(`Dedup hit: file ${existingFile.id} for hash ${contentHash}`);
|
|
216
263
|
// Track the dedup request
|
|
217
|
-
await
|
|
264
|
+
await txClient.query({
|
|
265
|
+
text: `INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
|
|
218
266
|
(file_id, bucket_id, key, content_type, content_hash, size, status, expires_at)
|
|
219
|
-
VALUES ($1, $2, $3, $4, $5, $6, 'confirmed', NOW())`,
|
|
220
|
-
|
|
267
|
+
VALUES ($1, $2, $3, $4, $5, $6, 'confirmed', NOW())`,
|
|
268
|
+
values: [existingFile.id, bucket.id, s3Key, contentType, contentHash, size],
|
|
269
|
+
});
|
|
221
270
|
return {
|
|
222
271
|
uploadUrl: null,
|
|
223
272
|
fileId: existingFile.id,
|
|
@@ -227,28 +276,36 @@ export function createPresignedUrlPlugin(options) {
|
|
|
227
276
|
};
|
|
228
277
|
}
|
|
229
278
|
// --- Create file record (status=pending) ---
|
|
230
|
-
const fileResult = await
|
|
279
|
+
const fileResult = await txClient.query({
|
|
280
|
+
text: `INSERT INTO ${storageConfig.filesQualifiedName}
|
|
231
281
|
(bucket_id, key, content_type, content_hash, size, filename, owner_id, is_public, status)
|
|
232
282
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending')
|
|
233
|
-
RETURNING id`,
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
283
|
+
RETURNING id`,
|
|
284
|
+
values: [
|
|
285
|
+
bucket.id,
|
|
286
|
+
s3Key,
|
|
287
|
+
contentType,
|
|
288
|
+
contentHash,
|
|
289
|
+
size,
|
|
290
|
+
filename || null,
|
|
291
|
+
bucket.owner_id,
|
|
292
|
+
bucket.is_public,
|
|
293
|
+
],
|
|
294
|
+
});
|
|
243
295
|
const fileId = fileResult.rows[0].id;
|
|
244
|
-
// ---
|
|
245
|
-
const
|
|
296
|
+
// --- Ensure the S3 bucket exists (lazy provisioning) ---
|
|
297
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
|
|
298
|
+
await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
|
|
299
|
+
// --- Generate presigned PUT URL (per-database bucket) ---
|
|
300
|
+
const uploadUrl = await generatePresignedPutUrl(s3ForDb, s3Key, contentType, size, storageConfig.uploadUrlExpirySeconds);
|
|
246
301
|
const expiresAt = new Date(Date.now() + storageConfig.uploadUrlExpirySeconds * 1000).toISOString();
|
|
247
302
|
// --- Track the upload request ---
|
|
248
|
-
await
|
|
303
|
+
await txClient.query({
|
|
304
|
+
text: `INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
|
|
249
305
|
(file_id, bucket_id, key, content_type, content_hash, size, status, expires_at)
|
|
250
|
-
VALUES ($1, $2, $3, $4, $5, $6, 'issued', $7)`,
|
|
251
|
-
|
|
306
|
+
VALUES ($1, $2, $3, $4, $5, $6, 'issued', $7)`,
|
|
307
|
+
values: [fileId, bucket.id, s3Key, contentType, contentHash, size, expiresAt],
|
|
308
|
+
});
|
|
252
309
|
return {
|
|
253
310
|
uploadUrl,
|
|
254
311
|
fileId,
|
|
@@ -256,11 +313,7 @@ export function createPresignedUrlPlugin(options) {
|
|
|
256
313
|
deduplicated: false,
|
|
257
314
|
expiresAt,
|
|
258
315
|
};
|
|
259
|
-
}
|
|
260
|
-
catch (err) {
|
|
261
|
-
await pgClient.query('ROLLBACK');
|
|
262
|
-
throw err;
|
|
263
|
-
}
|
|
316
|
+
});
|
|
264
317
|
});
|
|
265
318
|
});
|
|
266
319
|
},
|
|
@@ -279,68 +332,73 @@ export function createPresignedUrlPlugin(options) {
|
|
|
279
332
|
throw new Error('INVALID_FILE_ID');
|
|
280
333
|
}
|
|
281
334
|
return withPgClient(pgSettings, async (pgClient) => {
|
|
282
|
-
|
|
283
|
-
try {
|
|
335
|
+
return pgClient.withTransaction(async (txClient) => {
|
|
284
336
|
// --- Resolve storage module config ---
|
|
285
|
-
const databaseId = await resolveDatabaseId(
|
|
337
|
+
const databaseId = await resolveDatabaseId(txClient);
|
|
286
338
|
if (!databaseId) {
|
|
287
339
|
throw new Error('DATABASE_NOT_FOUND');
|
|
288
340
|
}
|
|
289
|
-
const storageConfig = await getStorageModuleConfig(
|
|
341
|
+
const storageConfig = await getStorageModuleConfig(txClient, databaseId);
|
|
290
342
|
if (!storageConfig) {
|
|
291
343
|
throw new Error('STORAGE_MODULE_NOT_PROVISIONED');
|
|
292
344
|
}
|
|
293
345
|
// --- Look up the file (RLS enforced) ---
|
|
294
|
-
const fileResult = await
|
|
346
|
+
const fileResult = await txClient.query({
|
|
347
|
+
text: `SELECT id, key, content_type, status, bucket_id
|
|
295
348
|
FROM ${storageConfig.filesQualifiedName}
|
|
296
349
|
WHERE id = $1
|
|
297
|
-
LIMIT 1`,
|
|
350
|
+
LIMIT 1`,
|
|
351
|
+
values: [fileId],
|
|
352
|
+
});
|
|
298
353
|
if (fileResult.rows.length === 0) {
|
|
299
354
|
throw new Error('FILE_NOT_FOUND');
|
|
300
355
|
}
|
|
301
356
|
const file = fileResult.rows[0];
|
|
302
357
|
if (file.status !== 'pending') {
|
|
303
358
|
// File is already confirmed or processed — idempotent success
|
|
304
|
-
await pgClient.query('COMMIT');
|
|
305
359
|
return {
|
|
306
360
|
fileId: file.id,
|
|
307
361
|
status: file.status,
|
|
308
362
|
success: true,
|
|
309
363
|
};
|
|
310
364
|
}
|
|
311
|
-
// --- Verify file exists in S3 ---
|
|
312
|
-
const
|
|
365
|
+
// --- Verify file exists in S3 (per-database bucket) ---
|
|
366
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
|
|
367
|
+
const s3Head = await headObject(s3ForDb, file.key, file.content_type);
|
|
313
368
|
if (!s3Head) {
|
|
314
369
|
throw new Error('FILE_NOT_IN_S3: the file has not been uploaded yet');
|
|
315
370
|
}
|
|
316
371
|
// --- Content-type verification ---
|
|
317
372
|
if (s3Head.contentType && s3Head.contentType !== file.content_type) {
|
|
318
373
|
// Mark upload_request as rejected
|
|
319
|
-
await
|
|
374
|
+
await txClient.query({
|
|
375
|
+
text: `UPDATE ${storageConfig.uploadRequestsQualifiedName}
|
|
320
376
|
SET status = 'rejected'
|
|
321
|
-
WHERE file_id = $1 AND status = 'issued'`,
|
|
322
|
-
|
|
377
|
+
WHERE file_id = $1 AND status = 'issued'`,
|
|
378
|
+
values: [fileId],
|
|
379
|
+
});
|
|
323
380
|
throw new Error(`CONTENT_TYPE_MISMATCH: expected ${file.content_type}, got ${s3Head.contentType}`);
|
|
324
381
|
}
|
|
325
382
|
// --- Transition file to 'ready' ---
|
|
326
|
-
await
|
|
383
|
+
await txClient.query({
|
|
384
|
+
text: `UPDATE ${storageConfig.filesQualifiedName}
|
|
327
385
|
SET status = 'ready'
|
|
328
|
-
WHERE id = $1`,
|
|
386
|
+
WHERE id = $1`,
|
|
387
|
+
values: [fileId],
|
|
388
|
+
});
|
|
329
389
|
// --- Update upload_request to 'confirmed' ---
|
|
330
|
-
await
|
|
390
|
+
await txClient.query({
|
|
391
|
+
text: `UPDATE ${storageConfig.uploadRequestsQualifiedName}
|
|
331
392
|
SET status = 'confirmed', confirmed_at = NOW()
|
|
332
|
-
WHERE file_id = $1 AND status = 'issued'`,
|
|
333
|
-
|
|
393
|
+
WHERE file_id = $1 AND status = 'issued'`,
|
|
394
|
+
values: [fileId],
|
|
395
|
+
});
|
|
334
396
|
return {
|
|
335
397
|
fileId: file.id,
|
|
336
398
|
status: 'ready',
|
|
337
399
|
success: true,
|
|
338
400
|
};
|
|
339
|
-
}
|
|
340
|
-
catch (err) {
|
|
341
|
-
await pgClient.query('ROLLBACK');
|
|
342
|
-
throw err;
|
|
343
|
-
}
|
|
401
|
+
});
|
|
344
402
|
});
|
|
345
403
|
});
|
|
346
404
|
},
|
|
@@ -7,7 +7,10 @@ import type { StorageModuleConfig, BucketConfig } from './types';
|
|
|
7
7
|
* @returns StorageModuleConfig or null if no storage module is provisioned
|
|
8
8
|
*/
|
|
9
9
|
export declare function getStorageModuleConfig(pgClient: {
|
|
10
|
-
query: (
|
|
10
|
+
query: (opts: {
|
|
11
|
+
text: string;
|
|
12
|
+
values?: unknown[];
|
|
13
|
+
}) => Promise<{
|
|
11
14
|
rows: unknown[];
|
|
12
15
|
}>;
|
|
13
16
|
}, databaseId: string): Promise<StorageModuleConfig | null>;
|
|
@@ -24,10 +27,21 @@ export declare function getStorageModuleConfig(pgClient: {
|
|
|
24
27
|
* @returns BucketConfig or null if the bucket doesn't exist / isn't accessible
|
|
25
28
|
*/
|
|
26
29
|
export declare function getBucketConfig(pgClient: {
|
|
27
|
-
query: (
|
|
30
|
+
query: (opts: {
|
|
31
|
+
text: string;
|
|
32
|
+
values?: unknown[];
|
|
33
|
+
}) => Promise<{
|
|
28
34
|
rows: unknown[];
|
|
29
35
|
}>;
|
|
30
36
|
}, storageConfig: StorageModuleConfig, databaseId: string, bucketKey: string): Promise<BucketConfig | null>;
|
|
37
|
+
/**
|
|
38
|
+
* Check whether an S3 bucket has already been provisioned (cached).
|
|
39
|
+
*/
|
|
40
|
+
export declare function isS3BucketProvisioned(s3BucketName: string): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Mark an S3 bucket as provisioned in the in-memory cache.
|
|
43
|
+
*/
|
|
44
|
+
export declare function markS3BucketProvisioned(s3BucketName: string): void;
|
|
31
45
|
/**
|
|
32
46
|
* Clear the storage module cache AND bucket cache.
|
|
33
47
|
* Useful for testing or schema changes.
|
|
@@ -41,6 +41,7 @@ const STORAGE_MODULE_QUERY = `
|
|
|
41
41
|
sm.endpoint,
|
|
42
42
|
sm.public_url_prefix,
|
|
43
43
|
sm.provider,
|
|
44
|
+
sm.allowed_origins,
|
|
44
45
|
sm.upload_url_expiry_seconds,
|
|
45
46
|
sm.download_url_expiry_seconds,
|
|
46
47
|
sm.default_max_file_size,
|
|
@@ -70,7 +71,7 @@ export async function getStorageModuleConfig(pgClient, databaseId) {
|
|
|
70
71
|
return cached;
|
|
71
72
|
}
|
|
72
73
|
log.debug(`Cache miss for database ${databaseId}, querying metaschema...`);
|
|
73
|
-
const result = await pgClient.query(STORAGE_MODULE_QUERY, [databaseId]);
|
|
74
|
+
const result = await pgClient.query({ text: STORAGE_MODULE_QUERY, values: [databaseId] });
|
|
74
75
|
if (result.rows.length === 0) {
|
|
75
76
|
log.warn(`No storage module found for database ${databaseId}`);
|
|
76
77
|
return null;
|
|
@@ -89,6 +90,7 @@ export async function getStorageModuleConfig(pgClient, databaseId) {
|
|
|
89
90
|
endpoint: row.endpoint,
|
|
90
91
|
publicUrlPrefix: row.public_url_prefix,
|
|
91
92
|
provider: row.provider,
|
|
93
|
+
allowedOrigins: row.allowed_origins,
|
|
92
94
|
uploadUrlExpirySeconds: row.upload_url_expiry_seconds ?? DEFAULT_UPLOAD_URL_EXPIRY_SECONDS,
|
|
93
95
|
downloadUrlExpirySeconds: row.download_url_expiry_seconds ?? DEFAULT_DOWNLOAD_URL_EXPIRY_SECONDS,
|
|
94
96
|
defaultMaxFileSize: row.default_max_file_size ?? DEFAULT_MAX_FILE_SIZE,
|
|
@@ -138,10 +140,13 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
|
|
|
138
140
|
return cached;
|
|
139
141
|
}
|
|
140
142
|
log.debug(`Bucket cache miss for ${databaseId}:${bucketKey}, querying DB...`);
|
|
141
|
-
const result = await pgClient.query(
|
|
143
|
+
const result = await pgClient.query({
|
|
144
|
+
text: `SELECT id, key, type, is_public, owner_id, allowed_mime_types, max_file_size
|
|
142
145
|
FROM ${storageConfig.bucketsQualifiedName}
|
|
143
146
|
WHERE key = $1
|
|
144
|
-
LIMIT 1`,
|
|
147
|
+
LIMIT 1`,
|
|
148
|
+
values: [bucketKey],
|
|
149
|
+
});
|
|
145
150
|
if (result.rows.length === 0) {
|
|
146
151
|
return null;
|
|
147
152
|
}
|
|
@@ -159,6 +164,34 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
|
|
|
159
164
|
log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id})`);
|
|
160
165
|
return config;
|
|
161
166
|
}
|
|
167
|
+
// --- S3 bucket existence cache ---
|
|
168
|
+
/**
|
|
169
|
+
* In-memory set of S3 bucket names that are known to exist.
|
|
170
|
+
*
|
|
171
|
+
* Used by the lazy provisioning logic in the presigned URL plugin:
|
|
172
|
+
* before generating a presigned PUT URL, the plugin checks this set.
|
|
173
|
+
* If the bucket name is absent, it calls `ensureBucketProvisioned`
|
|
174
|
+
* to create the S3 bucket, then adds the name here. Subsequent
|
|
175
|
+
* requests for the same bucket skip the provisioning entirely.
|
|
176
|
+
*
|
|
177
|
+
* No TTL needed — S3 buckets are never deleted during normal operation.
|
|
178
|
+
* The set resets on server restart, which is fine because the
|
|
179
|
+
* provisioner's createBucket is idempotent (handles "already exists").
|
|
180
|
+
*/
|
|
181
|
+
const provisionedBuckets = new Set();
|
|
182
|
+
/**
|
|
183
|
+
* Check whether an S3 bucket has already been provisioned (cached).
|
|
184
|
+
*/
|
|
185
|
+
export function isS3BucketProvisioned(s3BucketName) {
|
|
186
|
+
return provisionedBuckets.has(s3BucketName);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Mark an S3 bucket as provisioned in the in-memory cache.
|
|
190
|
+
*/
|
|
191
|
+
export function markS3BucketProvisioned(s3BucketName) {
|
|
192
|
+
provisionedBuckets.add(s3BucketName);
|
|
193
|
+
log.debug(`Marked S3 bucket "${s3BucketName}" as provisioned`);
|
|
194
|
+
}
|
|
162
195
|
/**
|
|
163
196
|
* Clear the storage module cache AND bucket cache.
|
|
164
197
|
* Useful for testing or schema changes.
|
|
@@ -166,6 +199,7 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
|
|
|
166
199
|
export function clearStorageModuleCache() {
|
|
167
200
|
storageModuleCache.clear();
|
|
168
201
|
bucketCache.clear();
|
|
202
|
+
provisionedBuckets.clear();
|
|
169
203
|
}
|
|
170
204
|
/**
|
|
171
205
|
* Clear cached bucket entries for a specific database.
|
package/esm/types.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface StorageModuleConfig {
|
|
|
37
37
|
publicUrlPrefix: string | null;
|
|
38
38
|
/** Storage provider type: 'minio', 's3', 'gcs', etc. (per-database override) */
|
|
39
39
|
provider: string | null;
|
|
40
|
+
/** CORS allowed origins (per-database override, NULL = use global fallback) */
|
|
41
|
+
allowedOrigins: string[] | null;
|
|
40
42
|
/** Presigned PUT URL expiry in seconds (default: 900 = 15 min) */
|
|
41
43
|
uploadUrlExpirySeconds: number;
|
|
42
44
|
/** Presigned GET URL expiry in seconds (default: 3600 = 1 hour) */
|
|
@@ -120,10 +122,50 @@ export interface S3Config {
|
|
|
120
122
|
* env-var reads and S3Client creation at module import time.
|
|
121
123
|
*/
|
|
122
124
|
export type S3ConfigOrGetter = S3Config | (() => S3Config);
|
|
125
|
+
/**
|
|
126
|
+
* Function to derive the actual S3 bucket name for a given database.
|
|
127
|
+
*
|
|
128
|
+
* When provided, the presigned URL plugin calls this on every request
|
|
129
|
+
* to determine which S3 bucket to use — enabling per-database bucket
|
|
130
|
+
* isolation. If not provided, falls back to `s3Config.bucket` (global).
|
|
131
|
+
*
|
|
132
|
+
* @param databaseId - The metaschema database UUID
|
|
133
|
+
* @returns The S3 bucket name for this database
|
|
134
|
+
*/
|
|
135
|
+
export type BucketNameResolver = (databaseId: string) => string;
|
|
136
|
+
/**
|
|
137
|
+
* Callback to lazily provision an S3 bucket on first use.
|
|
138
|
+
*
|
|
139
|
+
* Called by the presigned URL plugin before generating a presigned PUT URL
|
|
140
|
+
* when the bucket has not been seen before (tracked in an in-memory cache).
|
|
141
|
+
* The implementation should create and fully configure the S3 bucket
|
|
142
|
+
* (privacy policies, CORS, lifecycle rules, etc.) — or no-op if the
|
|
143
|
+
* bucket already exists.
|
|
144
|
+
*
|
|
145
|
+
* @param bucketName - The S3 bucket name to provision
|
|
146
|
+
* @param accessType - The logical bucket type ('public', 'private', 'temp')
|
|
147
|
+
* @param databaseId - The metaschema database UUID
|
|
148
|
+
* @param allowedOrigins - Per-database CORS origins (from storage_module), or null to use global fallback
|
|
149
|
+
*/
|
|
150
|
+
export type EnsureBucketProvisioned = (bucketName: string, accessType: 'public' | 'private' | 'temp', databaseId: string, allowedOrigins: string[] | null) => Promise<void>;
|
|
123
151
|
/**
|
|
124
152
|
* Plugin options for the presigned URL plugin.
|
|
125
153
|
*/
|
|
126
154
|
export interface PresignedUrlPluginOptions {
|
|
127
155
|
/** S3 configuration (concrete or lazy getter) */
|
|
128
156
|
s3: S3ConfigOrGetter;
|
|
157
|
+
/**
|
|
158
|
+
* Optional function to resolve S3 bucket name per-database.
|
|
159
|
+
* When set, each database gets its own S3 bucket instead of sharing
|
|
160
|
+
* the global `s3Config.bucket`. The S3 credentials (client) remain shared.
|
|
161
|
+
*/
|
|
162
|
+
resolveBucketName?: BucketNameResolver;
|
|
163
|
+
/**
|
|
164
|
+
* Optional callback to lazily provision an S3 bucket on first upload.
|
|
165
|
+
* When set, the plugin calls this before generating a presigned PUT URL
|
|
166
|
+
* for any S3 bucket it hasn't seen yet (tracked in an in-memory cache).
|
|
167
|
+
* This enables graceful bucket creation without requiring buckets to
|
|
168
|
+
* exist at database provisioning time.
|
|
169
|
+
*/
|
|
170
|
+
ensureBucketProvisioned?: EnsureBucketProvisioned;
|
|
129
171
|
}
|
package/index.d.ts
CHANGED
|
@@ -29,6 +29,6 @@
|
|
|
29
29
|
export { PresignedUrlPlugin, createPresignedUrlPlugin } from './plugin';
|
|
30
30
|
export { createDownloadUrlPlugin } from './download-url-field';
|
|
31
31
|
export { PresignedUrlPreset } from './preset';
|
|
32
|
-
export { getStorageModuleConfig, getBucketConfig, clearStorageModuleCache, clearBucketCache } from './storage-module-cache';
|
|
32
|
+
export { getStorageModuleConfig, getBucketConfig, clearStorageModuleCache, clearBucketCache, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
|
|
33
33
|
export { generatePresignedPutUrl, generatePresignedGetUrl, headObject } from './s3-signer';
|
|
34
|
-
export type { BucketConfig, StorageModuleConfig, RequestUploadUrlInput, RequestUploadUrlPayload, ConfirmUploadInput, ConfirmUploadPayload, S3Config, S3ConfigOrGetter, PresignedUrlPluginOptions, } from './types';
|
|
34
|
+
export type { BucketConfig, StorageModuleConfig, RequestUploadUrlInput, RequestUploadUrlPayload, ConfirmUploadInput, ConfirmUploadPayload, S3Config, S3ConfigOrGetter, PresignedUrlPluginOptions, BucketNameResolver, EnsureBucketProvisioned, } from './types';
|
package/index.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* ```
|
|
29
29
|
*/
|
|
30
30
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
|
-
exports.headObject = exports.generatePresignedGetUrl = exports.generatePresignedPutUrl = exports.clearBucketCache = exports.clearStorageModuleCache = exports.getBucketConfig = exports.getStorageModuleConfig = exports.PresignedUrlPreset = exports.createDownloadUrlPlugin = exports.createPresignedUrlPlugin = exports.PresignedUrlPlugin = void 0;
|
|
31
|
+
exports.headObject = exports.generatePresignedGetUrl = exports.generatePresignedPutUrl = exports.markS3BucketProvisioned = exports.isS3BucketProvisioned = exports.clearBucketCache = exports.clearStorageModuleCache = exports.getBucketConfig = exports.getStorageModuleConfig = exports.PresignedUrlPreset = exports.createDownloadUrlPlugin = exports.createPresignedUrlPlugin = exports.PresignedUrlPlugin = void 0;
|
|
32
32
|
var plugin_1 = require("./plugin");
|
|
33
33
|
Object.defineProperty(exports, "PresignedUrlPlugin", { enumerable: true, get: function () { return plugin_1.PresignedUrlPlugin; } });
|
|
34
34
|
Object.defineProperty(exports, "createPresignedUrlPlugin", { enumerable: true, get: function () { return plugin_1.createPresignedUrlPlugin; } });
|
|
@@ -41,6 +41,8 @@ Object.defineProperty(exports, "getStorageModuleConfig", { enumerable: true, get
|
|
|
41
41
|
Object.defineProperty(exports, "getBucketConfig", { enumerable: true, get: function () { return storage_module_cache_1.getBucketConfig; } });
|
|
42
42
|
Object.defineProperty(exports, "clearStorageModuleCache", { enumerable: true, get: function () { return storage_module_cache_1.clearStorageModuleCache; } });
|
|
43
43
|
Object.defineProperty(exports, "clearBucketCache", { enumerable: true, get: function () { return storage_module_cache_1.clearBucketCache; } });
|
|
44
|
+
Object.defineProperty(exports, "isS3BucketProvisioned", { enumerable: true, get: function () { return storage_module_cache_1.isS3BucketProvisioned; } });
|
|
45
|
+
Object.defineProperty(exports, "markS3BucketProvisioned", { enumerable: true, get: function () { return storage_module_cache_1.markS3BucketProvisioned; } });
|
|
44
46
|
var s3_signer_1 = require("./s3-signer");
|
|
45
47
|
Object.defineProperty(exports, "generatePresignedPutUrl", { enumerable: true, get: function () { return s3_signer_1.generatePresignedPutUrl; } });
|
|
46
48
|
Object.defineProperty(exports, "generatePresignedGetUrl", { enumerable: true, get: function () { return s3_signer_1.generatePresignedGetUrl; } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graphile-presigned-url-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Presigned URL upload plugin for PostGraphile v5 — requestUploadUrl, confirmUpload mutations and downloadUrl computed field",
|
|
5
5
|
"author": "Constructive <developers@constructive.io>",
|
|
6
6
|
"homepage": "https://github.com/constructive-io/constructive",
|
|
@@ -59,5 +59,5 @@
|
|
|
59
59
|
"@types/node": "^22.19.11",
|
|
60
60
|
"makage": "^0.1.10"
|
|
61
61
|
},
|
|
62
|
-
"gitHead": "
|
|
62
|
+
"gitHead": "79cd3e66871804a22c672c7ca2fa5e2105d4b368"
|
|
63
63
|
}
|
package/plugin.js
CHANGED
|
@@ -52,7 +52,9 @@ function buildS3Key(contentHash) {
|
|
|
52
52
|
* metaschema query needed.
|
|
53
53
|
*/
|
|
54
54
|
async function resolveDatabaseId(pgClient) {
|
|
55
|
-
const result = await pgClient.query(
|
|
55
|
+
const result = await pgClient.query({
|
|
56
|
+
text: `SELECT jwt_private.current_database_id() AS id`,
|
|
57
|
+
});
|
|
56
58
|
return result.rows[0]?.id ?? null;
|
|
57
59
|
}
|
|
58
60
|
// --- Plugin factory ---
|
|
@@ -70,6 +72,49 @@ function resolveS3(options) {
|
|
|
70
72
|
}
|
|
71
73
|
return options.s3;
|
|
72
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Build a per-database S3Config by overlaying storage_module overrides
|
|
77
|
+
* onto the global S3Config.
|
|
78
|
+
*
|
|
79
|
+
* - Bucket name: from resolveBucketName(databaseId) if provided, else global
|
|
80
|
+
* - publicUrlPrefix: from storageConfig.publicUrlPrefix if set, else global
|
|
81
|
+
* - S3 client (credentials, endpoint): always global (shared IAM key)
|
|
82
|
+
*/
|
|
83
|
+
function resolveS3ForDatabase(options, storageConfig, databaseId) {
|
|
84
|
+
const globalS3 = resolveS3(options);
|
|
85
|
+
const bucket = options.resolveBucketName
|
|
86
|
+
? options.resolveBucketName(databaseId)
|
|
87
|
+
: globalS3.bucket;
|
|
88
|
+
const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix;
|
|
89
|
+
if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
90
|
+
return globalS3;
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
...globalS3,
|
|
94
|
+
bucket,
|
|
95
|
+
...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Ensure the S3 bucket for a database exists, provisioning it lazily if needed.
|
|
100
|
+
*
|
|
101
|
+
* Checks an in-memory Set of known-provisioned bucket names. On the first
|
|
102
|
+
* request for an unseen bucket, calls the `ensureBucketProvisioned` callback
|
|
103
|
+
* (which creates the bucket with correct CORS, policies, etc.), then marks
|
|
104
|
+
* it as provisioned so subsequent requests skip the check entirely.
|
|
105
|
+
*
|
|
106
|
+
* If no `ensureBucketProvisioned` callback is configured, this is a no-op.
|
|
107
|
+
*/
|
|
108
|
+
async function ensureS3BucketExists(options, s3BucketName, bucket, databaseId, allowedOrigins) {
|
|
109
|
+
if (!options.ensureBucketProvisioned)
|
|
110
|
+
return;
|
|
111
|
+
if ((0, storage_module_cache_1.isS3BucketProvisioned)(s3BucketName))
|
|
112
|
+
return;
|
|
113
|
+
log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
|
|
114
|
+
await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
|
|
115
|
+
(0, storage_module_cache_1.markS3BucketProvisioned)(s3BucketName);
|
|
116
|
+
log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
|
|
117
|
+
}
|
|
73
118
|
function createPresignedUrlPlugin(options) {
|
|
74
119
|
return (0, graphile_utils_1.extendSchema)(() => ({
|
|
75
120
|
typeDefs: (0, graphile_utils_1.gql) `
|
|
@@ -161,14 +206,13 @@ function createPresignedUrlPlugin(options) {
|
|
|
161
206
|
throw new Error('INVALID_CONTENT_TYPE');
|
|
162
207
|
}
|
|
163
208
|
return withPgClient(pgSettings, async (pgClient) => {
|
|
164
|
-
|
|
165
|
-
try {
|
|
209
|
+
return pgClient.withTransaction(async (txClient) => {
|
|
166
210
|
// --- Resolve storage module config (all limits come from here) ---
|
|
167
|
-
const databaseId = await resolveDatabaseId(
|
|
211
|
+
const databaseId = await resolveDatabaseId(txClient);
|
|
168
212
|
if (!databaseId) {
|
|
169
213
|
throw new Error('DATABASE_NOT_FOUND');
|
|
170
214
|
}
|
|
171
|
-
const storageConfig = await (0, storage_module_cache_1.getStorageModuleConfig)(
|
|
215
|
+
const storageConfig = await (0, storage_module_cache_1.getStorageModuleConfig)(txClient, databaseId);
|
|
172
216
|
if (!storageConfig) {
|
|
173
217
|
throw new Error('STORAGE_MODULE_NOT_PROVISIONED');
|
|
174
218
|
}
|
|
@@ -182,7 +226,7 @@ function createPresignedUrlPlugin(options) {
|
|
|
182
226
|
}
|
|
183
227
|
}
|
|
184
228
|
// --- Look up the bucket (cached; first miss queries via RLS) ---
|
|
185
|
-
const bucket = await (0, storage_module_cache_1.getBucketConfig)(
|
|
229
|
+
const bucket = await (0, storage_module_cache_1.getBucketConfig)(txClient, storageConfig, databaseId, bucketKey);
|
|
186
230
|
if (!bucket) {
|
|
187
231
|
throw new Error('BUCKET_NOT_FOUND');
|
|
188
232
|
}
|
|
@@ -208,20 +252,25 @@ function createPresignedUrlPlugin(options) {
|
|
|
208
252
|
}
|
|
209
253
|
const s3Key = buildS3Key(contentHash);
|
|
210
254
|
// --- Dedup check: look for existing file with same content_hash in this bucket ---
|
|
211
|
-
const dedupResult = await
|
|
255
|
+
const dedupResult = await txClient.query({
|
|
256
|
+
text: `SELECT id, status
|
|
212
257
|
FROM ${storageConfig.filesQualifiedName}
|
|
213
258
|
WHERE content_hash = $1
|
|
214
259
|
AND bucket_id = $2
|
|
215
260
|
AND status IN ('ready', 'processed')
|
|
216
|
-
LIMIT 1`,
|
|
261
|
+
LIMIT 1`,
|
|
262
|
+
values: [contentHash, bucket.id],
|
|
263
|
+
});
|
|
217
264
|
if (dedupResult.rows.length > 0) {
|
|
218
265
|
const existingFile = dedupResult.rows[0];
|
|
219
266
|
log.info(`Dedup hit: file ${existingFile.id} for hash ${contentHash}`);
|
|
220
267
|
// Track the dedup request
|
|
221
|
-
await
|
|
268
|
+
await txClient.query({
|
|
269
|
+
text: `INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
|
|
222
270
|
(file_id, bucket_id, key, content_type, content_hash, size, status, expires_at)
|
|
223
|
-
VALUES ($1, $2, $3, $4, $5, $6, 'confirmed', NOW())`,
|
|
224
|
-
|
|
271
|
+
VALUES ($1, $2, $3, $4, $5, $6, 'confirmed', NOW())`,
|
|
272
|
+
values: [existingFile.id, bucket.id, s3Key, contentType, contentHash, size],
|
|
273
|
+
});
|
|
225
274
|
return {
|
|
226
275
|
uploadUrl: null,
|
|
227
276
|
fileId: existingFile.id,
|
|
@@ -231,28 +280,36 @@ function createPresignedUrlPlugin(options) {
|
|
|
231
280
|
};
|
|
232
281
|
}
|
|
233
282
|
// --- Create file record (status=pending) ---
|
|
234
|
-
const fileResult = await
|
|
283
|
+
const fileResult = await txClient.query({
|
|
284
|
+
text: `INSERT INTO ${storageConfig.filesQualifiedName}
|
|
235
285
|
(bucket_id, key, content_type, content_hash, size, filename, owner_id, is_public, status)
|
|
236
286
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending')
|
|
237
|
-
RETURNING id`,
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
287
|
+
RETURNING id`,
|
|
288
|
+
values: [
|
|
289
|
+
bucket.id,
|
|
290
|
+
s3Key,
|
|
291
|
+
contentType,
|
|
292
|
+
contentHash,
|
|
293
|
+
size,
|
|
294
|
+
filename || null,
|
|
295
|
+
bucket.owner_id,
|
|
296
|
+
bucket.is_public,
|
|
297
|
+
],
|
|
298
|
+
});
|
|
247
299
|
const fileId = fileResult.rows[0].id;
|
|
248
|
-
// ---
|
|
249
|
-
const
|
|
300
|
+
// --- Ensure the S3 bucket exists (lazy provisioning) ---
|
|
301
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
|
|
302
|
+
await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
|
|
303
|
+
// --- Generate presigned PUT URL (per-database bucket) ---
|
|
304
|
+
const uploadUrl = await (0, s3_signer_1.generatePresignedPutUrl)(s3ForDb, s3Key, contentType, size, storageConfig.uploadUrlExpirySeconds);
|
|
250
305
|
const expiresAt = new Date(Date.now() + storageConfig.uploadUrlExpirySeconds * 1000).toISOString();
|
|
251
306
|
// --- Track the upload request ---
|
|
252
|
-
await
|
|
307
|
+
await txClient.query({
|
|
308
|
+
text: `INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
|
|
253
309
|
(file_id, bucket_id, key, content_type, content_hash, size, status, expires_at)
|
|
254
|
-
VALUES ($1, $2, $3, $4, $5, $6, 'issued', $7)`,
|
|
255
|
-
|
|
310
|
+
VALUES ($1, $2, $3, $4, $5, $6, 'issued', $7)`,
|
|
311
|
+
values: [fileId, bucket.id, s3Key, contentType, contentHash, size, expiresAt],
|
|
312
|
+
});
|
|
256
313
|
return {
|
|
257
314
|
uploadUrl,
|
|
258
315
|
fileId,
|
|
@@ -260,11 +317,7 @@ function createPresignedUrlPlugin(options) {
|
|
|
260
317
|
deduplicated: false,
|
|
261
318
|
expiresAt,
|
|
262
319
|
};
|
|
263
|
-
}
|
|
264
|
-
catch (err) {
|
|
265
|
-
await pgClient.query('ROLLBACK');
|
|
266
|
-
throw err;
|
|
267
|
-
}
|
|
320
|
+
});
|
|
268
321
|
});
|
|
269
322
|
});
|
|
270
323
|
},
|
|
@@ -283,68 +336,73 @@ function createPresignedUrlPlugin(options) {
|
|
|
283
336
|
throw new Error('INVALID_FILE_ID');
|
|
284
337
|
}
|
|
285
338
|
return withPgClient(pgSettings, async (pgClient) => {
|
|
286
|
-
|
|
287
|
-
try {
|
|
339
|
+
return pgClient.withTransaction(async (txClient) => {
|
|
288
340
|
// --- Resolve storage module config ---
|
|
289
|
-
const databaseId = await resolveDatabaseId(
|
|
341
|
+
const databaseId = await resolveDatabaseId(txClient);
|
|
290
342
|
if (!databaseId) {
|
|
291
343
|
throw new Error('DATABASE_NOT_FOUND');
|
|
292
344
|
}
|
|
293
|
-
const storageConfig = await (0, storage_module_cache_1.getStorageModuleConfig)(
|
|
345
|
+
const storageConfig = await (0, storage_module_cache_1.getStorageModuleConfig)(txClient, databaseId);
|
|
294
346
|
if (!storageConfig) {
|
|
295
347
|
throw new Error('STORAGE_MODULE_NOT_PROVISIONED');
|
|
296
348
|
}
|
|
297
349
|
// --- Look up the file (RLS enforced) ---
|
|
298
|
-
const fileResult = await
|
|
350
|
+
const fileResult = await txClient.query({
|
|
351
|
+
text: `SELECT id, key, content_type, status, bucket_id
|
|
299
352
|
FROM ${storageConfig.filesQualifiedName}
|
|
300
353
|
WHERE id = $1
|
|
301
|
-
LIMIT 1`,
|
|
354
|
+
LIMIT 1`,
|
|
355
|
+
values: [fileId],
|
|
356
|
+
});
|
|
302
357
|
if (fileResult.rows.length === 0) {
|
|
303
358
|
throw new Error('FILE_NOT_FOUND');
|
|
304
359
|
}
|
|
305
360
|
const file = fileResult.rows[0];
|
|
306
361
|
if (file.status !== 'pending') {
|
|
307
362
|
// File is already confirmed or processed — idempotent success
|
|
308
|
-
await pgClient.query('COMMIT');
|
|
309
363
|
return {
|
|
310
364
|
fileId: file.id,
|
|
311
365
|
status: file.status,
|
|
312
366
|
success: true,
|
|
313
367
|
};
|
|
314
368
|
}
|
|
315
|
-
// --- Verify file exists in S3 ---
|
|
316
|
-
const
|
|
369
|
+
// --- Verify file exists in S3 (per-database bucket) ---
|
|
370
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
|
|
371
|
+
const s3Head = await (0, s3_signer_1.headObject)(s3ForDb, file.key, file.content_type);
|
|
317
372
|
if (!s3Head) {
|
|
318
373
|
throw new Error('FILE_NOT_IN_S3: the file has not been uploaded yet');
|
|
319
374
|
}
|
|
320
375
|
// --- Content-type verification ---
|
|
321
376
|
if (s3Head.contentType && s3Head.contentType !== file.content_type) {
|
|
322
377
|
// Mark upload_request as rejected
|
|
323
|
-
await
|
|
378
|
+
await txClient.query({
|
|
379
|
+
text: `UPDATE ${storageConfig.uploadRequestsQualifiedName}
|
|
324
380
|
SET status = 'rejected'
|
|
325
|
-
WHERE file_id = $1 AND status = 'issued'`,
|
|
326
|
-
|
|
381
|
+
WHERE file_id = $1 AND status = 'issued'`,
|
|
382
|
+
values: [fileId],
|
|
383
|
+
});
|
|
327
384
|
throw new Error(`CONTENT_TYPE_MISMATCH: expected ${file.content_type}, got ${s3Head.contentType}`);
|
|
328
385
|
}
|
|
329
386
|
// --- Transition file to 'ready' ---
|
|
330
|
-
await
|
|
387
|
+
await txClient.query({
|
|
388
|
+
text: `UPDATE ${storageConfig.filesQualifiedName}
|
|
331
389
|
SET status = 'ready'
|
|
332
|
-
WHERE id = $1`,
|
|
390
|
+
WHERE id = $1`,
|
|
391
|
+
values: [fileId],
|
|
392
|
+
});
|
|
333
393
|
// --- Update upload_request to 'confirmed' ---
|
|
334
|
-
await
|
|
394
|
+
await txClient.query({
|
|
395
|
+
text: `UPDATE ${storageConfig.uploadRequestsQualifiedName}
|
|
335
396
|
SET status = 'confirmed', confirmed_at = NOW()
|
|
336
|
-
WHERE file_id = $1 AND status = 'issued'`,
|
|
337
|
-
|
|
397
|
+
WHERE file_id = $1 AND status = 'issued'`,
|
|
398
|
+
values: [fileId],
|
|
399
|
+
});
|
|
338
400
|
return {
|
|
339
401
|
fileId: file.id,
|
|
340
402
|
status: 'ready',
|
|
341
403
|
success: true,
|
|
342
404
|
};
|
|
343
|
-
}
|
|
344
|
-
catch (err) {
|
|
345
|
-
await pgClient.query('ROLLBACK');
|
|
346
|
-
throw err;
|
|
347
|
-
}
|
|
405
|
+
});
|
|
348
406
|
});
|
|
349
407
|
});
|
|
350
408
|
},
|
|
@@ -7,7 +7,10 @@ import type { StorageModuleConfig, BucketConfig } from './types';
|
|
|
7
7
|
* @returns StorageModuleConfig or null if no storage module is provisioned
|
|
8
8
|
*/
|
|
9
9
|
export declare function getStorageModuleConfig(pgClient: {
|
|
10
|
-
query: (
|
|
10
|
+
query: (opts: {
|
|
11
|
+
text: string;
|
|
12
|
+
values?: unknown[];
|
|
13
|
+
}) => Promise<{
|
|
11
14
|
rows: unknown[];
|
|
12
15
|
}>;
|
|
13
16
|
}, databaseId: string): Promise<StorageModuleConfig | null>;
|
|
@@ -24,10 +27,21 @@ export declare function getStorageModuleConfig(pgClient: {
|
|
|
24
27
|
* @returns BucketConfig or null if the bucket doesn't exist / isn't accessible
|
|
25
28
|
*/
|
|
26
29
|
export declare function getBucketConfig(pgClient: {
|
|
27
|
-
query: (
|
|
30
|
+
query: (opts: {
|
|
31
|
+
text: string;
|
|
32
|
+
values?: unknown[];
|
|
33
|
+
}) => Promise<{
|
|
28
34
|
rows: unknown[];
|
|
29
35
|
}>;
|
|
30
36
|
}, storageConfig: StorageModuleConfig, databaseId: string, bucketKey: string): Promise<BucketConfig | null>;
|
|
37
|
+
/**
|
|
38
|
+
* Check whether an S3 bucket has already been provisioned (cached).
|
|
39
|
+
*/
|
|
40
|
+
export declare function isS3BucketProvisioned(s3BucketName: string): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Mark an S3 bucket as provisioned in the in-memory cache.
|
|
43
|
+
*/
|
|
44
|
+
export declare function markS3BucketProvisioned(s3BucketName: string): void;
|
|
31
45
|
/**
|
|
32
46
|
* Clear the storage module cache AND bucket cache.
|
|
33
47
|
* Useful for testing or schema changes.
|
package/storage-module-cache.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.getStorageModuleConfig = getStorageModuleConfig;
|
|
4
4
|
exports.getBucketConfig = getBucketConfig;
|
|
5
|
+
exports.isS3BucketProvisioned = isS3BucketProvisioned;
|
|
6
|
+
exports.markS3BucketProvisioned = markS3BucketProvisioned;
|
|
5
7
|
exports.clearStorageModuleCache = clearStorageModuleCache;
|
|
6
8
|
exports.clearBucketCache = clearBucketCache;
|
|
7
9
|
const logger_1 = require("@pgpmjs/logger");
|
|
@@ -47,6 +49,7 @@ const STORAGE_MODULE_QUERY = `
|
|
|
47
49
|
sm.endpoint,
|
|
48
50
|
sm.public_url_prefix,
|
|
49
51
|
sm.provider,
|
|
52
|
+
sm.allowed_origins,
|
|
50
53
|
sm.upload_url_expiry_seconds,
|
|
51
54
|
sm.download_url_expiry_seconds,
|
|
52
55
|
sm.default_max_file_size,
|
|
@@ -76,7 +79,7 @@ async function getStorageModuleConfig(pgClient, databaseId) {
|
|
|
76
79
|
return cached;
|
|
77
80
|
}
|
|
78
81
|
log.debug(`Cache miss for database ${databaseId}, querying metaschema...`);
|
|
79
|
-
const result = await pgClient.query(STORAGE_MODULE_QUERY, [databaseId]);
|
|
82
|
+
const result = await pgClient.query({ text: STORAGE_MODULE_QUERY, values: [databaseId] });
|
|
80
83
|
if (result.rows.length === 0) {
|
|
81
84
|
log.warn(`No storage module found for database ${databaseId}`);
|
|
82
85
|
return null;
|
|
@@ -95,6 +98,7 @@ async function getStorageModuleConfig(pgClient, databaseId) {
|
|
|
95
98
|
endpoint: row.endpoint,
|
|
96
99
|
publicUrlPrefix: row.public_url_prefix,
|
|
97
100
|
provider: row.provider,
|
|
101
|
+
allowedOrigins: row.allowed_origins,
|
|
98
102
|
uploadUrlExpirySeconds: row.upload_url_expiry_seconds ?? DEFAULT_UPLOAD_URL_EXPIRY_SECONDS,
|
|
99
103
|
downloadUrlExpirySeconds: row.download_url_expiry_seconds ?? DEFAULT_DOWNLOAD_URL_EXPIRY_SECONDS,
|
|
100
104
|
defaultMaxFileSize: row.default_max_file_size ?? DEFAULT_MAX_FILE_SIZE,
|
|
@@ -144,10 +148,13 @@ async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey) {
|
|
|
144
148
|
return cached;
|
|
145
149
|
}
|
|
146
150
|
log.debug(`Bucket cache miss for ${databaseId}:${bucketKey}, querying DB...`);
|
|
147
|
-
const result = await pgClient.query(
|
|
151
|
+
const result = await pgClient.query({
|
|
152
|
+
text: `SELECT id, key, type, is_public, owner_id, allowed_mime_types, max_file_size
|
|
148
153
|
FROM ${storageConfig.bucketsQualifiedName}
|
|
149
154
|
WHERE key = $1
|
|
150
|
-
LIMIT 1`,
|
|
155
|
+
LIMIT 1`,
|
|
156
|
+
values: [bucketKey],
|
|
157
|
+
});
|
|
151
158
|
if (result.rows.length === 0) {
|
|
152
159
|
return null;
|
|
153
160
|
}
|
|
@@ -165,6 +172,34 @@ async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey) {
|
|
|
165
172
|
log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id})`);
|
|
166
173
|
return config;
|
|
167
174
|
}
|
|
175
|
+
// --- S3 bucket existence cache ---
|
|
176
|
+
/**
|
|
177
|
+
* In-memory set of S3 bucket names that are known to exist.
|
|
178
|
+
*
|
|
179
|
+
* Used by the lazy provisioning logic in the presigned URL plugin:
|
|
180
|
+
* before generating a presigned PUT URL, the plugin checks this set.
|
|
181
|
+
* If the bucket name is absent, it calls `ensureBucketProvisioned`
|
|
182
|
+
* to create the S3 bucket, then adds the name here. Subsequent
|
|
183
|
+
* requests for the same bucket skip the provisioning entirely.
|
|
184
|
+
*
|
|
185
|
+
* No TTL needed — S3 buckets are never deleted during normal operation.
|
|
186
|
+
* The set resets on server restart, which is fine because the
|
|
187
|
+
* provisioner's createBucket is idempotent (handles "already exists").
|
|
188
|
+
*/
|
|
189
|
+
const provisionedBuckets = new Set();
|
|
190
|
+
/**
|
|
191
|
+
* Check whether an S3 bucket has already been provisioned (cached).
|
|
192
|
+
*/
|
|
193
|
+
function isS3BucketProvisioned(s3BucketName) {
|
|
194
|
+
return provisionedBuckets.has(s3BucketName);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Mark an S3 bucket as provisioned in the in-memory cache.
|
|
198
|
+
*/
|
|
199
|
+
function markS3BucketProvisioned(s3BucketName) {
|
|
200
|
+
provisionedBuckets.add(s3BucketName);
|
|
201
|
+
log.debug(`Marked S3 bucket "${s3BucketName}" as provisioned`);
|
|
202
|
+
}
|
|
168
203
|
/**
|
|
169
204
|
* Clear the storage module cache AND bucket cache.
|
|
170
205
|
* Useful for testing or schema changes.
|
|
@@ -172,6 +207,7 @@ async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey) {
|
|
|
172
207
|
function clearStorageModuleCache() {
|
|
173
208
|
storageModuleCache.clear();
|
|
174
209
|
bucketCache.clear();
|
|
210
|
+
provisionedBuckets.clear();
|
|
175
211
|
}
|
|
176
212
|
/**
|
|
177
213
|
* Clear cached bucket entries for a specific database.
|
package/types.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface StorageModuleConfig {
|
|
|
37
37
|
publicUrlPrefix: string | null;
|
|
38
38
|
/** Storage provider type: 'minio', 's3', 'gcs', etc. (per-database override) */
|
|
39
39
|
provider: string | null;
|
|
40
|
+
/** CORS allowed origins (per-database override, NULL = use global fallback) */
|
|
41
|
+
allowedOrigins: string[] | null;
|
|
40
42
|
/** Presigned PUT URL expiry in seconds (default: 900 = 15 min) */
|
|
41
43
|
uploadUrlExpirySeconds: number;
|
|
42
44
|
/** Presigned GET URL expiry in seconds (default: 3600 = 1 hour) */
|
|
@@ -120,10 +122,50 @@ export interface S3Config {
|
|
|
120
122
|
* env-var reads and S3Client creation at module import time.
|
|
121
123
|
*/
|
|
122
124
|
export type S3ConfigOrGetter = S3Config | (() => S3Config);
|
|
125
|
+
/**
|
|
126
|
+
* Function to derive the actual S3 bucket name for a given database.
|
|
127
|
+
*
|
|
128
|
+
* When provided, the presigned URL plugin calls this on every request
|
|
129
|
+
* to determine which S3 bucket to use — enabling per-database bucket
|
|
130
|
+
* isolation. If not provided, falls back to `s3Config.bucket` (global).
|
|
131
|
+
*
|
|
132
|
+
* @param databaseId - The metaschema database UUID
|
|
133
|
+
* @returns The S3 bucket name for this database
|
|
134
|
+
*/
|
|
135
|
+
export type BucketNameResolver = (databaseId: string) => string;
|
|
136
|
+
/**
|
|
137
|
+
* Callback to lazily provision an S3 bucket on first use.
|
|
138
|
+
*
|
|
139
|
+
* Called by the presigned URL plugin before generating a presigned PUT URL
|
|
140
|
+
* when the bucket has not been seen before (tracked in an in-memory cache).
|
|
141
|
+
* The implementation should create and fully configure the S3 bucket
|
|
142
|
+
* (privacy policies, CORS, lifecycle rules, etc.) — or no-op if the
|
|
143
|
+
* bucket already exists.
|
|
144
|
+
*
|
|
145
|
+
* @param bucketName - The S3 bucket name to provision
|
|
146
|
+
* @param accessType - The logical bucket type ('public', 'private', 'temp')
|
|
147
|
+
* @param databaseId - The metaschema database UUID
|
|
148
|
+
* @param allowedOrigins - Per-database CORS origins (from storage_module), or null to use global fallback
|
|
149
|
+
*/
|
|
150
|
+
export type EnsureBucketProvisioned = (bucketName: string, accessType: 'public' | 'private' | 'temp', databaseId: string, allowedOrigins: string[] | null) => Promise<void>;
|
|
123
151
|
/**
|
|
124
152
|
* Plugin options for the presigned URL plugin.
|
|
125
153
|
*/
|
|
126
154
|
export interface PresignedUrlPluginOptions {
|
|
127
155
|
/** S3 configuration (concrete or lazy getter) */
|
|
128
156
|
s3: S3ConfigOrGetter;
|
|
157
|
+
/**
|
|
158
|
+
* Optional function to resolve S3 bucket name per-database.
|
|
159
|
+
* When set, each database gets its own S3 bucket instead of sharing
|
|
160
|
+
* the global `s3Config.bucket`. The S3 credentials (client) remain shared.
|
|
161
|
+
*/
|
|
162
|
+
resolveBucketName?: BucketNameResolver;
|
|
163
|
+
/**
|
|
164
|
+
* Optional callback to lazily provision an S3 bucket on first upload.
|
|
165
|
+
* When set, the plugin calls this before generating a presigned PUT URL
|
|
166
|
+
* for any S3 bucket it hasn't seen yet (tracked in an in-memory cache).
|
|
167
|
+
* This enables graceful bucket creation without requiring buckets to
|
|
168
|
+
* exist at database provisioning time.
|
|
169
|
+
*/
|
|
170
|
+
ensureBucketProvisioned?: EnsureBucketProvisioned;
|
|
129
171
|
}
|