graphile-presigned-url-plugin 0.2.0 → 0.4.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 +12 -0
- package/download-url-field.d.ts +0 -9
- package/download-url-field.js +48 -13
- package/esm/download-url-field.d.ts +0 -9
- package/esm/download-url-field.js +48 -13
- package/esm/index.d.ts +2 -2
- package/esm/index.js +1 -1
- package/esm/plugin.js +66 -6
- package/esm/storage-module-cache.d.ts +8 -0
- package/esm/storage-module-cache.js +37 -0
- package/esm/types.d.ts +57 -2
- package/index.d.ts +2 -2
- package/index.js +3 -1
- package/package.json +3 -2
- package/plugin.js +65 -5
- package/storage-module-cache.d.ts +8 -0
- package/storage-module-cache.js +39 -0
- package/types.d.ts +57 -2
package/README.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# graphile-presigned-url-plugin
|
|
2
2
|
|
|
3
|
+
<p align="center" width="100%">
|
|
4
|
+
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<p align="center" width="100%">
|
|
8
|
+
<a href="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml">
|
|
9
|
+
<img height="20" src="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml/badge.svg" />
|
|
10
|
+
</a>
|
|
11
|
+
<a href="https://github.com/constructive-io/constructive/blob/main/LICENSE"><img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/></a>
|
|
12
|
+
<a href="https://www.npmjs.com/package/graphile-presigned-url-plugin"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/constructive?filename=graphile%2Fgraphile-presigned-url-plugin%2Fpackage.json"/></a>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
3
15
|
Presigned URL upload plugin for PostGraphile v5.
|
|
4
16
|
|
|
5
17
|
## Features
|
package/download-url-field.d.ts
CHANGED
|
@@ -14,14 +14,5 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import type { GraphileConfig } from 'graphile-config';
|
|
16
16
|
import type { PresignedUrlPluginOptions } from './types';
|
|
17
|
-
/**
|
|
18
|
-
* Creates the downloadUrl computed field plugin.
|
|
19
|
-
*
|
|
20
|
-
* This is a separate plugin from the main presigned URL plugin because it
|
|
21
|
-
* uses the GraphQLObjectType_fields hook (low-level) rather than extendSchema.
|
|
22
|
-
* The downloadUrl field needs to be added dynamically to whatever table is
|
|
23
|
-
* the storage module's files table, which we discover at schema-build time
|
|
24
|
-
* via the `@storageFiles` smart tag.
|
|
25
|
-
*/
|
|
26
17
|
export declare function createDownloadUrlPlugin(options: PresignedUrlPluginOptions): GraphileConfig.Plugin;
|
|
27
18
|
export default createDownloadUrlPlugin;
|
package/download-url-field.js
CHANGED
|
@@ -28,8 +28,38 @@ const log = new logger_1.Logger('graphile-presigned-url:download-url');
|
|
|
28
28
|
* the storage module's files table, which we discover at schema-build time
|
|
29
29
|
* via the `@storageFiles` smart tag.
|
|
30
30
|
*/
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the S3 config from the options. If the option is a lazy getter
|
|
33
|
+
* function, call it (and cache the result).
|
|
34
|
+
*/
|
|
35
|
+
function resolveS3(options) {
|
|
36
|
+
if (typeof options.s3 === 'function') {
|
|
37
|
+
const resolved = options.s3();
|
|
38
|
+
options.s3 = resolved;
|
|
39
|
+
return resolved;
|
|
40
|
+
}
|
|
41
|
+
return options.s3;
|
|
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
|
+
}
|
|
31
62
|
function createDownloadUrlPlugin(options) {
|
|
32
|
-
const { s3 } = options;
|
|
33
63
|
return {
|
|
34
64
|
name: 'PresignedUrlDownloadPlugin',
|
|
35
65
|
version: '0.1.0',
|
|
@@ -65,34 +95,39 @@ function createDownloadUrlPlugin(options) {
|
|
|
65
95
|
if (status !== 'ready' && status !== 'processed') {
|
|
66
96
|
return null;
|
|
67
97
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return `${s3.publicUrlPrefix}/${key}`;
|
|
71
|
-
}
|
|
72
|
-
// 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
|
|
73
100
|
let downloadUrlExpirySeconds = 3600; // fallback default
|
|
74
101
|
try {
|
|
75
102
|
const withPgClient = context.pgSettings
|
|
76
103
|
? context.withPgClient
|
|
77
104
|
: null;
|
|
78
105
|
if (withPgClient) {
|
|
79
|
-
const
|
|
106
|
+
const resolved = await withPgClient(null, async (pgClient) => {
|
|
80
107
|
const dbResult = await pgClient.query(`SELECT jwt_private.current_database_id() AS id`);
|
|
81
108
|
const databaseId = dbResult.rows[0]?.id;
|
|
82
109
|
if (!databaseId)
|
|
83
110
|
return null;
|
|
84
|
-
|
|
111
|
+
const config = await (0, storage_module_cache_1.getStorageModuleConfig)(pgClient, databaseId);
|
|
112
|
+
if (!config)
|
|
113
|
+
return null;
|
|
114
|
+
return { config, databaseId };
|
|
85
115
|
});
|
|
86
|
-
if (
|
|
87
|
-
downloadUrlExpirySeconds = config.downloadUrlExpirySeconds;
|
|
116
|
+
if (resolved) {
|
|
117
|
+
downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds;
|
|
118
|
+
s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.databaseId);
|
|
88
119
|
}
|
|
89
120
|
}
|
|
90
121
|
}
|
|
91
122
|
catch {
|
|
92
|
-
// Fall back to
|
|
123
|
+
// Fall back to global config if lookup fails
|
|
124
|
+
}
|
|
125
|
+
if (isPublic && s3ForDb.publicUrlPrefix) {
|
|
126
|
+
// Public file: return direct CDN URL (per-database prefix)
|
|
127
|
+
return `${s3ForDb.publicUrlPrefix}/${key}`;
|
|
93
128
|
}
|
|
94
|
-
// Private file: generate presigned GET URL
|
|
95
|
-
return (0, s3_signer_1.generatePresignedGetUrl)(
|
|
129
|
+
// Private file: generate presigned GET URL (per-database bucket)
|
|
130
|
+
return (0, s3_signer_1.generatePresignedGetUrl)(s3ForDb, key, downloadUrlExpirySeconds, filename || undefined);
|
|
96
131
|
},
|
|
97
132
|
}),
|
|
98
133
|
}, 'PresignedUrlDownloadPlugin adding downloadUrl field');
|
|
@@ -14,14 +14,5 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import type { GraphileConfig } from 'graphile-config';
|
|
16
16
|
import type { PresignedUrlPluginOptions } from './types';
|
|
17
|
-
/**
|
|
18
|
-
* Creates the downloadUrl computed field plugin.
|
|
19
|
-
*
|
|
20
|
-
* This is a separate plugin from the main presigned URL plugin because it
|
|
21
|
-
* uses the GraphQLObjectType_fields hook (low-level) rather than extendSchema.
|
|
22
|
-
* The downloadUrl field needs to be added dynamically to whatever table is
|
|
23
|
-
* the storage module's files table, which we discover at schema-build time
|
|
24
|
-
* via the `@storageFiles` smart tag.
|
|
25
|
-
*/
|
|
26
17
|
export declare function createDownloadUrlPlugin(options: PresignedUrlPluginOptions): GraphileConfig.Plugin;
|
|
27
18
|
export default createDownloadUrlPlugin;
|
|
@@ -25,8 +25,38 @@ const log = new Logger('graphile-presigned-url:download-url');
|
|
|
25
25
|
* the storage module's files table, which we discover at schema-build time
|
|
26
26
|
* via the `@storageFiles` smart tag.
|
|
27
27
|
*/
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the S3 config from the options. If the option is a lazy getter
|
|
30
|
+
* function, call it (and cache the result).
|
|
31
|
+
*/
|
|
32
|
+
function resolveS3(options) {
|
|
33
|
+
if (typeof options.s3 === 'function') {
|
|
34
|
+
const resolved = options.s3();
|
|
35
|
+
options.s3 = resolved;
|
|
36
|
+
return resolved;
|
|
37
|
+
}
|
|
38
|
+
return options.s3;
|
|
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
|
+
}
|
|
28
59
|
export function createDownloadUrlPlugin(options) {
|
|
29
|
-
const { s3 } = options;
|
|
30
60
|
return {
|
|
31
61
|
name: 'PresignedUrlDownloadPlugin',
|
|
32
62
|
version: '0.1.0',
|
|
@@ -62,34 +92,39 @@ export function createDownloadUrlPlugin(options) {
|
|
|
62
92
|
if (status !== 'ready' && status !== 'processed') {
|
|
63
93
|
return null;
|
|
64
94
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return `${s3.publicUrlPrefix}/${key}`;
|
|
68
|
-
}
|
|
69
|
-
// 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
|
|
70
97
|
let downloadUrlExpirySeconds = 3600; // fallback default
|
|
71
98
|
try {
|
|
72
99
|
const withPgClient = context.pgSettings
|
|
73
100
|
? context.withPgClient
|
|
74
101
|
: null;
|
|
75
102
|
if (withPgClient) {
|
|
76
|
-
const
|
|
103
|
+
const resolved = await withPgClient(null, async (pgClient) => {
|
|
77
104
|
const dbResult = await pgClient.query(`SELECT jwt_private.current_database_id() AS id`);
|
|
78
105
|
const databaseId = dbResult.rows[0]?.id;
|
|
79
106
|
if (!databaseId)
|
|
80
107
|
return null;
|
|
81
|
-
|
|
108
|
+
const config = await getStorageModuleConfig(pgClient, databaseId);
|
|
109
|
+
if (!config)
|
|
110
|
+
return null;
|
|
111
|
+
return { config, databaseId };
|
|
82
112
|
});
|
|
83
|
-
if (
|
|
84
|
-
downloadUrlExpirySeconds = config.downloadUrlExpirySeconds;
|
|
113
|
+
if (resolved) {
|
|
114
|
+
downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds;
|
|
115
|
+
s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.databaseId);
|
|
85
116
|
}
|
|
86
117
|
}
|
|
87
118
|
}
|
|
88
119
|
catch {
|
|
89
|
-
// Fall back to
|
|
120
|
+
// Fall back to global config if lookup fails
|
|
121
|
+
}
|
|
122
|
+
if (isPublic && s3ForDb.publicUrlPrefix) {
|
|
123
|
+
// Public file: return direct CDN URL (per-database prefix)
|
|
124
|
+
return `${s3ForDb.publicUrlPrefix}/${key}`;
|
|
90
125
|
}
|
|
91
|
-
// Private file: generate presigned GET URL
|
|
92
|
-
return generatePresignedGetUrl(
|
|
126
|
+
// Private file: generate presigned GET URL (per-database bucket)
|
|
127
|
+
return generatePresignedGetUrl(s3ForDb, key, downloadUrlExpirySeconds, filename || undefined);
|
|
93
128
|
},
|
|
94
129
|
}),
|
|
95
130
|
}, '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, 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) ---
|
|
@@ -52,8 +52,64 @@ async function resolveDatabaseId(pgClient) {
|
|
|
52
52
|
return result.rows[0]?.id ?? null;
|
|
53
53
|
}
|
|
54
54
|
// --- Plugin factory ---
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the S3 config from the options. If the option is a lazy getter
|
|
57
|
+
* function, call it (and cache the result). This avoids reading env vars
|
|
58
|
+
* or constructing an S3Client at module-import time.
|
|
59
|
+
*/
|
|
60
|
+
function resolveS3(options) {
|
|
61
|
+
if (typeof options.s3 === 'function') {
|
|
62
|
+
const resolved = options.s3();
|
|
63
|
+
// Cache so subsequent calls don't re-evaluate
|
|
64
|
+
options.s3 = resolved;
|
|
65
|
+
return resolved;
|
|
66
|
+
}
|
|
67
|
+
return options.s3;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Build a per-database S3Config by overlaying storage_module overrides
|
|
71
|
+
* onto the global S3Config.
|
|
72
|
+
*
|
|
73
|
+
* - Bucket name: from resolveBucketName(databaseId) if provided, else global
|
|
74
|
+
* - publicUrlPrefix: from storageConfig.publicUrlPrefix if set, else global
|
|
75
|
+
* - S3 client (credentials, endpoint): always global (shared IAM key)
|
|
76
|
+
*/
|
|
77
|
+
function resolveS3ForDatabase(options, storageConfig, databaseId) {
|
|
78
|
+
const globalS3 = resolveS3(options);
|
|
79
|
+
const bucket = options.resolveBucketName
|
|
80
|
+
? options.resolveBucketName(databaseId)
|
|
81
|
+
: globalS3.bucket;
|
|
82
|
+
const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix;
|
|
83
|
+
if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
84
|
+
return globalS3;
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
...globalS3,
|
|
88
|
+
bucket,
|
|
89
|
+
...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Ensure the S3 bucket for a database exists, provisioning it lazily if needed.
|
|
94
|
+
*
|
|
95
|
+
* Checks an in-memory Set of known-provisioned bucket names. On the first
|
|
96
|
+
* request for an unseen bucket, calls the `ensureBucketProvisioned` callback
|
|
97
|
+
* (which creates the bucket with correct CORS, policies, etc.), then marks
|
|
98
|
+
* it as provisioned so subsequent requests skip the check entirely.
|
|
99
|
+
*
|
|
100
|
+
* If no `ensureBucketProvisioned` callback is configured, this is a no-op.
|
|
101
|
+
*/
|
|
102
|
+
async function ensureS3BucketExists(options, s3BucketName, bucket, databaseId, allowedOrigins) {
|
|
103
|
+
if (!options.ensureBucketProvisioned)
|
|
104
|
+
return;
|
|
105
|
+
if (isS3BucketProvisioned(s3BucketName))
|
|
106
|
+
return;
|
|
107
|
+
log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
|
|
108
|
+
await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
|
|
109
|
+
markS3BucketProvisioned(s3BucketName);
|
|
110
|
+
log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
|
|
111
|
+
}
|
|
55
112
|
export function createPresignedUrlPlugin(options) {
|
|
56
|
-
const { s3 } = options;
|
|
57
113
|
return extendSchema(() => ({
|
|
58
114
|
typeDefs: gql `
|
|
59
115
|
input RequestUploadUrlInput {
|
|
@@ -228,8 +284,11 @@ export function createPresignedUrlPlugin(options) {
|
|
|
228
284
|
bucket.is_public,
|
|
229
285
|
]);
|
|
230
286
|
const fileId = fileResult.rows[0].id;
|
|
231
|
-
// ---
|
|
232
|
-
const
|
|
287
|
+
// --- Ensure the S3 bucket exists (lazy provisioning) ---
|
|
288
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
|
|
289
|
+
await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
|
|
290
|
+
// --- Generate presigned PUT URL (per-database bucket) ---
|
|
291
|
+
const uploadUrl = await generatePresignedPutUrl(s3ForDb, s3Key, contentType, size, storageConfig.uploadUrlExpirySeconds);
|
|
233
292
|
const expiresAt = new Date(Date.now() + storageConfig.uploadUrlExpirySeconds * 1000).toISOString();
|
|
234
293
|
// --- Track the upload request ---
|
|
235
294
|
await pgClient.query(`INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
|
|
@@ -295,8 +354,9 @@ export function createPresignedUrlPlugin(options) {
|
|
|
295
354
|
success: true,
|
|
296
355
|
};
|
|
297
356
|
}
|
|
298
|
-
// --- Verify file exists in S3 ---
|
|
299
|
-
const
|
|
357
|
+
// --- Verify file exists in S3 (per-database bucket) ---
|
|
358
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
|
|
359
|
+
const s3Head = await headObject(s3ForDb, file.key, file.content_type);
|
|
300
360
|
if (!s3Head) {
|
|
301
361
|
throw new Error('FILE_NOT_IN_S3: the file has not been uploaded yet');
|
|
302
362
|
}
|
|
@@ -28,6 +28,14 @@ export declare function getBucketConfig(pgClient: {
|
|
|
28
28
|
rows: unknown[];
|
|
29
29
|
}>;
|
|
30
30
|
}, storageConfig: StorageModuleConfig, databaseId: string, bucketKey: string): Promise<BucketConfig | null>;
|
|
31
|
+
/**
|
|
32
|
+
* Check whether an S3 bucket has already been provisioned (cached).
|
|
33
|
+
*/
|
|
34
|
+
export declare function isS3BucketProvisioned(s3BucketName: string): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Mark an S3 bucket as provisioned in the in-memory cache.
|
|
37
|
+
*/
|
|
38
|
+
export declare function markS3BucketProvisioned(s3BucketName: string): void;
|
|
31
39
|
/**
|
|
32
40
|
* Clear the storage module cache AND bucket cache.
|
|
33
41
|
* Useful for testing or schema changes.
|
|
@@ -38,6 +38,10 @@ const STORAGE_MODULE_QUERY = `
|
|
|
38
38
|
ft.name AS files_table,
|
|
39
39
|
urs.schema_name AS upload_requests_schema,
|
|
40
40
|
urt.name AS upload_requests_table,
|
|
41
|
+
sm.endpoint,
|
|
42
|
+
sm.public_url_prefix,
|
|
43
|
+
sm.provider,
|
|
44
|
+
sm.allowed_origins,
|
|
41
45
|
sm.upload_url_expiry_seconds,
|
|
42
46
|
sm.download_url_expiry_seconds,
|
|
43
47
|
sm.default_max_file_size,
|
|
@@ -83,6 +87,10 @@ export async function getStorageModuleConfig(pgClient, databaseId) {
|
|
|
83
87
|
bucketsTableName: row.buckets_table,
|
|
84
88
|
filesTableName: row.files_table,
|
|
85
89
|
uploadRequestsTableName: row.upload_requests_table,
|
|
90
|
+
endpoint: row.endpoint,
|
|
91
|
+
publicUrlPrefix: row.public_url_prefix,
|
|
92
|
+
provider: row.provider,
|
|
93
|
+
allowedOrigins: row.allowed_origins,
|
|
86
94
|
uploadUrlExpirySeconds: row.upload_url_expiry_seconds ?? DEFAULT_UPLOAD_URL_EXPIRY_SECONDS,
|
|
87
95
|
downloadUrlExpirySeconds: row.download_url_expiry_seconds ?? DEFAULT_DOWNLOAD_URL_EXPIRY_SECONDS,
|
|
88
96
|
defaultMaxFileSize: row.default_max_file_size ?? DEFAULT_MAX_FILE_SIZE,
|
|
@@ -153,6 +161,34 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
|
|
|
153
161
|
log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id})`);
|
|
154
162
|
return config;
|
|
155
163
|
}
|
|
164
|
+
// --- S3 bucket existence cache ---
|
|
165
|
+
/**
|
|
166
|
+
* In-memory set of S3 bucket names that are known to exist.
|
|
167
|
+
*
|
|
168
|
+
* Used by the lazy provisioning logic in the presigned URL plugin:
|
|
169
|
+
* before generating a presigned PUT URL, the plugin checks this set.
|
|
170
|
+
* If the bucket name is absent, it calls `ensureBucketProvisioned`
|
|
171
|
+
* to create the S3 bucket, then adds the name here. Subsequent
|
|
172
|
+
* requests for the same bucket skip the provisioning entirely.
|
|
173
|
+
*
|
|
174
|
+
* No TTL needed — S3 buckets are never deleted during normal operation.
|
|
175
|
+
* The set resets on server restart, which is fine because the
|
|
176
|
+
* provisioner's createBucket is idempotent (handles "already exists").
|
|
177
|
+
*/
|
|
178
|
+
const provisionedBuckets = new Set();
|
|
179
|
+
/**
|
|
180
|
+
* Check whether an S3 bucket has already been provisioned (cached).
|
|
181
|
+
*/
|
|
182
|
+
export function isS3BucketProvisioned(s3BucketName) {
|
|
183
|
+
return provisionedBuckets.has(s3BucketName);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Mark an S3 bucket as provisioned in the in-memory cache.
|
|
187
|
+
*/
|
|
188
|
+
export function markS3BucketProvisioned(s3BucketName) {
|
|
189
|
+
provisionedBuckets.add(s3BucketName);
|
|
190
|
+
log.debug(`Marked S3 bucket "${s3BucketName}" as provisioned`);
|
|
191
|
+
}
|
|
156
192
|
/**
|
|
157
193
|
* Clear the storage module cache AND bucket cache.
|
|
158
194
|
* Useful for testing or schema changes.
|
|
@@ -160,6 +196,7 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
|
|
|
160
196
|
export function clearStorageModuleCache() {
|
|
161
197
|
storageModuleCache.clear();
|
|
162
198
|
bucketCache.clear();
|
|
199
|
+
provisionedBuckets.clear();
|
|
163
200
|
}
|
|
164
201
|
/**
|
|
165
202
|
* Clear cached bucket entries for a specific database.
|
package/esm/types.d.ts
CHANGED
|
@@ -31,6 +31,14 @@ export interface StorageModuleConfig {
|
|
|
31
31
|
filesTableName: string;
|
|
32
32
|
/** Upload requests table name */
|
|
33
33
|
uploadRequestsTableName: string;
|
|
34
|
+
/** S3-compatible API endpoint URL (per-database override) */
|
|
35
|
+
endpoint: string | null;
|
|
36
|
+
/** Public URL prefix for generating download URLs (per-database override) */
|
|
37
|
+
publicUrlPrefix: string | null;
|
|
38
|
+
/** Storage provider type: 'minio', 's3', 'gcs', etc. (per-database override) */
|
|
39
|
+
provider: string | null;
|
|
40
|
+
/** CORS allowed origins (per-database override, NULL = use global fallback) */
|
|
41
|
+
allowedOrigins: string[] | null;
|
|
34
42
|
/** Presigned PUT URL expiry in seconds (default: 900 = 15 min) */
|
|
35
43
|
uploadUrlExpirySeconds: number;
|
|
36
44
|
/** Presigned GET URL expiry in seconds (default: 3600 = 1 hour) */
|
|
@@ -107,10 +115,57 @@ export interface S3Config {
|
|
|
107
115
|
/** Public URL prefix for generating download URLs */
|
|
108
116
|
publicUrlPrefix?: string;
|
|
109
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* S3 configuration or a lazy getter that returns it on first use.
|
|
120
|
+
* When a function is provided, it will only be called when the first
|
|
121
|
+
* mutation or resolver actually needs the S3 client — avoiding eager
|
|
122
|
+
* env-var reads and S3Client creation at module import time.
|
|
123
|
+
*/
|
|
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>;
|
|
110
151
|
/**
|
|
111
152
|
* Plugin options for the presigned URL plugin.
|
|
112
153
|
*/
|
|
113
154
|
export interface PresignedUrlPluginOptions {
|
|
114
|
-
/** S3 configuration */
|
|
115
|
-
s3:
|
|
155
|
+
/** S3 configuration (concrete or lazy getter) */
|
|
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;
|
|
116
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, 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.0",
|
|
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",
|
|
@@ -55,8 +55,9 @@
|
|
|
55
55
|
"postgraphile": "5.0.0"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
|
+
"@constructive-io/s3-utils": "^2.10.2",
|
|
58
59
|
"@types/node": "^22.19.11",
|
|
59
60
|
"makage": "^0.1.10"
|
|
60
61
|
},
|
|
61
|
-
"gitHead": "
|
|
62
|
+
"gitHead": "3bf7c522cf9f9d2595750ac7cea81d470b3e6c30"
|
|
62
63
|
}
|
package/plugin.js
CHANGED
|
@@ -56,8 +56,64 @@ async function resolveDatabaseId(pgClient) {
|
|
|
56
56
|
return result.rows[0]?.id ?? null;
|
|
57
57
|
}
|
|
58
58
|
// --- Plugin factory ---
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the S3 config from the options. If the option is a lazy getter
|
|
61
|
+
* function, call it (and cache the result). This avoids reading env vars
|
|
62
|
+
* or constructing an S3Client at module-import time.
|
|
63
|
+
*/
|
|
64
|
+
function resolveS3(options) {
|
|
65
|
+
if (typeof options.s3 === 'function') {
|
|
66
|
+
const resolved = options.s3();
|
|
67
|
+
// Cache so subsequent calls don't re-evaluate
|
|
68
|
+
options.s3 = resolved;
|
|
69
|
+
return resolved;
|
|
70
|
+
}
|
|
71
|
+
return options.s3;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Build a per-database S3Config by overlaying storage_module overrides
|
|
75
|
+
* onto the global S3Config.
|
|
76
|
+
*
|
|
77
|
+
* - Bucket name: from resolveBucketName(databaseId) if provided, else global
|
|
78
|
+
* - publicUrlPrefix: from storageConfig.publicUrlPrefix if set, else global
|
|
79
|
+
* - S3 client (credentials, endpoint): always global (shared IAM key)
|
|
80
|
+
*/
|
|
81
|
+
function resolveS3ForDatabase(options, storageConfig, databaseId) {
|
|
82
|
+
const globalS3 = resolveS3(options);
|
|
83
|
+
const bucket = options.resolveBucketName
|
|
84
|
+
? options.resolveBucketName(databaseId)
|
|
85
|
+
: globalS3.bucket;
|
|
86
|
+
const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix;
|
|
87
|
+
if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
88
|
+
return globalS3;
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
...globalS3,
|
|
92
|
+
bucket,
|
|
93
|
+
...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Ensure the S3 bucket for a database exists, provisioning it lazily if needed.
|
|
98
|
+
*
|
|
99
|
+
* Checks an in-memory Set of known-provisioned bucket names. On the first
|
|
100
|
+
* request for an unseen bucket, calls the `ensureBucketProvisioned` callback
|
|
101
|
+
* (which creates the bucket with correct CORS, policies, etc.), then marks
|
|
102
|
+
* it as provisioned so subsequent requests skip the check entirely.
|
|
103
|
+
*
|
|
104
|
+
* If no `ensureBucketProvisioned` callback is configured, this is a no-op.
|
|
105
|
+
*/
|
|
106
|
+
async function ensureS3BucketExists(options, s3BucketName, bucket, databaseId, allowedOrigins) {
|
|
107
|
+
if (!options.ensureBucketProvisioned)
|
|
108
|
+
return;
|
|
109
|
+
if ((0, storage_module_cache_1.isS3BucketProvisioned)(s3BucketName))
|
|
110
|
+
return;
|
|
111
|
+
log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
|
|
112
|
+
await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
|
|
113
|
+
(0, storage_module_cache_1.markS3BucketProvisioned)(s3BucketName);
|
|
114
|
+
log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
|
|
115
|
+
}
|
|
59
116
|
function createPresignedUrlPlugin(options) {
|
|
60
|
-
const { s3 } = options;
|
|
61
117
|
return (0, graphile_utils_1.extendSchema)(() => ({
|
|
62
118
|
typeDefs: (0, graphile_utils_1.gql) `
|
|
63
119
|
input RequestUploadUrlInput {
|
|
@@ -232,8 +288,11 @@ function createPresignedUrlPlugin(options) {
|
|
|
232
288
|
bucket.is_public,
|
|
233
289
|
]);
|
|
234
290
|
const fileId = fileResult.rows[0].id;
|
|
235
|
-
// ---
|
|
236
|
-
const
|
|
291
|
+
// --- Ensure the S3 bucket exists (lazy provisioning) ---
|
|
292
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
|
|
293
|
+
await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
|
|
294
|
+
// --- Generate presigned PUT URL (per-database bucket) ---
|
|
295
|
+
const uploadUrl = await (0, s3_signer_1.generatePresignedPutUrl)(s3ForDb, s3Key, contentType, size, storageConfig.uploadUrlExpirySeconds);
|
|
237
296
|
const expiresAt = new Date(Date.now() + storageConfig.uploadUrlExpirySeconds * 1000).toISOString();
|
|
238
297
|
// --- Track the upload request ---
|
|
239
298
|
await pgClient.query(`INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
|
|
@@ -299,8 +358,9 @@ function createPresignedUrlPlugin(options) {
|
|
|
299
358
|
success: true,
|
|
300
359
|
};
|
|
301
360
|
}
|
|
302
|
-
// --- Verify file exists in S3 ---
|
|
303
|
-
const
|
|
361
|
+
// --- Verify file exists in S3 (per-database bucket) ---
|
|
362
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
|
|
363
|
+
const s3Head = await (0, s3_signer_1.headObject)(s3ForDb, file.key, file.content_type);
|
|
304
364
|
if (!s3Head) {
|
|
305
365
|
throw new Error('FILE_NOT_IN_S3: the file has not been uploaded yet');
|
|
306
366
|
}
|
|
@@ -28,6 +28,14 @@ export declare function getBucketConfig(pgClient: {
|
|
|
28
28
|
rows: unknown[];
|
|
29
29
|
}>;
|
|
30
30
|
}, storageConfig: StorageModuleConfig, databaseId: string, bucketKey: string): Promise<BucketConfig | null>;
|
|
31
|
+
/**
|
|
32
|
+
* Check whether an S3 bucket has already been provisioned (cached).
|
|
33
|
+
*/
|
|
34
|
+
export declare function isS3BucketProvisioned(s3BucketName: string): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Mark an S3 bucket as provisioned in the in-memory cache.
|
|
37
|
+
*/
|
|
38
|
+
export declare function markS3BucketProvisioned(s3BucketName: string): void;
|
|
31
39
|
/**
|
|
32
40
|
* Clear the storage module cache AND bucket cache.
|
|
33
41
|
* 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");
|
|
@@ -44,6 +46,10 @@ const STORAGE_MODULE_QUERY = `
|
|
|
44
46
|
ft.name AS files_table,
|
|
45
47
|
urs.schema_name AS upload_requests_schema,
|
|
46
48
|
urt.name AS upload_requests_table,
|
|
49
|
+
sm.endpoint,
|
|
50
|
+
sm.public_url_prefix,
|
|
51
|
+
sm.provider,
|
|
52
|
+
sm.allowed_origins,
|
|
47
53
|
sm.upload_url_expiry_seconds,
|
|
48
54
|
sm.download_url_expiry_seconds,
|
|
49
55
|
sm.default_max_file_size,
|
|
@@ -89,6 +95,10 @@ async function getStorageModuleConfig(pgClient, databaseId) {
|
|
|
89
95
|
bucketsTableName: row.buckets_table,
|
|
90
96
|
filesTableName: row.files_table,
|
|
91
97
|
uploadRequestsTableName: row.upload_requests_table,
|
|
98
|
+
endpoint: row.endpoint,
|
|
99
|
+
publicUrlPrefix: row.public_url_prefix,
|
|
100
|
+
provider: row.provider,
|
|
101
|
+
allowedOrigins: row.allowed_origins,
|
|
92
102
|
uploadUrlExpirySeconds: row.upload_url_expiry_seconds ?? DEFAULT_UPLOAD_URL_EXPIRY_SECONDS,
|
|
93
103
|
downloadUrlExpirySeconds: row.download_url_expiry_seconds ?? DEFAULT_DOWNLOAD_URL_EXPIRY_SECONDS,
|
|
94
104
|
defaultMaxFileSize: row.default_max_file_size ?? DEFAULT_MAX_FILE_SIZE,
|
|
@@ -159,6 +169,34 @@ async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey) {
|
|
|
159
169
|
log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id})`);
|
|
160
170
|
return config;
|
|
161
171
|
}
|
|
172
|
+
// --- S3 bucket existence cache ---
|
|
173
|
+
/**
|
|
174
|
+
* In-memory set of S3 bucket names that are known to exist.
|
|
175
|
+
*
|
|
176
|
+
* Used by the lazy provisioning logic in the presigned URL plugin:
|
|
177
|
+
* before generating a presigned PUT URL, the plugin checks this set.
|
|
178
|
+
* If the bucket name is absent, it calls `ensureBucketProvisioned`
|
|
179
|
+
* to create the S3 bucket, then adds the name here. Subsequent
|
|
180
|
+
* requests for the same bucket skip the provisioning entirely.
|
|
181
|
+
*
|
|
182
|
+
* No TTL needed — S3 buckets are never deleted during normal operation.
|
|
183
|
+
* The set resets on server restart, which is fine because the
|
|
184
|
+
* provisioner's createBucket is idempotent (handles "already exists").
|
|
185
|
+
*/
|
|
186
|
+
const provisionedBuckets = new Set();
|
|
187
|
+
/**
|
|
188
|
+
* Check whether an S3 bucket has already been provisioned (cached).
|
|
189
|
+
*/
|
|
190
|
+
function isS3BucketProvisioned(s3BucketName) {
|
|
191
|
+
return provisionedBuckets.has(s3BucketName);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Mark an S3 bucket as provisioned in the in-memory cache.
|
|
195
|
+
*/
|
|
196
|
+
function markS3BucketProvisioned(s3BucketName) {
|
|
197
|
+
provisionedBuckets.add(s3BucketName);
|
|
198
|
+
log.debug(`Marked S3 bucket "${s3BucketName}" as provisioned`);
|
|
199
|
+
}
|
|
162
200
|
/**
|
|
163
201
|
* Clear the storage module cache AND bucket cache.
|
|
164
202
|
* Useful for testing or schema changes.
|
|
@@ -166,6 +204,7 @@ async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey) {
|
|
|
166
204
|
function clearStorageModuleCache() {
|
|
167
205
|
storageModuleCache.clear();
|
|
168
206
|
bucketCache.clear();
|
|
207
|
+
provisionedBuckets.clear();
|
|
169
208
|
}
|
|
170
209
|
/**
|
|
171
210
|
* Clear cached bucket entries for a specific database.
|
package/types.d.ts
CHANGED
|
@@ -31,6 +31,14 @@ export interface StorageModuleConfig {
|
|
|
31
31
|
filesTableName: string;
|
|
32
32
|
/** Upload requests table name */
|
|
33
33
|
uploadRequestsTableName: string;
|
|
34
|
+
/** S3-compatible API endpoint URL (per-database override) */
|
|
35
|
+
endpoint: string | null;
|
|
36
|
+
/** Public URL prefix for generating download URLs (per-database override) */
|
|
37
|
+
publicUrlPrefix: string | null;
|
|
38
|
+
/** Storage provider type: 'minio', 's3', 'gcs', etc. (per-database override) */
|
|
39
|
+
provider: string | null;
|
|
40
|
+
/** CORS allowed origins (per-database override, NULL = use global fallback) */
|
|
41
|
+
allowedOrigins: string[] | null;
|
|
34
42
|
/** Presigned PUT URL expiry in seconds (default: 900 = 15 min) */
|
|
35
43
|
uploadUrlExpirySeconds: number;
|
|
36
44
|
/** Presigned GET URL expiry in seconds (default: 3600 = 1 hour) */
|
|
@@ -107,10 +115,57 @@ export interface S3Config {
|
|
|
107
115
|
/** Public URL prefix for generating download URLs */
|
|
108
116
|
publicUrlPrefix?: string;
|
|
109
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* S3 configuration or a lazy getter that returns it on first use.
|
|
120
|
+
* When a function is provided, it will only be called when the first
|
|
121
|
+
* mutation or resolver actually needs the S3 client — avoiding eager
|
|
122
|
+
* env-var reads and S3Client creation at module import time.
|
|
123
|
+
*/
|
|
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>;
|
|
110
151
|
/**
|
|
111
152
|
* Plugin options for the presigned URL plugin.
|
|
112
153
|
*/
|
|
113
154
|
export interface PresignedUrlPluginOptions {
|
|
114
|
-
/** S3 configuration */
|
|
115
|
-
s3:
|
|
155
|
+
/** S3 configuration (concrete or lazy getter) */
|
|
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;
|
|
116
171
|
}
|