graphile-presigned-url-plugin 0.3.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/download-url-field.js +36 -13
- package/esm/download-url-field.js +36 -13
- package/esm/index.d.ts +2 -2
- package/esm/index.js +1 -1
- package/esm/plugin.js +52 -5
- package/esm/storage-module-cache.d.ts +8 -0
- package/esm/storage-module-cache.js +31 -0
- 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 +51 -4
- package/storage-module-cache.d.ts +8 -0
- package/storage-module-cache.js +33 -0
- 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,39 @@ 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
|
|
106
|
+
const resolved = await withPgClient(null, async (pgClient) => {
|
|
92
107
|
const dbResult = await pgClient.query(`SELECT jwt_private.current_database_id() AS id`);
|
|
93
108
|
const databaseId = dbResult.rows[0]?.id;
|
|
94
109
|
if (!databaseId)
|
|
95
110
|
return null;
|
|
96
|
-
|
|
111
|
+
const config = await (0, storage_module_cache_1.getStorageModuleConfig)(pgClient, databaseId);
|
|
112
|
+
if (!config)
|
|
113
|
+
return null;
|
|
114
|
+
return { config, databaseId };
|
|
97
115
|
});
|
|
98
|
-
if (
|
|
99
|
-
downloadUrlExpirySeconds = config.downloadUrlExpirySeconds;
|
|
116
|
+
if (resolved) {
|
|
117
|
+
downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds;
|
|
118
|
+
s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.databaseId);
|
|
100
119
|
}
|
|
101
120
|
}
|
|
102
121
|
}
|
|
103
122
|
catch {
|
|
104
|
-
// 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}`;
|
|
105
128
|
}
|
|
106
|
-
// Private file: generate presigned GET URL
|
|
107
|
-
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);
|
|
108
131
|
},
|
|
109
132
|
}),
|
|
110
133
|
}, '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,39 @@ 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
|
|
103
|
+
const resolved = await withPgClient(null, async (pgClient) => {
|
|
89
104
|
const dbResult = await pgClient.query(`SELECT jwt_private.current_database_id() AS id`);
|
|
90
105
|
const databaseId = dbResult.rows[0]?.id;
|
|
91
106
|
if (!databaseId)
|
|
92
107
|
return null;
|
|
93
|
-
|
|
108
|
+
const config = await getStorageModuleConfig(pgClient, databaseId);
|
|
109
|
+
if (!config)
|
|
110
|
+
return null;
|
|
111
|
+
return { config, databaseId };
|
|
94
112
|
});
|
|
95
|
-
if (
|
|
96
|
-
downloadUrlExpirySeconds = config.downloadUrlExpirySeconds;
|
|
113
|
+
if (resolved) {
|
|
114
|
+
downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds;
|
|
115
|
+
s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.databaseId);
|
|
97
116
|
}
|
|
98
117
|
}
|
|
99
118
|
}
|
|
100
119
|
catch {
|
|
101
|
-
// 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}`;
|
|
102
125
|
}
|
|
103
|
-
// Private file: generate presigned GET URL
|
|
104
|
-
return generatePresignedGetUrl(
|
|
126
|
+
// Private file: generate presigned GET URL (per-database bucket)
|
|
127
|
+
return generatePresignedGetUrl(s3ForDb, key, downloadUrlExpirySeconds, filename || undefined);
|
|
105
128
|
},
|
|
106
129
|
}),
|
|
107
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, 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) ---
|
|
@@ -66,6 +66,49 @@ function resolveS3(options) {
|
|
|
66
66
|
}
|
|
67
67
|
return options.s3;
|
|
68
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
|
+
}
|
|
69
112
|
export function createPresignedUrlPlugin(options) {
|
|
70
113
|
return extendSchema(() => ({
|
|
71
114
|
typeDefs: gql `
|
|
@@ -241,8 +284,11 @@ export function createPresignedUrlPlugin(options) {
|
|
|
241
284
|
bucket.is_public,
|
|
242
285
|
]);
|
|
243
286
|
const fileId = fileResult.rows[0].id;
|
|
244
|
-
// ---
|
|
245
|
-
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);
|
|
246
292
|
const expiresAt = new Date(Date.now() + storageConfig.uploadUrlExpirySeconds * 1000).toISOString();
|
|
247
293
|
// --- Track the upload request ---
|
|
248
294
|
await pgClient.query(`INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
|
|
@@ -308,8 +354,9 @@ export function createPresignedUrlPlugin(options) {
|
|
|
308
354
|
success: true,
|
|
309
355
|
};
|
|
310
356
|
}
|
|
311
|
-
// --- Verify file exists in S3 ---
|
|
312
|
-
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);
|
|
313
360
|
if (!s3Head) {
|
|
314
361
|
throw new Error('FILE_NOT_IN_S3: the file has not been uploaded yet');
|
|
315
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.
|
|
@@ -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,
|
|
@@ -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,
|
|
@@ -159,6 +161,34 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
|
|
|
159
161
|
log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id})`);
|
|
160
162
|
return config;
|
|
161
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
|
+
}
|
|
162
192
|
/**
|
|
163
193
|
* Clear the storage module cache AND bucket cache.
|
|
164
194
|
* Useful for testing or schema changes.
|
|
@@ -166,6 +196,7 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
|
|
|
166
196
|
export function clearStorageModuleCache() {
|
|
167
197
|
storageModuleCache.clear();
|
|
168
198
|
bucketCache.clear();
|
|
199
|
+
provisionedBuckets.clear();
|
|
169
200
|
}
|
|
170
201
|
/**
|
|
171
202
|
* 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.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",
|
|
@@ -59,5 +59,5 @@
|
|
|
59
59
|
"@types/node": "^22.19.11",
|
|
60
60
|
"makage": "^0.1.10"
|
|
61
61
|
},
|
|
62
|
-
"gitHead": "
|
|
62
|
+
"gitHead": "3bf7c522cf9f9d2595750ac7cea81d470b3e6c30"
|
|
63
63
|
}
|
package/plugin.js
CHANGED
|
@@ -70,6 +70,49 @@ function resolveS3(options) {
|
|
|
70
70
|
}
|
|
71
71
|
return options.s3;
|
|
72
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
|
+
}
|
|
73
116
|
function createPresignedUrlPlugin(options) {
|
|
74
117
|
return (0, graphile_utils_1.extendSchema)(() => ({
|
|
75
118
|
typeDefs: (0, graphile_utils_1.gql) `
|
|
@@ -245,8 +288,11 @@ function createPresignedUrlPlugin(options) {
|
|
|
245
288
|
bucket.is_public,
|
|
246
289
|
]);
|
|
247
290
|
const fileId = fileResult.rows[0].id;
|
|
248
|
-
// ---
|
|
249
|
-
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);
|
|
250
296
|
const expiresAt = new Date(Date.now() + storageConfig.uploadUrlExpirySeconds * 1000).toISOString();
|
|
251
297
|
// --- Track the upload request ---
|
|
252
298
|
await pgClient.query(`INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
|
|
@@ -312,8 +358,9 @@ function createPresignedUrlPlugin(options) {
|
|
|
312
358
|
success: true,
|
|
313
359
|
};
|
|
314
360
|
}
|
|
315
|
-
// --- Verify file exists in S3 ---
|
|
316
|
-
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);
|
|
317
364
|
if (!s3Head) {
|
|
318
365
|
throw new Error('FILE_NOT_IN_S3: the file has not been uploaded yet');
|
|
319
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");
|
|
@@ -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,
|
|
@@ -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,
|
|
@@ -165,6 +169,34 @@ async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey) {
|
|
|
165
169
|
log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id})`);
|
|
166
170
|
return config;
|
|
167
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
|
+
}
|
|
168
200
|
/**
|
|
169
201
|
* Clear the storage module cache AND bucket cache.
|
|
170
202
|
* Useful for testing or schema changes.
|
|
@@ -172,6 +204,7 @@ async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey) {
|
|
|
172
204
|
function clearStorageModuleCache() {
|
|
173
205
|
storageModuleCache.clear();
|
|
174
206
|
bucketCache.clear();
|
|
207
|
+
provisionedBuckets.clear();
|
|
175
208
|
}
|
|
176
209
|
/**
|
|
177
210
|
* 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
|
}
|