graphile-presigned-url-plugin 1.11.4 → 1.12.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/default-bucket.d.ts +42 -0
- package/default-bucket.js +51 -0
- package/esm/default-bucket.d.ts +42 -0
- package/esm/default-bucket.js +48 -0
- package/esm/file-ref-registry.d.ts +61 -0
- package/esm/file-ref-registry.js +115 -0
- package/esm/index.d.ts +8 -1
- package/esm/index.js +6 -1
- package/esm/managed-upload.d.ts +132 -0
- package/esm/managed-upload.js +262 -0
- package/esm/physical-bucket.d.ts +60 -0
- package/esm/physical-bucket.js +111 -0
- package/esm/plugin.js +53 -84
- package/esm/s3-signer.d.ts +13 -0
- package/esm/s3-signer.js +23 -1
- package/esm/types.d.ts +34 -2
- package/file-ref-registry.d.ts +61 -0
- package/file-ref-registry.js +121 -0
- package/index.d.ts +8 -1
- package/index.js +20 -1
- package/managed-upload.d.ts +132 -0
- package/managed-upload.js +268 -0
- package/package.json +2 -2
- package/physical-bucket.d.ts +60 -0
- package/physical-bucket.js +117 -0
- package/plugin.js +57 -88
- package/s3-signer.d.ts +13 -0
- package/s3-signer.js +23 -0
- package/types.d.ts +34 -2
package/esm/plugin.js
CHANGED
|
@@ -19,9 +19,12 @@
|
|
|
19
19
|
import 'graphile-build';
|
|
20
20
|
import { Logger } from '@pgpmjs/logger';
|
|
21
21
|
import { access, context as grafastContext, lambda, object } from 'grafast';
|
|
22
|
+
import { resolveDefaultBucket } from './default-bucket';
|
|
23
|
+
import { buildFileProjection } from './managed-upload';
|
|
24
|
+
import { provisionAndRecordPhysicalBucket, resolveS3ForDatabase } from './physical-bucket';
|
|
22
25
|
import { withRequestPgClient } from './request-pg-client';
|
|
23
26
|
import { deleteS3Object, generatePresignedPutUrl } from './s3-signer';
|
|
24
|
-
import { getBucketConfig,
|
|
27
|
+
import { getBucketConfig, loadAllStorageModules, resolveStorageConfigFromCodec, storedPhysicalName } from './storage-module-cache';
|
|
25
28
|
const log = new Logger('graphile-presigned-url:plugin');
|
|
26
29
|
// --- Protocol-level constants (not configurable) ---
|
|
27
30
|
const MAX_CONTENT_HASH_LENGTH = 128;
|
|
@@ -67,87 +70,20 @@ async function resolveDatabaseId(pgClient) {
|
|
|
67
70
|
});
|
|
68
71
|
return result.rows[0]?.id ?? null;
|
|
69
72
|
}
|
|
70
|
-
function resolveS3(options) {
|
|
71
|
-
if (typeof options.s3 === 'function') {
|
|
72
|
-
const resolved = options.s3();
|
|
73
|
-
options.s3 = resolved;
|
|
74
|
-
return resolved;
|
|
75
|
-
}
|
|
76
|
-
return options.s3;
|
|
77
|
-
}
|
|
78
73
|
/**
|
|
79
|
-
*
|
|
74
|
+
* Resolve the bucket an upload mutation writes into.
|
|
80
75
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
76
|
+
* A named `bucketKey` is the caller's override and is read directly, as before.
|
|
77
|
+
* An omitted one asks the database for the tenant's reserved default tag for the
|
|
78
|
+
* requested access, so a missing or ambiguous default raises in SQL rather than
|
|
79
|
+
* falling back to a server-global bucket name here.
|
|
84
80
|
*/
|
|
85
|
-
function
|
|
86
|
-
if (
|
|
87
|
-
return
|
|
81
|
+
async function resolveUploadBucket(pgClient, storageConfig, databaseId, bucketKey, ownerId, isPublic) {
|
|
82
|
+
if (bucketKey) {
|
|
83
|
+
return getBucketConfig(pgClient, storageConfig, databaseId, bucketKey, ownerId || undefined);
|
|
88
84
|
}
|
|
89
|
-
|
|
90
|
-
return
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* Build the S3 config for a *known* physical bucket. `physicalName` is
|
|
94
|
-
* required — callers must resolve the coordinate (stored row value, or a
|
|
95
|
-
* freshly provisioned name) before getting here. No name is ever recomputed.
|
|
96
|
-
*/
|
|
97
|
-
function resolveS3ForDatabase(options, storageConfig, physicalName) {
|
|
98
|
-
const globalS3 = resolveS3(options);
|
|
99
|
-
const publicUrlPrefix = storageConfig.publicUrlPrefix != null
|
|
100
|
-
? storageConfig.publicUrlPrefix
|
|
101
|
-
: globalS3.publicUrlPrefix;
|
|
102
|
-
if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
103
|
-
return globalS3;
|
|
104
|
-
}
|
|
105
|
-
return {
|
|
106
|
-
...globalS3,
|
|
107
|
-
bucket: physicalName,
|
|
108
|
-
...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
/**
|
|
112
|
-
* First provision of a logical bucket: mint a name, create the physical S3
|
|
113
|
-
* bucket, and record the exact name on the source row. Returns the recorded
|
|
114
|
-
* physical name.
|
|
115
|
-
*
|
|
116
|
-
* Only called when the row has no `physical_name` yet. Afterwards the stored
|
|
117
|
-
* value is the durable coordinate: route resolution and every later read use
|
|
118
|
-
* it verbatim; nothing is recomputed.
|
|
119
|
-
*
|
|
120
|
-
* The record write runs in the system lane (privileged role, so it bypasses the
|
|
121
|
-
* RLS that stops request roles from UPDATE-ing bucket rows) — it is server
|
|
122
|
-
* bookkeeping, not request data. It still carries the tenant `database_id`
|
|
123
|
-
* claim, because the buckets table's catalog-sync trigger calls
|
|
124
|
-
* `jwt_private.current_database_id()` and would otherwise raise
|
|
125
|
-
* DATABASE_CLAIM_REQUIRED; `withRequestPgClient` applies that claim inside the
|
|
126
|
-
* write's transaction without switching off the privileged role.
|
|
127
|
-
* `bucket` (the cached config) is mutated in place so subsequent reads observe
|
|
128
|
-
* the recorded name without a DB round-trip.
|
|
129
|
-
*/
|
|
130
|
-
async function provisionAndRecordPhysicalBucket(options, withPgClient, storageConfig, databaseId, bucket, allowedOrigins) {
|
|
131
|
-
const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key);
|
|
132
|
-
if (options.ensureBucketProvisioned && !isS3BucketProvisioned(s3BucketName)) {
|
|
133
|
-
log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
|
|
134
|
-
await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
|
|
135
|
-
markS3BucketProvisioned(s3BucketName);
|
|
136
|
-
log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
|
|
137
|
-
}
|
|
138
|
-
// Record the physical coordinate on the source row. The `physical_name IS NULL`
|
|
139
|
-
// guard keeps this idempotent and race-safe across concurrent first uploads.
|
|
140
|
-
// The catalog-sync trigger on this UPDATE needs `jwt.claims.database_id`, so the
|
|
141
|
-
// write runs under the resolved database claim (privileged role preserved).
|
|
142
|
-
await withRequestPgClient(withPgClient, { 'jwt.claims.database_id': databaseId }, (client) => client.query({
|
|
143
|
-
text: `UPDATE ${storageConfig.bucketsQualifiedName}
|
|
144
|
-
SET physical_name = $1
|
|
145
|
-
WHERE id = $2 AND physical_name IS NULL`,
|
|
146
|
-
values: [s3BucketName, bucket.id],
|
|
147
|
-
}));
|
|
148
|
-
bucket.physical_name = s3BucketName;
|
|
149
|
-
log.info(`Recorded physical_name="${s3BucketName}" on bucket ${bucket.id}`);
|
|
150
|
-
return s3BucketName;
|
|
85
|
+
const coordinate = await resolveDefaultBucket(pgClient, databaseId, storageConfig.scope, ownerId, isPublic, null);
|
|
86
|
+
return getBucketConfig(pgClient, storageConfig, databaseId, coordinate.resolvedKey, ownerId || undefined);
|
|
151
87
|
}
|
|
152
88
|
// --- Plugin factory ---
|
|
153
89
|
export function createPresignedUrlPlugin(options) {
|
|
@@ -166,6 +102,14 @@ export function createPresignedUrlPlugin(options) {
|
|
|
166
102
|
if (!isRootMutation)
|
|
167
103
|
return fields;
|
|
168
104
|
const { graphql: { GraphQLString, GraphQLNonNull, GraphQLInt, GraphQLBoolean, GraphQLObjectType, GraphQLInputObjectType, GraphQLList, }, } = build;
|
|
105
|
+
// The projection document is jsonb-shaped. PostGraphile registers a JSON
|
|
106
|
+
// scalar whenever the schema has a jsonb column, which any storage-equipped
|
|
107
|
+
// database does; if it is absent the payload simply omits the field rather
|
|
108
|
+
// than failing schema build over a field nothing can have asked for yet.
|
|
109
|
+
const jsonType = build.getTypeByName('JSON') ?? null;
|
|
110
|
+
if (!jsonType) {
|
|
111
|
+
log.warn('No JSON scalar in this schema; upload payloads will omit the `file` projection');
|
|
112
|
+
}
|
|
169
113
|
const bucketCodecs = Object.values(build.input.pgRegistry.pgCodecs).filter((codec) => codec.attributes && codec.extensions?.tags?.storageBuckets);
|
|
170
114
|
if (bucketCodecs.length === 0)
|
|
171
115
|
return fields;
|
|
@@ -199,7 +143,8 @@ export function createPresignedUrlPlugin(options) {
|
|
|
199
143
|
const InputType = new GraphQLInputObjectType({
|
|
200
144
|
name: `Upload${filesTypeName}Input`,
|
|
201
145
|
fields: {
|
|
202
|
-
bucketKey: { type:
|
|
146
|
+
bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
|
|
147
|
+
isPublic: { type: GraphQLBoolean, description: 'Which default bucket to resolve when bucketKey is omitted: the public one (true) or the private one (default false). Ignored when bucketKey is given.' },
|
|
203
148
|
...(hasOwnerId
|
|
204
149
|
? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
|
|
205
150
|
: {}),
|
|
@@ -219,6 +164,16 @@ export function createPresignedUrlPlugin(options) {
|
|
|
219
164
|
deduplicated: { type: new GraphQLNonNull(GraphQLBoolean), description: 'Whether this file was deduplicated (content already exists)' },
|
|
220
165
|
expiresAt: { type: GraphQLString, description: 'Presigned URL expiry time (null if deduplicated)' },
|
|
221
166
|
previousVersionId: { type: GraphQLString, description: 'ID of the previous version (when using custom keys)' },
|
|
167
|
+
...(jsonType
|
|
168
|
+
? {
|
|
169
|
+
file: {
|
|
170
|
+
type: jsonType,
|
|
171
|
+
description: 'The projection document for the created file: {id, key, bucket_id, mime, size, filename, url?}. ' +
|
|
172
|
+
'Store this verbatim in an image/upload column — its `id` is what keeps the object from being ' +
|
|
173
|
+
'garbage collected while the column still references it.',
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
: {}),
|
|
222
177
|
},
|
|
223
178
|
});
|
|
224
179
|
const capturedFilesCodec = filesCodec;
|
|
@@ -232,6 +187,7 @@ export function createPresignedUrlPlugin(options) {
|
|
|
232
187
|
plan(_$mutation, fieldArgs) {
|
|
233
188
|
const $input = fieldArgs.getRaw('input');
|
|
234
189
|
const $bucketKey = access($input, 'bucketKey');
|
|
190
|
+
const $isPublic = access($input, 'isPublic');
|
|
235
191
|
const $contentHash = access($input, 'contentHash');
|
|
236
192
|
const $contentType = access($input, 'contentType');
|
|
237
193
|
const $size = access($input, 'size');
|
|
@@ -242,6 +198,7 @@ export function createPresignedUrlPlugin(options) {
|
|
|
242
198
|
const $pgSettings = grafastContext().get('pgSettings');
|
|
243
199
|
const $combined = object({
|
|
244
200
|
bucketKey: $bucketKey,
|
|
201
|
+
isPublic: $isPublic,
|
|
245
202
|
ownerId: $ownerId,
|
|
246
203
|
contentHash: $contentHash,
|
|
247
204
|
contentType: $contentType,
|
|
@@ -264,8 +221,8 @@ export function createPresignedUrlPlugin(options) {
|
|
|
264
221
|
const storageConfig = resolveStorageConfigFromCodec(capturedFilesCodec, allConfigs);
|
|
265
222
|
if (!storageConfig)
|
|
266
223
|
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
267
|
-
// Bucket
|
|
268
|
-
const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) =>
|
|
224
|
+
// Bucket resolution + read under the request role (RLS-gated visibility).
|
|
225
|
+
const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => resolveUploadBucket(pgClient, storageConfig, databaseId, vals.bucketKey ?? null, vals.ownerId ?? null, vals.isPublic === true));
|
|
269
226
|
if (!bucket)
|
|
270
227
|
throw new Error('BUCKET_NOT_FOUND');
|
|
271
228
|
// First provision mints + records the coordinate; afterwards the
|
|
@@ -305,12 +262,14 @@ export function createPresignedUrlPlugin(options) {
|
|
|
305
262
|
deduplicated: { type: new GraphQLNonNull(GraphQLBoolean) },
|
|
306
263
|
expiresAt: { type: GraphQLString },
|
|
307
264
|
previousVersionId: { type: GraphQLString },
|
|
265
|
+
...(jsonType ? { file: { type: jsonType, description: 'The projection document for the created file.' } } : {}),
|
|
308
266
|
},
|
|
309
267
|
});
|
|
310
268
|
const BulkInputType = new GraphQLInputObjectType({
|
|
311
269
|
name: `Upload${filesTypeName}BulkInput`,
|
|
312
270
|
fields: {
|
|
313
|
-
bucketKey: { type:
|
|
271
|
+
bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
|
|
272
|
+
isPublic: { type: GraphQLBoolean, description: 'Which default bucket to resolve when bucketKey is omitted. Ignored when bucketKey is given.' },
|
|
314
273
|
...(hasOwnerId
|
|
315
274
|
? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
|
|
316
275
|
: {}),
|
|
@@ -334,12 +293,14 @@ export function createPresignedUrlPlugin(options) {
|
|
|
334
293
|
plan(_$mutation, fieldArgs) {
|
|
335
294
|
const $input = fieldArgs.getRaw('input');
|
|
336
295
|
const $bucketKey = access($input, 'bucketKey');
|
|
296
|
+
const $isPublic = access($input, 'isPublic');
|
|
337
297
|
const $ownerId = hasOwnerId ? access($input, 'ownerId') : lambda(null, () => null);
|
|
338
298
|
const $files = access($input, 'files');
|
|
339
299
|
const $withPgClient = grafastContext().get('withPgClient');
|
|
340
300
|
const $pgSettings = grafastContext().get('pgSettings');
|
|
341
301
|
const $combined = object({
|
|
342
302
|
bucketKey: $bucketKey,
|
|
303
|
+
isPublic: $isPublic,
|
|
343
304
|
ownerId: $ownerId,
|
|
344
305
|
files: $files,
|
|
345
306
|
withPgClient: $withPgClient,
|
|
@@ -358,8 +319,8 @@ export function createPresignedUrlPlugin(options) {
|
|
|
358
319
|
const storageConfig = resolveStorageConfigFromCodec(capturedFilesCodec, allConfigs);
|
|
359
320
|
if (!storageConfig)
|
|
360
321
|
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
361
|
-
// Bucket
|
|
362
|
-
const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) =>
|
|
322
|
+
// Bucket resolution + read under the request role (RLS-gated visibility).
|
|
323
|
+
const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => resolveUploadBucket(pgClient, storageConfig, databaseId, vals.bucketKey ?? null, vals.ownerId ?? null, vals.isPublic === true));
|
|
363
324
|
if (!bucket)
|
|
364
325
|
throw new Error('BUCKET_NOT_FOUND');
|
|
365
326
|
// Enforce bulk upload limits
|
|
@@ -564,6 +525,11 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
564
525
|
if (bucket.max_file_size && size > bucket.max_file_size) {
|
|
565
526
|
throw new Error(`FILE_TOO_LARGE: exceeds bucket max of ${bucket.max_file_size} bytes`);
|
|
566
527
|
}
|
|
528
|
+
// The projection document the caller stores in an image/upload column. Built
|
|
529
|
+
// from the same values the files row carries, so the column and the row cannot
|
|
530
|
+
// disagree, and it names the files row by id — which is what stops GC from
|
|
531
|
+
// collecting an object a document still points at.
|
|
532
|
+
const projectFile = (fileId, key) => buildFileProjection({ id: fileId, key, bucketId: bucket.id, mime: contentType, size, filename }, bucket, s3ForDb);
|
|
567
533
|
// Determine S3 key
|
|
568
534
|
let s3Key;
|
|
569
535
|
let isCustomKey = false;
|
|
@@ -604,6 +570,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
604
570
|
deduplicated: true,
|
|
605
571
|
expiresAt: null,
|
|
606
572
|
previousVersionId: null,
|
|
573
|
+
file: projectFile(existing.id, s3Key),
|
|
607
574
|
};
|
|
608
575
|
}
|
|
609
576
|
previousVersionId = existing.id;
|
|
@@ -629,6 +596,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
629
596
|
deduplicated: true,
|
|
630
597
|
expiresAt: null,
|
|
631
598
|
previousVersionId: null,
|
|
599
|
+
file: projectFile(existingFile.id, s3Key),
|
|
632
600
|
};
|
|
633
601
|
}
|
|
634
602
|
}
|
|
@@ -669,6 +637,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
669
637
|
deduplicated: false,
|
|
670
638
|
expiresAt,
|
|
671
639
|
previousVersionId,
|
|
640
|
+
file: projectFile(fileId, s3Key),
|
|
672
641
|
};
|
|
673
642
|
}
|
|
674
643
|
export const PresignedUrlPlugin = createPresignedUrlPlugin;
|
package/esm/s3-signer.d.ts
CHANGED
|
@@ -36,6 +36,19 @@ export declare function generatePresignedGetUrl(s3Config: S3Config, key: string,
|
|
|
36
36
|
* @param key - S3 object key to delete
|
|
37
37
|
*/
|
|
38
38
|
export declare function deleteS3Object(s3Config: S3Config, key: string): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Copy an object within the same physical bucket, preserving its content type.
|
|
41
|
+
*
|
|
42
|
+
* Used to promote a staged upload to its content-addressed key: the bytes are
|
|
43
|
+
* hashed as they stream in, so the final key is only known once the stream ends.
|
|
44
|
+
* Server-side copy keeps that promotion off the application's wire.
|
|
45
|
+
*
|
|
46
|
+
* @param s3Config - S3 client and bucket configuration
|
|
47
|
+
* @param sourceKey - The staged key the bytes were written to
|
|
48
|
+
* @param destinationKey - The final key (the content hash)
|
|
49
|
+
* @param contentType - MIME type to record on the destination object
|
|
50
|
+
*/
|
|
51
|
+
export declare function copyS3Object(s3Config: S3Config, sourceKey: string, destinationKey: string, contentType: string): Promise<void>;
|
|
39
52
|
/**
|
|
40
53
|
* Check if an object exists in S3 and optionally verify its content-type.
|
|
41
54
|
*
|
package/esm/s3-signer.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, } from '@aws-sdk/client-s3';
|
|
1
|
+
import { CopyObjectCommand, 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');
|
|
@@ -68,6 +68,28 @@ export async function deleteS3Object(s3Config, key) {
|
|
|
68
68
|
}));
|
|
69
69
|
log.debug(`Deleted S3 object: bucket=${s3Config.bucket}, key=${key}`);
|
|
70
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Copy an object within the same physical bucket, preserving its content type.
|
|
73
|
+
*
|
|
74
|
+
* Used to promote a staged upload to its content-addressed key: the bytes are
|
|
75
|
+
* hashed as they stream in, so the final key is only known once the stream ends.
|
|
76
|
+
* Server-side copy keeps that promotion off the application's wire.
|
|
77
|
+
*
|
|
78
|
+
* @param s3Config - S3 client and bucket configuration
|
|
79
|
+
* @param sourceKey - The staged key the bytes were written to
|
|
80
|
+
* @param destinationKey - The final key (the content hash)
|
|
81
|
+
* @param contentType - MIME type to record on the destination object
|
|
82
|
+
*/
|
|
83
|
+
export async function copyS3Object(s3Config, sourceKey, destinationKey, contentType) {
|
|
84
|
+
await s3Config.client.send(new CopyObjectCommand({
|
|
85
|
+
Bucket: s3Config.bucket,
|
|
86
|
+
Key: destinationKey,
|
|
87
|
+
CopySource: `${s3Config.bucket}/${sourceKey}`,
|
|
88
|
+
ContentType: contentType,
|
|
89
|
+
MetadataDirective: 'REPLACE',
|
|
90
|
+
}));
|
|
91
|
+
log.debug(`Copied S3 object: bucket=${s3Config.bucket}, ${sourceKey} → ${destinationKey}`);
|
|
92
|
+
}
|
|
71
93
|
/**
|
|
72
94
|
* Check if an object exists in S3 and optionally verify its content-type.
|
|
73
95
|
*
|
package/esm/types.d.ts
CHANGED
|
@@ -70,8 +70,19 @@ export interface StorageModuleConfig {
|
|
|
70
70
|
* Input for the requestUploadUrl mutation.
|
|
71
71
|
*/
|
|
72
72
|
export interface RequestUploadUrlInput {
|
|
73
|
-
/**
|
|
74
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Logical bucket key (e.g., "public", "private").
|
|
75
|
+
*
|
|
76
|
+
* Optional: when omitted the database resolves its own default bucket for the
|
|
77
|
+
* requested access (see `isPublic`), so a client never has to know a tenant's
|
|
78
|
+
* bucket naming to upload.
|
|
79
|
+
*/
|
|
80
|
+
bucketKey?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Which default bucket to resolve when `bucketKey` is omitted: the public one
|
|
83
|
+
* (true) or the private one (default false). Ignored when `bucketKey` is given.
|
|
84
|
+
*/
|
|
85
|
+
isPublic?: boolean;
|
|
75
86
|
/**
|
|
76
87
|
* Owner entity ID for entity-scoped uploads.
|
|
77
88
|
* Omit for app-level (database-wide) storage.
|
|
@@ -111,6 +122,27 @@ export interface RequestUploadUrlPayload {
|
|
|
111
122
|
expiresAt: string | null;
|
|
112
123
|
/** ID of the previous version (set when re-uploading to an existing custom key) */
|
|
113
124
|
previousVersionId: string | null;
|
|
125
|
+
/**
|
|
126
|
+
* The projection document to store in an `image`/`upload` column.
|
|
127
|
+
*
|
|
128
|
+
* Its `id` is the files row, which is what makes the column a reference the
|
|
129
|
+
* server can count — storage GC will not collect an object while a registered
|
|
130
|
+
* document column still names its file.
|
|
131
|
+
*/
|
|
132
|
+
file: FileProjection;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The document a managed `image`/`upload` column stores. See `./managed-upload`.
|
|
136
|
+
*/
|
|
137
|
+
export interface FileProjection {
|
|
138
|
+
id: string;
|
|
139
|
+
key: string;
|
|
140
|
+
bucket_id: string;
|
|
141
|
+
mime: string;
|
|
142
|
+
size: number;
|
|
143
|
+
filename?: string;
|
|
144
|
+
/** @deprecated Read the files row's `downloadUrl` via `id` instead. */
|
|
145
|
+
url?: string;
|
|
114
146
|
}
|
|
115
147
|
/**
|
|
116
148
|
* S3 configuration for the presigned URL plugin.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `file_ref_field` registry: which storage module and bucket a managed
|
|
3
|
+
* document column writes into.
|
|
4
|
+
*
|
|
5
|
+
* An `image`/`upload` column is a projection of a files row, and the decision of
|
|
6
|
+
* *where* those bytes live is a property of the field declaration, not of the
|
|
7
|
+
* request. The registry records that intent per (table, column) — a storage
|
|
8
|
+
* module plus either a logical bucket key, a tag selector, or nothing at all
|
|
9
|
+
* (meaning the reserved default tag for the declared publicness).
|
|
10
|
+
*
|
|
11
|
+
* This module answers one question — "what does a write to this column bind
|
|
12
|
+
* to?" — and answers it loudly: an unregistered column raises rather than
|
|
13
|
+
* falling back to a server-global bucket, because a silent fallback is how the
|
|
14
|
+
* unmanaged lane produced objects no tenant owned.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* A field's recorded storage intent.
|
|
18
|
+
*
|
|
19
|
+
* `bucketKey` and `bucketTags` are mutually exclusive by table constraint, and
|
|
20
|
+
* both may be absent — resolution then uses the reserved default tag for
|
|
21
|
+
* `isPublic`. Nothing here is a physical bucket name or id: the concrete bucket
|
|
22
|
+
* is resolved per written row, inside the tenant.
|
|
23
|
+
*/
|
|
24
|
+
export interface FileRefFieldBinding {
|
|
25
|
+
id: string;
|
|
26
|
+
storageModuleId: string;
|
|
27
|
+
bucketKey: string | null;
|
|
28
|
+
bucketTags: string[] | null;
|
|
29
|
+
isPublic: boolean | null;
|
|
30
|
+
enforceFk: boolean;
|
|
31
|
+
}
|
|
32
|
+
export declare class FileRefFieldNotRegisteredError extends Error {
|
|
33
|
+
readonly databaseId: string;
|
|
34
|
+
readonly schemaName: string;
|
|
35
|
+
readonly tableName: string;
|
|
36
|
+
readonly columnName: string;
|
|
37
|
+
constructor(databaseId: string, schemaName: string, tableName: string, columnName: string);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Look up the storage binding for a document column, or throw.
|
|
41
|
+
*
|
|
42
|
+
* The read runs on whichever client the caller passes. The registry is schema
|
|
43
|
+
* metadata rather than tenant rows, so callers resolve it in the system lane —
|
|
44
|
+
* the RLS that matters is on the files table the upload eventually writes.
|
|
45
|
+
*/
|
|
46
|
+
export declare function getFileRefFieldBinding(pgClient: {
|
|
47
|
+
query: (opts: {
|
|
48
|
+
text: string;
|
|
49
|
+
values?: unknown[];
|
|
50
|
+
}) => Promise<{
|
|
51
|
+
rows: unknown[];
|
|
52
|
+
}>;
|
|
53
|
+
}, databaseId: string, field: {
|
|
54
|
+
schemaName: string;
|
|
55
|
+
tableName: string;
|
|
56
|
+
columnName: string;
|
|
57
|
+
}): Promise<FileRefFieldBinding>;
|
|
58
|
+
/**
|
|
59
|
+
* Drop cached bindings. Used by tests and after a re-provision.
|
|
60
|
+
*/
|
|
61
|
+
export declare function clearFileRefFieldCache(): void;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The `file_ref_field` registry: which storage module and bucket a managed
|
|
4
|
+
* document column writes into.
|
|
5
|
+
*
|
|
6
|
+
* An `image`/`upload` column is a projection of a files row, and the decision of
|
|
7
|
+
* *where* those bytes live is a property of the field declaration, not of the
|
|
8
|
+
* request. The registry records that intent per (table, column) — a storage
|
|
9
|
+
* module plus either a logical bucket key, a tag selector, or nothing at all
|
|
10
|
+
* (meaning the reserved default tag for the declared publicness).
|
|
11
|
+
*
|
|
12
|
+
* This module answers one question — "what does a write to this column bind
|
|
13
|
+
* to?" — and answers it loudly: an unregistered column raises rather than
|
|
14
|
+
* falling back to a server-global bucket, because a silent fallback is how the
|
|
15
|
+
* unmanaged lane produced objects no tenant owned.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.FileRefFieldNotRegisteredError = void 0;
|
|
19
|
+
exports.getFileRefFieldBinding = getFileRefFieldBinding;
|
|
20
|
+
exports.clearFileRefFieldCache = clearFileRefFieldCache;
|
|
21
|
+
const logger_1 = require("@pgpmjs/logger");
|
|
22
|
+
const lru_cache_1 = require("lru-cache");
|
|
23
|
+
const log = new logger_1.Logger('graphile-presigned-url:file-ref-registry');
|
|
24
|
+
const FIVE_MINUTES_MS = 1000 * 60 * 5;
|
|
25
|
+
const ONE_HOUR_MS = 1000 * 60 * 60;
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the registry row for a document column.
|
|
28
|
+
*
|
|
29
|
+
* Joined through metaschema rather than keyed by name, because the registry
|
|
30
|
+
* records field *ids*: the physical (schema, table, column) triple is what the
|
|
31
|
+
* GraphQL layer knows, and metaschema is the only thing that maps one to the
|
|
32
|
+
* other.
|
|
33
|
+
*/
|
|
34
|
+
const FILE_REF_FIELD_QUERY = `
|
|
35
|
+
SELECT
|
|
36
|
+
frf.id,
|
|
37
|
+
frf.storage_module_id,
|
|
38
|
+
frf.bucket_key,
|
|
39
|
+
frf.bucket_tags::text[] AS bucket_tags,
|
|
40
|
+
frf.is_public,
|
|
41
|
+
frf.enforce_fk
|
|
42
|
+
FROM metaschema_modules_public.file_ref_field frf
|
|
43
|
+
JOIN metaschema_public.field f ON f.id = frf.field_id
|
|
44
|
+
JOIN metaschema_public.table t ON t.id = frf.table_id
|
|
45
|
+
JOIN metaschema_public.schema s ON s.id = t.schema_id
|
|
46
|
+
WHERE frf.database_id = $1
|
|
47
|
+
AND s.schema_name = $2
|
|
48
|
+
AND t.name = $3
|
|
49
|
+
AND f.name = $4
|
|
50
|
+
LIMIT 1
|
|
51
|
+
`;
|
|
52
|
+
/**
|
|
53
|
+
* LRU cache of field bindings.
|
|
54
|
+
*
|
|
55
|
+
* A binding is schema, not data: it changes only when a database is
|
|
56
|
+
* re-provisioned, so it caches on the same terms as the storage module config
|
|
57
|
+
* next to it. Misses are never cached — an unregistered column is a hard error
|
|
58
|
+
* every time it is written, not a remembered "no".
|
|
59
|
+
*/
|
|
60
|
+
const bindingCache = new lru_cache_1.LRUCache({
|
|
61
|
+
max: 500,
|
|
62
|
+
ttl: process.env.NODE_ENV === 'development' ? FIVE_MINUTES_MS : ONE_HOUR_MS,
|
|
63
|
+
updateAgeOnGet: true,
|
|
64
|
+
});
|
|
65
|
+
class FileRefFieldNotRegisteredError extends Error {
|
|
66
|
+
databaseId;
|
|
67
|
+
schemaName;
|
|
68
|
+
tableName;
|
|
69
|
+
columnName;
|
|
70
|
+
constructor(databaseId, schemaName, tableName, columnName) {
|
|
71
|
+
super(`FILE_REF_FIELD_NOT_REGISTERED: ${schemaName}.${tableName}.${columnName} ` +
|
|
72
|
+
`is not a registered file-reference field in database ${databaseId}. ` +
|
|
73
|
+
'A managed upload needs the declared storage module and bucket intent; ' +
|
|
74
|
+
'there is no server-global bucket to fall back to.');
|
|
75
|
+
this.databaseId = databaseId;
|
|
76
|
+
this.schemaName = schemaName;
|
|
77
|
+
this.tableName = tableName;
|
|
78
|
+
this.columnName = columnName;
|
|
79
|
+
this.name = 'FileRefFieldNotRegisteredError';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
exports.FileRefFieldNotRegisteredError = FileRefFieldNotRegisteredError;
|
|
83
|
+
/**
|
|
84
|
+
* Look up the storage binding for a document column, or throw.
|
|
85
|
+
*
|
|
86
|
+
* The read runs on whichever client the caller passes. The registry is schema
|
|
87
|
+
* metadata rather than tenant rows, so callers resolve it in the system lane —
|
|
88
|
+
* the RLS that matters is on the files table the upload eventually writes.
|
|
89
|
+
*/
|
|
90
|
+
async function getFileRefFieldBinding(pgClient, databaseId, field) {
|
|
91
|
+
const cacheKey = `file-ref:${databaseId}:${field.schemaName}.${field.tableName}.${field.columnName}`;
|
|
92
|
+
const cached = bindingCache.get(cacheKey);
|
|
93
|
+
if (cached)
|
|
94
|
+
return cached;
|
|
95
|
+
const result = await pgClient.query({
|
|
96
|
+
text: FILE_REF_FIELD_QUERY,
|
|
97
|
+
values: [databaseId, field.schemaName, field.tableName, field.columnName],
|
|
98
|
+
});
|
|
99
|
+
if (result.rows.length === 0) {
|
|
100
|
+
throw new FileRefFieldNotRegisteredError(databaseId, field.schemaName, field.tableName, field.columnName);
|
|
101
|
+
}
|
|
102
|
+
const row = result.rows[0];
|
|
103
|
+
const binding = {
|
|
104
|
+
id: row.id,
|
|
105
|
+
storageModuleId: row.storage_module_id,
|
|
106
|
+
bucketKey: row.bucket_key,
|
|
107
|
+
bucketTags: row.bucket_tags,
|
|
108
|
+
isPublic: row.is_public,
|
|
109
|
+
enforceFk: row.enforce_fk,
|
|
110
|
+
};
|
|
111
|
+
bindingCache.set(cacheKey, binding);
|
|
112
|
+
log.debug(`Bound ${field.schemaName}.${field.tableName}.${field.columnName} to storage module ` +
|
|
113
|
+
`${binding.storageModuleId} (bucket_key=${binding.bucketKey ?? '<default tag>'})`);
|
|
114
|
+
return binding;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Drop cached bindings. Used by tests and after a re-provision.
|
|
118
|
+
*/
|
|
119
|
+
function clearFileRefFieldCache() {
|
|
120
|
+
bindingCache.clear();
|
|
121
|
+
}
|
package/index.d.ts
CHANGED
|
@@ -26,9 +26,16 @@
|
|
|
26
26
|
* };
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
|
+
export type { ResolvedBucketCoordinate } from './default-bucket';
|
|
30
|
+
export { resolveDefaultBucket } from './default-bucket';
|
|
29
31
|
export { createDownloadUrlPlugin } from './download-url-field';
|
|
32
|
+
export type { FileRefFieldBinding } from './file-ref-registry';
|
|
33
|
+
export { clearFileRefFieldCache, FileRefFieldNotRegisteredError, getFileRefFieldBinding } from './file-ref-registry';
|
|
34
|
+
export { assertUploadAllowedByBucket, buildFileProjection, type FileProjection, finalizeStagedUpload, type ManagedUploadTarget, resolveManagedUploadTarget, } from './managed-upload';
|
|
35
|
+
export { mintPhysicalBucketName, provisionAndRecordPhysicalBucket, resolveS3, resolveS3ForDatabase } from './physical-bucket';
|
|
30
36
|
export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
|
|
31
37
|
export { PresignedUrlPreset } from './preset';
|
|
32
|
-
export {
|
|
38
|
+
export { type WithPgClient, withRequestPgClient } from './request-pg-client';
|
|
39
|
+
export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject } from './s3-signer';
|
|
33
40
|
export { clearBucketCache, clearStorageModuleCache, getBucketConfig, getStorageModuleConfig, getStorageModuleConfigForOwner, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
|
|
34
41
|
export type { BucketConfig, BucketNameResolver, EnsureBucketProvisioned, PresignedUrlPluginOptions, RequestUploadUrlInput, RequestUploadUrlPayload, S3Config, S3ConfigOrGetter, StorageModuleConfig, } from './types';
|
package/index.js
CHANGED
|
@@ -28,15 +28,34 @@
|
|
|
28
28
|
* ```
|
|
29
29
|
*/
|
|
30
30
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
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;
|
|
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.copyS3Object = exports.withRequestPgClient = exports.PresignedUrlPreset = exports.PresignedUrlPlugin = exports.createPresignedUrlPlugin = exports.resolveS3ForDatabase = exports.resolveS3 = exports.provisionAndRecordPhysicalBucket = exports.mintPhysicalBucketName = exports.resolveManagedUploadTarget = exports.finalizeStagedUpload = exports.buildFileProjection = exports.assertUploadAllowedByBucket = exports.getFileRefFieldBinding = exports.FileRefFieldNotRegisteredError = exports.clearFileRefFieldCache = exports.createDownloadUrlPlugin = exports.resolveDefaultBucket = void 0;
|
|
32
|
+
var default_bucket_1 = require("./default-bucket");
|
|
33
|
+
Object.defineProperty(exports, "resolveDefaultBucket", { enumerable: true, get: function () { return default_bucket_1.resolveDefaultBucket; } });
|
|
32
34
|
var download_url_field_1 = require("./download-url-field");
|
|
33
35
|
Object.defineProperty(exports, "createDownloadUrlPlugin", { enumerable: true, get: function () { return download_url_field_1.createDownloadUrlPlugin; } });
|
|
36
|
+
var file_ref_registry_1 = require("./file-ref-registry");
|
|
37
|
+
Object.defineProperty(exports, "clearFileRefFieldCache", { enumerable: true, get: function () { return file_ref_registry_1.clearFileRefFieldCache; } });
|
|
38
|
+
Object.defineProperty(exports, "FileRefFieldNotRegisteredError", { enumerable: true, get: function () { return file_ref_registry_1.FileRefFieldNotRegisteredError; } });
|
|
39
|
+
Object.defineProperty(exports, "getFileRefFieldBinding", { enumerable: true, get: function () { return file_ref_registry_1.getFileRefFieldBinding; } });
|
|
40
|
+
var managed_upload_1 = require("./managed-upload");
|
|
41
|
+
Object.defineProperty(exports, "assertUploadAllowedByBucket", { enumerable: true, get: function () { return managed_upload_1.assertUploadAllowedByBucket; } });
|
|
42
|
+
Object.defineProperty(exports, "buildFileProjection", { enumerable: true, get: function () { return managed_upload_1.buildFileProjection; } });
|
|
43
|
+
Object.defineProperty(exports, "finalizeStagedUpload", { enumerable: true, get: function () { return managed_upload_1.finalizeStagedUpload; } });
|
|
44
|
+
Object.defineProperty(exports, "resolveManagedUploadTarget", { enumerable: true, get: function () { return managed_upload_1.resolveManagedUploadTarget; } });
|
|
45
|
+
var physical_bucket_1 = require("./physical-bucket");
|
|
46
|
+
Object.defineProperty(exports, "mintPhysicalBucketName", { enumerable: true, get: function () { return physical_bucket_1.mintPhysicalBucketName; } });
|
|
47
|
+
Object.defineProperty(exports, "provisionAndRecordPhysicalBucket", { enumerable: true, get: function () { return physical_bucket_1.provisionAndRecordPhysicalBucket; } });
|
|
48
|
+
Object.defineProperty(exports, "resolveS3", { enumerable: true, get: function () { return physical_bucket_1.resolveS3; } });
|
|
49
|
+
Object.defineProperty(exports, "resolveS3ForDatabase", { enumerable: true, get: function () { return physical_bucket_1.resolveS3ForDatabase; } });
|
|
34
50
|
var plugin_1 = require("./plugin");
|
|
35
51
|
Object.defineProperty(exports, "createPresignedUrlPlugin", { enumerable: true, get: function () { return plugin_1.createPresignedUrlPlugin; } });
|
|
36
52
|
Object.defineProperty(exports, "PresignedUrlPlugin", { enumerable: true, get: function () { return plugin_1.PresignedUrlPlugin; } });
|
|
37
53
|
var preset_1 = require("./preset");
|
|
38
54
|
Object.defineProperty(exports, "PresignedUrlPreset", { enumerable: true, get: function () { return preset_1.PresignedUrlPreset; } });
|
|
55
|
+
var request_pg_client_1 = require("./request-pg-client");
|
|
56
|
+
Object.defineProperty(exports, "withRequestPgClient", { enumerable: true, get: function () { return request_pg_client_1.withRequestPgClient; } });
|
|
39
57
|
var s3_signer_1 = require("./s3-signer");
|
|
58
|
+
Object.defineProperty(exports, "copyS3Object", { enumerable: true, get: function () { return s3_signer_1.copyS3Object; } });
|
|
40
59
|
Object.defineProperty(exports, "deleteS3Object", { enumerable: true, get: function () { return s3_signer_1.deleteS3Object; } });
|
|
41
60
|
Object.defineProperty(exports, "generatePresignedGetUrl", { enumerable: true, get: function () { return s3_signer_1.generatePresignedGetUrl; } });
|
|
42
61
|
Object.defineProperty(exports, "generatePresignedPutUrl", { enumerable: true, get: function () { return s3_signer_1.generatePresignedPutUrl; } });
|