graphile-presigned-url-plugin 1.9.2 → 1.10.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.d.ts +1 -1
- package/download-url-field.js +23 -24
- package/esm/download-url-field.d.ts +1 -1
- package/esm/download-url-field.js +24 -25
- package/esm/index.d.ts +4 -4
- package/esm/index.js +3 -3
- package/esm/plugin.d.ts +1 -1
- package/esm/plugin.js +126 -71
- package/esm/preset.js +1 -1
- package/esm/s3-signer.js +1 -1
- package/esm/storage-module-cache.d.ts +12 -1
- package/esm/storage-module-cache.js +15 -3
- package/esm/types.d.ts +7 -0
- package/index.d.ts +4 -4
- package/index.js +15 -15
- package/package.json +5 -5
- package/plugin.d.ts +1 -1
- package/plugin.js +125 -70
- package/preset.js +1 -1
- package/storage-module-cache.d.ts +12 -1
- package/storage-module-cache.js +16 -3
- package/types.d.ts +7 -0
package/download-url-field.d.ts
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
* lookups. Since downloadUrl is a computed field (not a real column),
|
|
19
19
|
* the plan() function is required for Grafast to execute the S3 signing.
|
|
20
20
|
*/
|
|
21
|
-
import type { GraphileConfig } from 'graphile-config';
|
|
22
21
|
import 'graphile-build';
|
|
22
|
+
import type { GraphileConfig } from 'graphile-config';
|
|
23
23
|
import type { PresignedUrlPluginOptions } from './types';
|
|
24
24
|
export declare function createDownloadUrlPlugin(options: PresignedUrlPluginOptions): GraphileConfig.Plugin;
|
|
25
25
|
export default createDownloadUrlPlugin;
|
package/download-url-field.js
CHANGED
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
23
|
exports.createDownloadUrlPlugin = createDownloadUrlPlugin;
|
|
24
24
|
require("graphile-build");
|
|
25
|
-
const grafast_1 = require("grafast");
|
|
26
25
|
const logger_1 = require("@pgpmjs/logger");
|
|
26
|
+
const grafast_1 = require("grafast");
|
|
27
27
|
const s3_signer_1 = require("./s3-signer");
|
|
28
28
|
const storage_module_cache_1 = require("./storage-module-cache");
|
|
29
29
|
const log = new logger_1.Logger('graphile-presigned-url:download-url');
|
|
@@ -49,21 +49,21 @@ function resolveS3(options) {
|
|
|
49
49
|
return options.s3;
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
|
-
* Build a per-database S3Config
|
|
53
|
-
*
|
|
52
|
+
* Build a per-database S3Config for a *known* physical bucket. `physicalName`
|
|
53
|
+
* is required — the stored coordinate on the bucket row is the only source;
|
|
54
|
+
* no name is ever recomputed here. Same logic as plugin.ts resolveS3ForDatabase.
|
|
54
55
|
*/
|
|
55
|
-
function resolveS3ForDatabase(options, storageConfig,
|
|
56
|
+
function resolveS3ForDatabase(options, storageConfig, physicalName) {
|
|
56
57
|
const globalS3 = resolveS3(options);
|
|
57
|
-
const
|
|
58
|
-
?
|
|
59
|
-
: globalS3.
|
|
60
|
-
|
|
61
|
-
if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
58
|
+
const publicUrlPrefix = storageConfig.publicUrlPrefix != null
|
|
59
|
+
? storageConfig.publicUrlPrefix
|
|
60
|
+
: globalS3.publicUrlPrefix;
|
|
61
|
+
if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
62
62
|
return globalS3;
|
|
63
63
|
}
|
|
64
64
|
return {
|
|
65
65
|
...globalS3,
|
|
66
|
-
bucket,
|
|
66
|
+
bucket: physicalName,
|
|
67
67
|
...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
|
|
68
68
|
};
|
|
69
69
|
}
|
|
@@ -126,25 +126,24 @@ function createDownloadUrlPlugin(options) {
|
|
|
126
126
|
const config = databaseId
|
|
127
127
|
? (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedCodec, await withPgClient(null, (pgClient) => (0, storage_module_cache_1.loadAllStorageModules)(pgClient, databaseId)))
|
|
128
128
|
: null;
|
|
129
|
-
const resolved = config
|
|
129
|
+
const resolved = config && bucketId
|
|
130
130
|
? await withPgClient(pgSettings, async (pgClient) => {
|
|
131
|
-
// Look up the
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (bucketResult.rows[0]?.key) {
|
|
139
|
-
bucketKey = bucketResult.rows[0].key;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
return { config, databaseId, bucketKey };
|
|
131
|
+
// Look up the stored physical coordinate for scoped S3 resolution
|
|
132
|
+
const bucketResult = await pgClient.query({
|
|
133
|
+
text: `SELECT key, physical_name FROM ${config.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
|
|
134
|
+
values: [bucketId],
|
|
135
|
+
});
|
|
136
|
+
const row = bucketResult.rows[0];
|
|
137
|
+
return row ? { config, physicalName: (0, storage_module_cache_1.storedPhysicalName)(row) } : null;
|
|
143
138
|
})
|
|
144
139
|
: null;
|
|
145
140
|
if (resolved) {
|
|
141
|
+
if (resolved.physicalName === null) {
|
|
142
|
+
// No physical bucket was ever provisioned — no object can exist.
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
146
145
|
downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds;
|
|
147
|
-
s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.
|
|
146
|
+
s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.physicalName);
|
|
148
147
|
}
|
|
149
148
|
}
|
|
150
149
|
}
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
* lookups. Since downloadUrl is a computed field (not a real column),
|
|
19
19
|
* the plan() function is required for Grafast to execute the S3 signing.
|
|
20
20
|
*/
|
|
21
|
-
import type { GraphileConfig } from 'graphile-config';
|
|
22
21
|
import 'graphile-build';
|
|
22
|
+
import type { GraphileConfig } from 'graphile-config';
|
|
23
23
|
import type { PresignedUrlPluginOptions } from './types';
|
|
24
24
|
export declare function createDownloadUrlPlugin(options: PresignedUrlPluginOptions): GraphileConfig.Plugin;
|
|
25
25
|
export default createDownloadUrlPlugin;
|
|
@@ -19,10 +19,10 @@
|
|
|
19
19
|
* the plan() function is required for Grafast to execute the S3 signing.
|
|
20
20
|
*/
|
|
21
21
|
import 'graphile-build';
|
|
22
|
-
import { context as grafastContext, lambda, object } from 'grafast';
|
|
23
22
|
import { Logger } from '@pgpmjs/logger';
|
|
23
|
+
import { context as grafastContext, lambda, object } from 'grafast';
|
|
24
24
|
import { generatePresignedGetUrl } from './s3-signer';
|
|
25
|
-
import { loadAllStorageModules, resolveStorageConfigFromCodec } from './storage-module-cache';
|
|
25
|
+
import { loadAllStorageModules, resolveStorageConfigFromCodec, storedPhysicalName } from './storage-module-cache';
|
|
26
26
|
const log = new Logger('graphile-presigned-url:download-url');
|
|
27
27
|
/**
|
|
28
28
|
* Creates the downloadUrl computed field plugin.
|
|
@@ -46,21 +46,21 @@ function resolveS3(options) {
|
|
|
46
46
|
return options.s3;
|
|
47
47
|
}
|
|
48
48
|
/**
|
|
49
|
-
* Build a per-database S3Config
|
|
50
|
-
*
|
|
49
|
+
* Build a per-database S3Config for a *known* physical bucket. `physicalName`
|
|
50
|
+
* is required — the stored coordinate on the bucket row is the only source;
|
|
51
|
+
* no name is ever recomputed here. Same logic as plugin.ts resolveS3ForDatabase.
|
|
51
52
|
*/
|
|
52
|
-
function resolveS3ForDatabase(options, storageConfig,
|
|
53
|
+
function resolveS3ForDatabase(options, storageConfig, physicalName) {
|
|
53
54
|
const globalS3 = resolveS3(options);
|
|
54
|
-
const
|
|
55
|
-
?
|
|
56
|
-
: globalS3.
|
|
57
|
-
|
|
58
|
-
if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
55
|
+
const publicUrlPrefix = storageConfig.publicUrlPrefix != null
|
|
56
|
+
? storageConfig.publicUrlPrefix
|
|
57
|
+
: globalS3.publicUrlPrefix;
|
|
58
|
+
if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
59
59
|
return globalS3;
|
|
60
60
|
}
|
|
61
61
|
return {
|
|
62
62
|
...globalS3,
|
|
63
|
-
bucket,
|
|
63
|
+
bucket: physicalName,
|
|
64
64
|
...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
|
|
65
65
|
};
|
|
66
66
|
}
|
|
@@ -123,25 +123,24 @@ export function createDownloadUrlPlugin(options) {
|
|
|
123
123
|
const config = databaseId
|
|
124
124
|
? resolveStorageConfigFromCodec(capturedCodec, await withPgClient(null, (pgClient) => loadAllStorageModules(pgClient, databaseId)))
|
|
125
125
|
: null;
|
|
126
|
-
const resolved = config
|
|
126
|
+
const resolved = config && bucketId
|
|
127
127
|
? await withPgClient(pgSettings, async (pgClient) => {
|
|
128
|
-
// Look up the
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (bucketResult.rows[0]?.key) {
|
|
136
|
-
bucketKey = bucketResult.rows[0].key;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
return { config, databaseId, bucketKey };
|
|
128
|
+
// Look up the stored physical coordinate for scoped S3 resolution
|
|
129
|
+
const bucketResult = await pgClient.query({
|
|
130
|
+
text: `SELECT key, physical_name FROM ${config.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
|
|
131
|
+
values: [bucketId],
|
|
132
|
+
});
|
|
133
|
+
const row = bucketResult.rows[0];
|
|
134
|
+
return row ? { config, physicalName: storedPhysicalName(row) } : null;
|
|
140
135
|
})
|
|
141
136
|
: null;
|
|
142
137
|
if (resolved) {
|
|
138
|
+
if (resolved.physicalName === null) {
|
|
139
|
+
// No physical bucket was ever provisioned — no object can exist.
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
143
142
|
downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds;
|
|
144
|
-
s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.
|
|
143
|
+
s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.physicalName);
|
|
145
144
|
}
|
|
146
145
|
}
|
|
147
146
|
}
|
package/esm/index.d.ts
CHANGED
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
* };
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
|
-
export { PresignedUrlPlugin, createPresignedUrlPlugin } from './plugin';
|
|
30
29
|
export { createDownloadUrlPlugin } from './download-url-field';
|
|
30
|
+
export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
|
|
31
31
|
export { PresignedUrlPreset } from './preset';
|
|
32
|
-
export {
|
|
33
|
-
export {
|
|
34
|
-
export type { BucketConfig,
|
|
32
|
+
export { deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject } from './s3-signer';
|
|
33
|
+
export { clearBucketCache, clearStorageModuleCache, getBucketConfig, getStorageModuleConfig, getStorageModuleConfigForOwner, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
|
|
34
|
+
export type { BucketConfig, BucketNameResolver, EnsureBucketProvisioned, PresignedUrlPluginOptions, RequestUploadUrlInput, RequestUploadUrlPayload, S3Config, S3ConfigOrGetter, StorageModuleConfig, } from './types';
|
package/esm/index.js
CHANGED
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
* };
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
|
-
export { PresignedUrlPlugin, createPresignedUrlPlugin } from './plugin';
|
|
30
29
|
export { createDownloadUrlPlugin } from './download-url-field';
|
|
30
|
+
export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
|
|
31
31
|
export { PresignedUrlPreset } from './preset';
|
|
32
|
-
export {
|
|
33
|
-
export {
|
|
32
|
+
export { deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject } from './s3-signer';
|
|
33
|
+
export { clearBucketCache, clearStorageModuleCache, getBucketConfig, getStorageModuleConfig, getStorageModuleConfigForOwner, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
|
package/esm/plugin.d.ts
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
* Scope resolution uses the codec's schema/table name matched against
|
|
17
17
|
* cached storage module configs.
|
|
18
18
|
*/
|
|
19
|
-
import type { GraphileConfig } from 'graphile-config';
|
|
20
19
|
import 'graphile-build';
|
|
20
|
+
import type { GraphileConfig } from 'graphile-config';
|
|
21
21
|
import type { PresignedUrlPluginOptions } from './types';
|
|
22
22
|
export declare function createPresignedUrlPlugin(options: PresignedUrlPluginOptions): GraphileConfig.Plugin;
|
|
23
23
|
export declare const PresignedUrlPlugin: typeof createPresignedUrlPlugin;
|
package/esm/plugin.js
CHANGED
|
@@ -16,18 +16,18 @@
|
|
|
16
16
|
* Scope resolution uses the codec's schema/table name matched against
|
|
17
17
|
* cached storage module configs.
|
|
18
18
|
*/
|
|
19
|
-
import { access, context as grafastContext, lambda, object } from 'grafast';
|
|
20
19
|
import 'graphile-build';
|
|
21
20
|
import { Logger } from '@pgpmjs/logger';
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
21
|
+
import { access, context as grafastContext, lambda, object } from 'grafast';
|
|
22
|
+
import { deleteS3Object, generatePresignedPutUrl } from './s3-signer';
|
|
23
|
+
import { getBucketConfig, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, storedPhysicalName } from './storage-module-cache';
|
|
24
24
|
const log = new Logger('graphile-presigned-url:plugin');
|
|
25
25
|
// --- Protocol-level constants (not configurable) ---
|
|
26
26
|
const MAX_CONTENT_HASH_LENGTH = 128;
|
|
27
27
|
const MAX_CONTENT_TYPE_LENGTH = 255;
|
|
28
28
|
const MAX_CUSTOM_KEY_LENGTH = 1024;
|
|
29
29
|
const SHA256_HEX_REGEX = /^[a-f0-9]{64}$/;
|
|
30
|
-
const CUSTOM_KEY_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_
|
|
30
|
+
const CUSTOM_KEY_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.\-/]*$/;
|
|
31
31
|
// --- Helpers ---
|
|
32
32
|
function isValidSha256(hash) {
|
|
33
33
|
return SHA256_HEX_REGEX.test(hash);
|
|
@@ -74,30 +74,72 @@ function resolveS3(options) {
|
|
|
74
74
|
}
|
|
75
75
|
return options.s3;
|
|
76
76
|
}
|
|
77
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Mint the physical S3 bucket name for a logical bucket's first provision.
|
|
79
|
+
*
|
|
80
|
+
* This is a naming *policy*, consulted exactly once per bucket — before the
|
|
81
|
+
* physical bucket exists. Once provisioned, the recorded `physical_name` on
|
|
82
|
+
* the row is authoritative and this function must not be consulted again.
|
|
83
|
+
*/
|
|
84
|
+
function mintPhysicalBucketName(options, databaseId, bucketKey) {
|
|
85
|
+
if (options.resolveBucketName) {
|
|
86
|
+
return options.resolveBucketName(databaseId, bucketKey);
|
|
87
|
+
}
|
|
88
|
+
// Single-bucket deployment: the globally configured bucket is the physical bucket.
|
|
89
|
+
return resolveS3(options).bucket;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Build the S3 config for a *known* physical bucket. `physicalName` is
|
|
93
|
+
* required — callers must resolve the coordinate (stored row value, or a
|
|
94
|
+
* freshly provisioned name) before getting here. No name is ever recomputed.
|
|
95
|
+
*/
|
|
96
|
+
function resolveS3ForDatabase(options, storageConfig, physicalName) {
|
|
78
97
|
const globalS3 = resolveS3(options);
|
|
79
|
-
const
|
|
80
|
-
?
|
|
81
|
-
: globalS3.
|
|
82
|
-
|
|
83
|
-
if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
98
|
+
const publicUrlPrefix = storageConfig.publicUrlPrefix != null
|
|
99
|
+
? storageConfig.publicUrlPrefix
|
|
100
|
+
: globalS3.publicUrlPrefix;
|
|
101
|
+
if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
84
102
|
return globalS3;
|
|
85
103
|
}
|
|
86
104
|
return {
|
|
87
105
|
...globalS3,
|
|
88
|
-
bucket,
|
|
106
|
+
bucket: physicalName,
|
|
89
107
|
...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
|
|
90
108
|
};
|
|
91
109
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
110
|
+
/**
|
|
111
|
+
* First provision of a logical bucket: mint a name, create the physical S3
|
|
112
|
+
* bucket, and record the exact name on the source row. Returns the recorded
|
|
113
|
+
* physical name.
|
|
114
|
+
*
|
|
115
|
+
* Only called when the row has no `physical_name` yet. Afterwards the stored
|
|
116
|
+
* value is the durable coordinate: route resolution and every later read use
|
|
117
|
+
* it verbatim; nothing is recomputed.
|
|
118
|
+
*
|
|
119
|
+
* The record write runs in the system lane (`withPgClient(null, ...)`) — it is
|
|
120
|
+
* server bookkeeping, not request data, and anonymous request roles cannot
|
|
121
|
+
* UPDATE bucket rows under RLS. `bucket` (the cached config) is mutated in place
|
|
122
|
+
* so subsequent reads observe the recorded name without a DB round-trip.
|
|
123
|
+
*/
|
|
124
|
+
async function provisionAndRecordPhysicalBucket(options, withPgClient, storageConfig, databaseId, bucket, allowedOrigins) {
|
|
125
|
+
const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key);
|
|
126
|
+
if (options.ensureBucketProvisioned && !isS3BucketProvisioned(s3BucketName)) {
|
|
127
|
+
log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
|
|
128
|
+
await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
|
|
129
|
+
markS3BucketProvisioned(s3BucketName);
|
|
130
|
+
log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
|
|
131
|
+
}
|
|
132
|
+
// Record the physical coordinate on the source row. The `physical_name IS NULL`
|
|
133
|
+
// guard keeps this idempotent and race-safe across concurrent first uploads.
|
|
134
|
+
await withPgClient(null, (client) => client.query({
|
|
135
|
+
text: `UPDATE ${storageConfig.bucketsQualifiedName}
|
|
136
|
+
SET physical_name = $1
|
|
137
|
+
WHERE id = $2 AND physical_name IS NULL`,
|
|
138
|
+
values: [s3BucketName, bucket.id],
|
|
139
|
+
}));
|
|
140
|
+
bucket.physical_name = s3BucketName;
|
|
141
|
+
log.info(`Recorded physical_name="${s3BucketName}" on bucket ${bucket.id}`);
|
|
142
|
+
return s3BucketName;
|
|
101
143
|
}
|
|
102
144
|
// --- Plugin factory ---
|
|
103
145
|
export function createPresignedUrlPlugin(options) {
|
|
@@ -211,22 +253,24 @@ export function createPresignedUrlPlugin(options) {
|
|
|
211
253
|
const storageConfig = resolveStorageConfigFromCodec(capturedFilesCodec, allConfigs);
|
|
212
254
|
if (!storageConfig)
|
|
213
255
|
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
256
|
+
// Bucket config read under the request role (RLS-gated visibility).
|
|
257
|
+
const bucket = await vals.withPgClient(vals.pgSettings, (pgClient) => getBucketConfig(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
|
|
258
|
+
if (!bucket)
|
|
259
|
+
throw new Error('BUCKET_NOT_FOUND');
|
|
260
|
+
// First provision mints + records the coordinate; afterwards the
|
|
261
|
+
// stored physical_name is authoritative and nothing is recomputed.
|
|
262
|
+
const physicalName = bucket.physical_name === null
|
|
263
|
+
? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
|
|
264
|
+
: bucket.physical_name;
|
|
265
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
|
|
266
|
+
// File row INSERT under the request role (RLS enforced).
|
|
267
|
+
return vals.withPgClient(vals.pgSettings, (pgClient) => pgClient.withTransaction((txClient) => processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
|
|
268
|
+
contentHash: vals.contentHash,
|
|
269
|
+
contentType: vals.contentType,
|
|
270
|
+
size: vals.size,
|
|
271
|
+
filename: vals.filename,
|
|
272
|
+
key: vals.customKey,
|
|
273
|
+
})));
|
|
230
274
|
});
|
|
231
275
|
},
|
|
232
276
|
});
|
|
@@ -300,35 +344,39 @@ export function createPresignedUrlPlugin(options) {
|
|
|
300
344
|
const storageConfig = resolveStorageConfigFromCodec(capturedFilesCodec, allConfigs);
|
|
301
345
|
if (!storageConfig)
|
|
302
346
|
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
347
|
+
// Bucket config read under the request role (RLS-gated visibility).
|
|
348
|
+
const bucket = await vals.withPgClient(vals.pgSettings, (pgClient) => getBucketConfig(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
|
|
349
|
+
if (!bucket)
|
|
350
|
+
throw new Error('BUCKET_NOT_FOUND');
|
|
351
|
+
// Enforce bulk upload limits
|
|
352
|
+
const filesArray = vals.files;
|
|
353
|
+
if (filesArray.length > storageConfig.maxBulkFiles) {
|
|
354
|
+
throw new Error(`BULK_UPLOAD_FILES_EXCEEDED: ${filesArray.length} files exceeds maximum of ${storageConfig.maxBulkFiles} per batch`);
|
|
355
|
+
}
|
|
356
|
+
const totalSize = filesArray.reduce((sum, f) => sum + (f.size || 0), 0);
|
|
357
|
+
if (totalSize > storageConfig.maxBulkTotalSize) {
|
|
358
|
+
throw new Error(`BULK_UPLOAD_SIZE_EXCEEDED: ${totalSize} bytes exceeds maximum of ${storageConfig.maxBulkTotalSize} bytes per batch`);
|
|
359
|
+
}
|
|
360
|
+
// First provision mints + records the coordinate; afterwards the
|
|
361
|
+
// stored physical_name is authoritative and nothing is recomputed.
|
|
362
|
+
const physicalName = bucket.physical_name === null
|
|
363
|
+
? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
|
|
364
|
+
: bucket.physical_name;
|
|
365
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
|
|
366
|
+
// File row INSERTs under the request role (RLS enforced).
|
|
367
|
+
return vals.withPgClient(vals.pgSettings, (pgClient) => pgClient.withTransaction(async (txClient) => {
|
|
368
|
+
const results = [];
|
|
369
|
+
for (const file of filesArray) {
|
|
370
|
+
results.push(await processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
|
|
371
|
+
contentHash: file.contentHash,
|
|
372
|
+
contentType: file.contentType,
|
|
373
|
+
size: file.size,
|
|
374
|
+
filename: file.filename,
|
|
375
|
+
key: file.key,
|
|
376
|
+
}));
|
|
377
|
+
}
|
|
378
|
+
return { files: results };
|
|
379
|
+
}));
|
|
332
380
|
});
|
|
333
381
|
},
|
|
334
382
|
});
|
|
@@ -425,18 +473,25 @@ export function createPresignedUrlPlugin(options) {
|
|
|
425
473
|
log.info(`File deleted from DB; S3 key ${fileRow.key} still referenced by ${refCount} file(s)`);
|
|
426
474
|
return;
|
|
427
475
|
}
|
|
428
|
-
// No other references — attempt sync S3 delete
|
|
429
|
-
//
|
|
476
|
+
// No other references — attempt sync S3 delete.
|
|
477
|
+
// Read the stored physical coordinate; the object lives in the
|
|
478
|
+
// recorded bucket, never a recomputed prefix name.
|
|
430
479
|
const bucketResult = await pgClient.query({
|
|
431
|
-
text: `SELECT key FROM ${storageConfig.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
|
|
480
|
+
text: `SELECT key, physical_name FROM ${storageConfig.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
|
|
432
481
|
values: [fileRow.bucket_id],
|
|
433
482
|
});
|
|
434
|
-
const
|
|
435
|
-
if (!
|
|
483
|
+
const bucketRow = bucketResult.rows[0];
|
|
484
|
+
if (!bucketRow) {
|
|
436
485
|
log.warn(`Bucket not found for bucket_id=${fileRow.bucket_id}; skipping S3 delete`);
|
|
437
486
|
return;
|
|
438
487
|
}
|
|
439
|
-
const
|
|
488
|
+
const physicalName = storedPhysicalName(bucketRow);
|
|
489
|
+
if (physicalName === null) {
|
|
490
|
+
// No physical bucket was ever provisioned — there is no object to delete.
|
|
491
|
+
log.warn(`Bucket ${fileRow.bucket_id} has no physical_name; skipping S3 delete`);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
|
|
440
495
|
await deleteS3Object(s3ForDb, fileRow.key);
|
|
441
496
|
log.info(`Sync S3 delete succeeded for key=${fileRow.key}`);
|
|
442
497
|
});
|
package/esm/preset.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* in PostGraphile. Combines the main mutation plugin (requestUploadUrl)
|
|
6
6
|
* with the downloadUrl computed field plugin.
|
|
7
7
|
*/
|
|
8
|
-
import { createPresignedUrlPlugin } from './plugin';
|
|
9
8
|
import { createDownloadUrlPlugin } from './download-url-field';
|
|
9
|
+
import { createPresignedUrlPlugin } from './plugin';
|
|
10
10
|
/**
|
|
11
11
|
* Creates a preset that includes the presigned URL plugins with the given options.
|
|
12
12
|
*
|
package/esm/s3-signer.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, } from '@aws-sdk/client-s3';
|
|
2
2
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
3
3
|
import { Logger } from '@pgpmjs/logger';
|
|
4
4
|
const log = new Logger('graphile-presigned-url:s3');
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { BucketConfig, StorageModuleConfig } from './types';
|
|
2
2
|
/**
|
|
3
3
|
* Resolve the app-level storage module config for a database, using the LRU cache.
|
|
4
4
|
*
|
|
@@ -100,6 +100,17 @@ export declare function resolveStorageConfigFromCodec(pgCodec: {
|
|
|
100
100
|
};
|
|
101
101
|
sqlType?: string;
|
|
102
102
|
}, allConfigs: StorageModuleConfig[]): StorageModuleConfig | null;
|
|
103
|
+
/**
|
|
104
|
+
* Normalize the recorded physical coordinate at the DB boundary.
|
|
105
|
+
*
|
|
106
|
+
* A bucket row either carries a recorded coordinate or it does not; SQL nulls
|
|
107
|
+
* and absent columns both mean "never provisioned". Collapsing them here is
|
|
108
|
+
* the single place that shape is interpreted — callers branch on `string`
|
|
109
|
+
* vs `null` and never coalesce a bucket name into existence.
|
|
110
|
+
*/
|
|
111
|
+
export declare function storedPhysicalName(row: {
|
|
112
|
+
physical_name?: string | null;
|
|
113
|
+
}): string | null;
|
|
103
114
|
/**
|
|
104
115
|
* Resolve bucket metadata for a given database + bucket key, using the LRU cache.
|
|
105
116
|
*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Logger } from '@pgpmjs/logger';
|
|
2
|
-
import { LRUCache } from 'lru-cache';
|
|
3
2
|
import { QuoteUtils } from '@pgsql/quotes';
|
|
3
|
+
import { LRUCache } from 'lru-cache';
|
|
4
4
|
const log = new Logger('graphile-presigned-url:cache');
|
|
5
5
|
// --- Defaults ---
|
|
6
6
|
const DEFAULT_UPLOAD_URL_EXPIRY_SECONDS = 900; // 15 minutes
|
|
@@ -320,6 +320,17 @@ const bucketCache = new LRUCache({
|
|
|
320
320
|
ttl: process.env.NODE_ENV === 'development' ? FIVE_MINUTES_MS : ONE_HOUR_MS,
|
|
321
321
|
updateAgeOnGet: true,
|
|
322
322
|
});
|
|
323
|
+
/**
|
|
324
|
+
* Normalize the recorded physical coordinate at the DB boundary.
|
|
325
|
+
*
|
|
326
|
+
* A bucket row either carries a recorded coordinate or it does not; SQL nulls
|
|
327
|
+
* and absent columns both mean "never provisioned". Collapsing them here is
|
|
328
|
+
* the single place that shape is interpreted — callers branch on `string`
|
|
329
|
+
* vs `null` and never coalesce a bucket name into existence.
|
|
330
|
+
*/
|
|
331
|
+
export function storedPhysicalName(row) {
|
|
332
|
+
return row.physical_name == null ? null : row.physical_name;
|
|
333
|
+
}
|
|
323
334
|
/**
|
|
324
335
|
* Resolve bucket metadata for a given database + bucket key, using the LRU cache.
|
|
325
336
|
*
|
|
@@ -346,11 +357,11 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
|
|
|
346
357
|
const hasOwner = ownerId && isEntityScoped;
|
|
347
358
|
const result = await pgClient.query({
|
|
348
359
|
text: hasOwner
|
|
349
|
-
? `SELECT id, key, type, is_public, owner_id, allowed_mime_types, max_file_size, allow_custom_keys
|
|
360
|
+
? `SELECT id, key, type, is_public, owner_id, allowed_mime_types, max_file_size, allow_custom_keys, physical_name
|
|
350
361
|
FROM ${storageConfig.bucketsQualifiedName}
|
|
351
362
|
WHERE key = $1 AND owner_id = $2
|
|
352
363
|
LIMIT 1`
|
|
353
|
-
: `SELECT id, key, type, is_public, ${isEntityScoped ? 'owner_id,' : ''} allowed_mime_types, max_file_size, allow_custom_keys
|
|
364
|
+
: `SELECT id, key, type, is_public, ${isEntityScoped ? 'owner_id,' : ''} allowed_mime_types, max_file_size, allow_custom_keys, physical_name
|
|
354
365
|
FROM ${storageConfig.bucketsQualifiedName}
|
|
355
366
|
WHERE key = $1
|
|
356
367
|
LIMIT 1`,
|
|
@@ -369,6 +380,7 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
|
|
|
369
380
|
allowed_mime_types: row.allowed_mime_types,
|
|
370
381
|
max_file_size: row.max_file_size,
|
|
371
382
|
allow_custom_keys: row.allow_custom_keys ?? false,
|
|
383
|
+
physical_name: storedPhysicalName(row),
|
|
372
384
|
};
|
|
373
385
|
bucketCache.set(cacheKey, config);
|
|
374
386
|
log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id}, scope=${storageConfig.scope})`);
|
package/esm/types.d.ts
CHANGED
|
@@ -11,6 +11,13 @@ export interface BucketConfig {
|
|
|
11
11
|
allowed_mime_types: string[] | null;
|
|
12
12
|
max_file_size: number | null;
|
|
13
13
|
allow_custom_keys: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* The physical S3/MinIO bucket name recorded when the physical bucket was
|
|
16
|
+
* first provisioned. NULL until the first upload provisions it. Once set,
|
|
17
|
+
* it is the source of truth for the physical bucket — reads never
|
|
18
|
+
* reconstruct the name from a prefix convention.
|
|
19
|
+
*/
|
|
20
|
+
physical_name: string | null;
|
|
14
21
|
}
|
|
15
22
|
/**
|
|
16
23
|
* Storage module configuration resolved from metaschema for a given database.
|
package/index.d.ts
CHANGED
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
* };
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
|
-
export { PresignedUrlPlugin, createPresignedUrlPlugin } from './plugin';
|
|
30
29
|
export { createDownloadUrlPlugin } from './download-url-field';
|
|
30
|
+
export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
|
|
31
31
|
export { PresignedUrlPreset } from './preset';
|
|
32
|
-
export {
|
|
33
|
-
export {
|
|
34
|
-
export type { BucketConfig,
|
|
32
|
+
export { deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject } from './s3-signer';
|
|
33
|
+
export { clearBucketCache, clearStorageModuleCache, getBucketConfig, getStorageModuleConfig, getStorageModuleConfigForOwner, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
|
|
34
|
+
export type { BucketConfig, BucketNameResolver, EnsureBucketProvisioned, PresignedUrlPluginOptions, RequestUploadUrlInput, RequestUploadUrlPayload, S3Config, S3ConfigOrGetter, StorageModuleConfig, } from './types';
|
package/index.js
CHANGED
|
@@ -28,27 +28,27 @@
|
|
|
28
28
|
* ```
|
|
29
29
|
*/
|
|
30
30
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
|
-
exports.
|
|
32
|
-
var plugin_1 = require("./plugin");
|
|
33
|
-
Object.defineProperty(exports, "PresignedUrlPlugin", { enumerable: true, get: function () { return plugin_1.PresignedUrlPlugin; } });
|
|
34
|
-
Object.defineProperty(exports, "createPresignedUrlPlugin", { enumerable: true, get: function () { return plugin_1.createPresignedUrlPlugin; } });
|
|
31
|
+
exports.resolveStorageModuleByFileId = exports.resolveStorageConfigFromCodec = exports.markS3BucketProvisioned = exports.loadAllStorageModules = exports.isS3BucketProvisioned = exports.getStorageModuleConfigForOwner = exports.getStorageModuleConfig = exports.getBucketConfig = exports.clearStorageModuleCache = exports.clearBucketCache = exports.headObject = exports.generatePresignedPutUrl = exports.generatePresignedGetUrl = exports.deleteS3Object = exports.PresignedUrlPreset = exports.PresignedUrlPlugin = exports.createPresignedUrlPlugin = exports.createDownloadUrlPlugin = void 0;
|
|
35
32
|
var download_url_field_1 = require("./download-url-field");
|
|
36
33
|
Object.defineProperty(exports, "createDownloadUrlPlugin", { enumerable: true, get: function () { return download_url_field_1.createDownloadUrlPlugin; } });
|
|
34
|
+
var plugin_1 = require("./plugin");
|
|
35
|
+
Object.defineProperty(exports, "createPresignedUrlPlugin", { enumerable: true, get: function () { return plugin_1.createPresignedUrlPlugin; } });
|
|
36
|
+
Object.defineProperty(exports, "PresignedUrlPlugin", { enumerable: true, get: function () { return plugin_1.PresignedUrlPlugin; } });
|
|
37
37
|
var preset_1 = require("./preset");
|
|
38
38
|
Object.defineProperty(exports, "PresignedUrlPreset", { enumerable: true, get: function () { return preset_1.PresignedUrlPreset; } });
|
|
39
|
+
var s3_signer_1 = require("./s3-signer");
|
|
40
|
+
Object.defineProperty(exports, "deleteS3Object", { enumerable: true, get: function () { return s3_signer_1.deleteS3Object; } });
|
|
41
|
+
Object.defineProperty(exports, "generatePresignedGetUrl", { enumerable: true, get: function () { return s3_signer_1.generatePresignedGetUrl; } });
|
|
42
|
+
Object.defineProperty(exports, "generatePresignedPutUrl", { enumerable: true, get: function () { return s3_signer_1.generatePresignedPutUrl; } });
|
|
43
|
+
Object.defineProperty(exports, "headObject", { enumerable: true, get: function () { return s3_signer_1.headObject; } });
|
|
39
44
|
var storage_module_cache_1 = require("./storage-module-cache");
|
|
45
|
+
Object.defineProperty(exports, "clearBucketCache", { enumerable: true, get: function () { return storage_module_cache_1.clearBucketCache; } });
|
|
46
|
+
Object.defineProperty(exports, "clearStorageModuleCache", { enumerable: true, get: function () { return storage_module_cache_1.clearStorageModuleCache; } });
|
|
47
|
+
Object.defineProperty(exports, "getBucketConfig", { enumerable: true, get: function () { return storage_module_cache_1.getBucketConfig; } });
|
|
40
48
|
Object.defineProperty(exports, "getStorageModuleConfig", { enumerable: true, get: function () { return storage_module_cache_1.getStorageModuleConfig; } });
|
|
41
49
|
Object.defineProperty(exports, "getStorageModuleConfigForOwner", { enumerable: true, get: function () { return storage_module_cache_1.getStorageModuleConfigForOwner; } });
|
|
42
|
-
Object.defineProperty(exports, "getBucketConfig", { enumerable: true, get: function () { return storage_module_cache_1.getBucketConfig; } });
|
|
43
|
-
Object.defineProperty(exports, "resolveStorageModuleByFileId", { enumerable: true, get: function () { return storage_module_cache_1.resolveStorageModuleByFileId; } });
|
|
44
|
-
Object.defineProperty(exports, "loadAllStorageModules", { enumerable: true, get: function () { return storage_module_cache_1.loadAllStorageModules; } });
|
|
45
|
-
Object.defineProperty(exports, "resolveStorageConfigFromCodec", { enumerable: true, get: function () { return storage_module_cache_1.resolveStorageConfigFromCodec; } });
|
|
46
|
-
Object.defineProperty(exports, "clearStorageModuleCache", { enumerable: true, get: function () { return storage_module_cache_1.clearStorageModuleCache; } });
|
|
47
|
-
Object.defineProperty(exports, "clearBucketCache", { enumerable: true, get: function () { return storage_module_cache_1.clearBucketCache; } });
|
|
48
50
|
Object.defineProperty(exports, "isS3BucketProvisioned", { enumerable: true, get: function () { return storage_module_cache_1.isS3BucketProvisioned; } });
|
|
51
|
+
Object.defineProperty(exports, "loadAllStorageModules", { enumerable: true, get: function () { return storage_module_cache_1.loadAllStorageModules; } });
|
|
49
52
|
Object.defineProperty(exports, "markS3BucketProvisioned", { enumerable: true, get: function () { return storage_module_cache_1.markS3BucketProvisioned; } });
|
|
50
|
-
|
|
51
|
-
Object.defineProperty(exports, "
|
|
52
|
-
Object.defineProperty(exports, "generatePresignedGetUrl", { enumerable: true, get: function () { return s3_signer_1.generatePresignedGetUrl; } });
|
|
53
|
-
Object.defineProperty(exports, "deleteS3Object", { enumerable: true, get: function () { return s3_signer_1.deleteS3Object; } });
|
|
54
|
-
Object.defineProperty(exports, "headObject", { enumerable: true, get: function () { return s3_signer_1.headObject; } });
|
|
53
|
+
Object.defineProperty(exports, "resolveStorageConfigFromCodec", { enumerable: true, get: function () { return storage_module_cache_1.resolveStorageConfigFromCodec; } });
|
|
54
|
+
Object.defineProperty(exports, "resolveStorageModuleByFileId", { enumerable: true, get: function () { return storage_module_cache_1.resolveStorageModuleByFileId; } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graphile-presigned-url-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Presigned URL upload plugin for PostGraphile v5 — requestUploadUrl mutation and downloadUrl computed field",
|
|
5
5
|
"author": "Constructive <developers@constructive.io>",
|
|
6
6
|
"homepage": "https://github.com/constructive-io/constructive",
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@aws-sdk/client-s3": "^3.1052.0",
|
|
44
44
|
"@aws-sdk/s3-request-presigner": "^3.1052.0",
|
|
45
|
-
"@pgpmjs/logger": "^2.23.
|
|
46
|
-
"@pgsql/quotes": "^18.
|
|
45
|
+
"@pgpmjs/logger": "^2.23.3",
|
|
46
|
+
"@pgsql/quotes": "^18.2.0",
|
|
47
47
|
"lru-cache": "^11.2.7"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
@@ -56,9 +56,9 @@
|
|
|
56
56
|
"postgraphile": "5.0.3"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
-
"@constructive-io/s3-utils": "^2.29.
|
|
59
|
+
"@constructive-io/s3-utils": "^2.29.3",
|
|
60
60
|
"@types/node": "^22.19.11",
|
|
61
61
|
"makage": "^0.3.0"
|
|
62
62
|
},
|
|
63
|
-
"gitHead": "
|
|
63
|
+
"gitHead": "c438821362ceaf70b2a20d74ecf25ad4191c1748"
|
|
64
64
|
}
|
package/plugin.d.ts
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
* Scope resolution uses the codec's schema/table name matched against
|
|
17
17
|
* cached storage module configs.
|
|
18
18
|
*/
|
|
19
|
-
import type { GraphileConfig } from 'graphile-config';
|
|
20
19
|
import 'graphile-build';
|
|
20
|
+
import type { GraphileConfig } from 'graphile-config';
|
|
21
21
|
import type { PresignedUrlPluginOptions } from './types';
|
|
22
22
|
export declare function createPresignedUrlPlugin(options: PresignedUrlPluginOptions): GraphileConfig.Plugin;
|
|
23
23
|
export declare const PresignedUrlPlugin: typeof createPresignedUrlPlugin;
|
package/plugin.js
CHANGED
|
@@ -20,18 +20,18 @@
|
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
21
|
exports.PresignedUrlPlugin = void 0;
|
|
22
22
|
exports.createPresignedUrlPlugin = createPresignedUrlPlugin;
|
|
23
|
-
const grafast_1 = require("grafast");
|
|
24
23
|
require("graphile-build");
|
|
25
24
|
const logger_1 = require("@pgpmjs/logger");
|
|
26
|
-
const
|
|
25
|
+
const grafast_1 = require("grafast");
|
|
27
26
|
const s3_signer_1 = require("./s3-signer");
|
|
27
|
+
const storage_module_cache_1 = require("./storage-module-cache");
|
|
28
28
|
const log = new logger_1.Logger('graphile-presigned-url:plugin');
|
|
29
29
|
// --- Protocol-level constants (not configurable) ---
|
|
30
30
|
const MAX_CONTENT_HASH_LENGTH = 128;
|
|
31
31
|
const MAX_CONTENT_TYPE_LENGTH = 255;
|
|
32
32
|
const MAX_CUSTOM_KEY_LENGTH = 1024;
|
|
33
33
|
const SHA256_HEX_REGEX = /^[a-f0-9]{64}$/;
|
|
34
|
-
const CUSTOM_KEY_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_
|
|
34
|
+
const CUSTOM_KEY_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.\-/]*$/;
|
|
35
35
|
// --- Helpers ---
|
|
36
36
|
function isValidSha256(hash) {
|
|
37
37
|
return SHA256_HEX_REGEX.test(hash);
|
|
@@ -78,30 +78,72 @@ function resolveS3(options) {
|
|
|
78
78
|
}
|
|
79
79
|
return options.s3;
|
|
80
80
|
}
|
|
81
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Mint the physical S3 bucket name for a logical bucket's first provision.
|
|
83
|
+
*
|
|
84
|
+
* This is a naming *policy*, consulted exactly once per bucket — before the
|
|
85
|
+
* physical bucket exists. Once provisioned, the recorded `physical_name` on
|
|
86
|
+
* the row is authoritative and this function must not be consulted again.
|
|
87
|
+
*/
|
|
88
|
+
function mintPhysicalBucketName(options, databaseId, bucketKey) {
|
|
89
|
+
if (options.resolveBucketName) {
|
|
90
|
+
return options.resolveBucketName(databaseId, bucketKey);
|
|
91
|
+
}
|
|
92
|
+
// Single-bucket deployment: the globally configured bucket is the physical bucket.
|
|
93
|
+
return resolveS3(options).bucket;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Build the S3 config for a *known* physical bucket. `physicalName` is
|
|
97
|
+
* required — callers must resolve the coordinate (stored row value, or a
|
|
98
|
+
* freshly provisioned name) before getting here. No name is ever recomputed.
|
|
99
|
+
*/
|
|
100
|
+
function resolveS3ForDatabase(options, storageConfig, physicalName) {
|
|
82
101
|
const globalS3 = resolveS3(options);
|
|
83
|
-
const
|
|
84
|
-
?
|
|
85
|
-
: globalS3.
|
|
86
|
-
|
|
87
|
-
if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
102
|
+
const publicUrlPrefix = storageConfig.publicUrlPrefix != null
|
|
103
|
+
? storageConfig.publicUrlPrefix
|
|
104
|
+
: globalS3.publicUrlPrefix;
|
|
105
|
+
if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
88
106
|
return globalS3;
|
|
89
107
|
}
|
|
90
108
|
return {
|
|
91
109
|
...globalS3,
|
|
92
|
-
bucket,
|
|
110
|
+
bucket: physicalName,
|
|
93
111
|
...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
|
|
94
112
|
};
|
|
95
113
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
114
|
+
/**
|
|
115
|
+
* First provision of a logical bucket: mint a name, create the physical S3
|
|
116
|
+
* bucket, and record the exact name on the source row. Returns the recorded
|
|
117
|
+
* physical name.
|
|
118
|
+
*
|
|
119
|
+
* Only called when the row has no `physical_name` yet. Afterwards the stored
|
|
120
|
+
* value is the durable coordinate: route resolution and every later read use
|
|
121
|
+
* it verbatim; nothing is recomputed.
|
|
122
|
+
*
|
|
123
|
+
* The record write runs in the system lane (`withPgClient(null, ...)`) — it is
|
|
124
|
+
* server bookkeeping, not request data, and anonymous request roles cannot
|
|
125
|
+
* UPDATE bucket rows under RLS. `bucket` (the cached config) is mutated in place
|
|
126
|
+
* so subsequent reads observe the recorded name without a DB round-trip.
|
|
127
|
+
*/
|
|
128
|
+
async function provisionAndRecordPhysicalBucket(options, withPgClient, storageConfig, databaseId, bucket, allowedOrigins) {
|
|
129
|
+
const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key);
|
|
130
|
+
if (options.ensureBucketProvisioned && !(0, storage_module_cache_1.isS3BucketProvisioned)(s3BucketName)) {
|
|
131
|
+
log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
|
|
132
|
+
await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
|
|
133
|
+
(0, storage_module_cache_1.markS3BucketProvisioned)(s3BucketName);
|
|
134
|
+
log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
|
|
135
|
+
}
|
|
136
|
+
// Record the physical coordinate on the source row. The `physical_name IS NULL`
|
|
137
|
+
// guard keeps this idempotent and race-safe across concurrent first uploads.
|
|
138
|
+
await withPgClient(null, (client) => client.query({
|
|
139
|
+
text: `UPDATE ${storageConfig.bucketsQualifiedName}
|
|
140
|
+
SET physical_name = $1
|
|
141
|
+
WHERE id = $2 AND physical_name IS NULL`,
|
|
142
|
+
values: [s3BucketName, bucket.id],
|
|
143
|
+
}));
|
|
144
|
+
bucket.physical_name = s3BucketName;
|
|
145
|
+
log.info(`Recorded physical_name="${s3BucketName}" on bucket ${bucket.id}`);
|
|
146
|
+
return s3BucketName;
|
|
105
147
|
}
|
|
106
148
|
// --- Plugin factory ---
|
|
107
149
|
function createPresignedUrlPlugin(options) {
|
|
@@ -215,22 +257,24 @@ function createPresignedUrlPlugin(options) {
|
|
|
215
257
|
const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedFilesCodec, allConfigs);
|
|
216
258
|
if (!storageConfig)
|
|
217
259
|
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
260
|
+
// Bucket config read under the request role (RLS-gated visibility).
|
|
261
|
+
const bucket = await vals.withPgClient(vals.pgSettings, (pgClient) => (0, storage_module_cache_1.getBucketConfig)(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
|
|
262
|
+
if (!bucket)
|
|
263
|
+
throw new Error('BUCKET_NOT_FOUND');
|
|
264
|
+
// First provision mints + records the coordinate; afterwards the
|
|
265
|
+
// stored physical_name is authoritative and nothing is recomputed.
|
|
266
|
+
const physicalName = bucket.physical_name === null
|
|
267
|
+
? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
|
|
268
|
+
: bucket.physical_name;
|
|
269
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
|
|
270
|
+
// File row INSERT under the request role (RLS enforced).
|
|
271
|
+
return vals.withPgClient(vals.pgSettings, (pgClient) => pgClient.withTransaction((txClient) => processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
|
|
272
|
+
contentHash: vals.contentHash,
|
|
273
|
+
contentType: vals.contentType,
|
|
274
|
+
size: vals.size,
|
|
275
|
+
filename: vals.filename,
|
|
276
|
+
key: vals.customKey,
|
|
277
|
+
})));
|
|
234
278
|
});
|
|
235
279
|
},
|
|
236
280
|
});
|
|
@@ -304,35 +348,39 @@ function createPresignedUrlPlugin(options) {
|
|
|
304
348
|
const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedFilesCodec, allConfigs);
|
|
305
349
|
if (!storageConfig)
|
|
306
350
|
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
351
|
+
// Bucket config read under the request role (RLS-gated visibility).
|
|
352
|
+
const bucket = await vals.withPgClient(vals.pgSettings, (pgClient) => (0, storage_module_cache_1.getBucketConfig)(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
|
|
353
|
+
if (!bucket)
|
|
354
|
+
throw new Error('BUCKET_NOT_FOUND');
|
|
355
|
+
// Enforce bulk upload limits
|
|
356
|
+
const filesArray = vals.files;
|
|
357
|
+
if (filesArray.length > storageConfig.maxBulkFiles) {
|
|
358
|
+
throw new Error(`BULK_UPLOAD_FILES_EXCEEDED: ${filesArray.length} files exceeds maximum of ${storageConfig.maxBulkFiles} per batch`);
|
|
359
|
+
}
|
|
360
|
+
const totalSize = filesArray.reduce((sum, f) => sum + (f.size || 0), 0);
|
|
361
|
+
if (totalSize > storageConfig.maxBulkTotalSize) {
|
|
362
|
+
throw new Error(`BULK_UPLOAD_SIZE_EXCEEDED: ${totalSize} bytes exceeds maximum of ${storageConfig.maxBulkTotalSize} bytes per batch`);
|
|
363
|
+
}
|
|
364
|
+
// First provision mints + records the coordinate; afterwards the
|
|
365
|
+
// stored physical_name is authoritative and nothing is recomputed.
|
|
366
|
+
const physicalName = bucket.physical_name === null
|
|
367
|
+
? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
|
|
368
|
+
: bucket.physical_name;
|
|
369
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
|
|
370
|
+
// File row INSERTs under the request role (RLS enforced).
|
|
371
|
+
return vals.withPgClient(vals.pgSettings, (pgClient) => pgClient.withTransaction(async (txClient) => {
|
|
372
|
+
const results = [];
|
|
373
|
+
for (const file of filesArray) {
|
|
374
|
+
results.push(await processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
|
|
375
|
+
contentHash: file.contentHash,
|
|
376
|
+
contentType: file.contentType,
|
|
377
|
+
size: file.size,
|
|
378
|
+
filename: file.filename,
|
|
379
|
+
key: file.key,
|
|
380
|
+
}));
|
|
381
|
+
}
|
|
382
|
+
return { files: results };
|
|
383
|
+
}));
|
|
336
384
|
});
|
|
337
385
|
},
|
|
338
386
|
});
|
|
@@ -429,18 +477,25 @@ function createPresignedUrlPlugin(options) {
|
|
|
429
477
|
log.info(`File deleted from DB; S3 key ${fileRow.key} still referenced by ${refCount} file(s)`);
|
|
430
478
|
return;
|
|
431
479
|
}
|
|
432
|
-
// No other references — attempt sync S3 delete
|
|
433
|
-
//
|
|
480
|
+
// No other references — attempt sync S3 delete.
|
|
481
|
+
// Read the stored physical coordinate; the object lives in the
|
|
482
|
+
// recorded bucket, never a recomputed prefix name.
|
|
434
483
|
const bucketResult = await pgClient.query({
|
|
435
|
-
text: `SELECT key FROM ${storageConfig.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
|
|
484
|
+
text: `SELECT key, physical_name FROM ${storageConfig.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
|
|
436
485
|
values: [fileRow.bucket_id],
|
|
437
486
|
});
|
|
438
|
-
const
|
|
439
|
-
if (!
|
|
487
|
+
const bucketRow = bucketResult.rows[0];
|
|
488
|
+
if (!bucketRow) {
|
|
440
489
|
log.warn(`Bucket not found for bucket_id=${fileRow.bucket_id}; skipping S3 delete`);
|
|
441
490
|
return;
|
|
442
491
|
}
|
|
443
|
-
const
|
|
492
|
+
const physicalName = (0, storage_module_cache_1.storedPhysicalName)(bucketRow);
|
|
493
|
+
if (physicalName === null) {
|
|
494
|
+
// No physical bucket was ever provisioned — there is no object to delete.
|
|
495
|
+
log.warn(`Bucket ${fileRow.bucket_id} has no physical_name; skipping S3 delete`);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
|
|
444
499
|
await (0, s3_signer_1.deleteS3Object)(s3ForDb, fileRow.key);
|
|
445
500
|
log.info(`Sync S3 delete succeeded for key=${fileRow.key}`);
|
|
446
501
|
});
|
package/preset.js
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
10
|
exports.PresignedUrlPreset = PresignedUrlPreset;
|
|
11
|
-
const plugin_1 = require("./plugin");
|
|
12
11
|
const download_url_field_1 = require("./download-url-field");
|
|
12
|
+
const plugin_1 = require("./plugin");
|
|
13
13
|
/**
|
|
14
14
|
* Creates a preset that includes the presigned URL plugins with the given options.
|
|
15
15
|
*
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { BucketConfig, StorageModuleConfig } from './types';
|
|
2
2
|
/**
|
|
3
3
|
* Resolve the app-level storage module config for a database, using the LRU cache.
|
|
4
4
|
*
|
|
@@ -100,6 +100,17 @@ export declare function resolveStorageConfigFromCodec(pgCodec: {
|
|
|
100
100
|
};
|
|
101
101
|
sqlType?: string;
|
|
102
102
|
}, allConfigs: StorageModuleConfig[]): StorageModuleConfig | null;
|
|
103
|
+
/**
|
|
104
|
+
* Normalize the recorded physical coordinate at the DB boundary.
|
|
105
|
+
*
|
|
106
|
+
* A bucket row either carries a recorded coordinate or it does not; SQL nulls
|
|
107
|
+
* and absent columns both mean "never provisioned". Collapsing them here is
|
|
108
|
+
* the single place that shape is interpreted — callers branch on `string`
|
|
109
|
+
* vs `null` and never coalesce a bucket name into existence.
|
|
110
|
+
*/
|
|
111
|
+
export declare function storedPhysicalName(row: {
|
|
112
|
+
physical_name?: string | null;
|
|
113
|
+
}): string | null;
|
|
103
114
|
/**
|
|
104
115
|
* Resolve bucket metadata for a given database + bucket key, using the LRU cache.
|
|
105
116
|
*
|
package/storage-module-cache.js
CHANGED
|
@@ -5,14 +5,15 @@ exports.getStorageModuleConfigForOwner = getStorageModuleConfigForOwner;
|
|
|
5
5
|
exports.resolveStorageModuleByFileId = resolveStorageModuleByFileId;
|
|
6
6
|
exports.loadAllStorageModules = loadAllStorageModules;
|
|
7
7
|
exports.resolveStorageConfigFromCodec = resolveStorageConfigFromCodec;
|
|
8
|
+
exports.storedPhysicalName = storedPhysicalName;
|
|
8
9
|
exports.getBucketConfig = getBucketConfig;
|
|
9
10
|
exports.isS3BucketProvisioned = isS3BucketProvisioned;
|
|
10
11
|
exports.markS3BucketProvisioned = markS3BucketProvisioned;
|
|
11
12
|
exports.clearStorageModuleCache = clearStorageModuleCache;
|
|
12
13
|
exports.clearBucketCache = clearBucketCache;
|
|
13
14
|
const logger_1 = require("@pgpmjs/logger");
|
|
14
|
-
const lru_cache_1 = require("lru-cache");
|
|
15
15
|
const quotes_1 = require("@pgsql/quotes");
|
|
16
|
+
const lru_cache_1 = require("lru-cache");
|
|
16
17
|
const log = new logger_1.Logger('graphile-presigned-url:cache');
|
|
17
18
|
// --- Defaults ---
|
|
18
19
|
const DEFAULT_UPLOAD_URL_EXPIRY_SECONDS = 900; // 15 minutes
|
|
@@ -332,6 +333,17 @@ const bucketCache = new lru_cache_1.LRUCache({
|
|
|
332
333
|
ttl: process.env.NODE_ENV === 'development' ? FIVE_MINUTES_MS : ONE_HOUR_MS,
|
|
333
334
|
updateAgeOnGet: true,
|
|
334
335
|
});
|
|
336
|
+
/**
|
|
337
|
+
* Normalize the recorded physical coordinate at the DB boundary.
|
|
338
|
+
*
|
|
339
|
+
* A bucket row either carries a recorded coordinate or it does not; SQL nulls
|
|
340
|
+
* and absent columns both mean "never provisioned". Collapsing them here is
|
|
341
|
+
* the single place that shape is interpreted — callers branch on `string`
|
|
342
|
+
* vs `null` and never coalesce a bucket name into existence.
|
|
343
|
+
*/
|
|
344
|
+
function storedPhysicalName(row) {
|
|
345
|
+
return row.physical_name == null ? null : row.physical_name;
|
|
346
|
+
}
|
|
335
347
|
/**
|
|
336
348
|
* Resolve bucket metadata for a given database + bucket key, using the LRU cache.
|
|
337
349
|
*
|
|
@@ -358,11 +370,11 @@ async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey, o
|
|
|
358
370
|
const hasOwner = ownerId && isEntityScoped;
|
|
359
371
|
const result = await pgClient.query({
|
|
360
372
|
text: hasOwner
|
|
361
|
-
? `SELECT id, key, type, is_public, owner_id, allowed_mime_types, max_file_size, allow_custom_keys
|
|
373
|
+
? `SELECT id, key, type, is_public, owner_id, allowed_mime_types, max_file_size, allow_custom_keys, physical_name
|
|
362
374
|
FROM ${storageConfig.bucketsQualifiedName}
|
|
363
375
|
WHERE key = $1 AND owner_id = $2
|
|
364
376
|
LIMIT 1`
|
|
365
|
-
: `SELECT id, key, type, is_public, ${isEntityScoped ? 'owner_id,' : ''} allowed_mime_types, max_file_size, allow_custom_keys
|
|
377
|
+
: `SELECT id, key, type, is_public, ${isEntityScoped ? 'owner_id,' : ''} allowed_mime_types, max_file_size, allow_custom_keys, physical_name
|
|
366
378
|
FROM ${storageConfig.bucketsQualifiedName}
|
|
367
379
|
WHERE key = $1
|
|
368
380
|
LIMIT 1`,
|
|
@@ -381,6 +393,7 @@ async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey, o
|
|
|
381
393
|
allowed_mime_types: row.allowed_mime_types,
|
|
382
394
|
max_file_size: row.max_file_size,
|
|
383
395
|
allow_custom_keys: row.allow_custom_keys ?? false,
|
|
396
|
+
physical_name: storedPhysicalName(row),
|
|
384
397
|
};
|
|
385
398
|
bucketCache.set(cacheKey, config);
|
|
386
399
|
log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id}, scope=${storageConfig.scope})`);
|
package/types.d.ts
CHANGED
|
@@ -11,6 +11,13 @@ export interface BucketConfig {
|
|
|
11
11
|
allowed_mime_types: string[] | null;
|
|
12
12
|
max_file_size: number | null;
|
|
13
13
|
allow_custom_keys: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* The physical S3/MinIO bucket name recorded when the physical bucket was
|
|
16
|
+
* first provisioned. NULL until the first upload provisions it. Once set,
|
|
17
|
+
* it is the source of truth for the physical bucket — reads never
|
|
18
|
+
* reconstruct the name from a prefix convention.
|
|
19
|
+
*/
|
|
20
|
+
physical_name: string | null;
|
|
14
21
|
}
|
|
15
22
|
/**
|
|
16
23
|
* Storage module configuration resolved from metaschema for a given database.
|