graphile-presigned-url-plugin 1.11.4 → 1.13.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/confirm-upload.d.ts +64 -0
- package/confirm-upload.js +70 -0
- package/default-bucket.d.ts +42 -0
- package/default-bucket.js +51 -0
- package/esm/confirm-upload.d.ts +64 -0
- package/esm/confirm-upload.js +66 -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 +9 -1
- package/esm/index.js +7 -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 +64 -84
- package/esm/s3-signer.d.ts +29 -0
- package/esm/s3-signer.js +61 -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 +9 -1
- package/index.js +24 -1
- package/managed-upload.d.ts +132 -0
- package/managed-upload.js +268 -0
- package/package.json +4 -3
- package/physical-bucket.d.ts +60 -0
- package/physical-bucket.js +117 -0
- package/plugin.js +68 -88
- package/s3-signer.d.ts +29 -0
- package/s3-signer.js +62 -0
- package/types.d.ts +34 -2
package/plugin.js
CHANGED
|
@@ -23,6 +23,10 @@ exports.createPresignedUrlPlugin = createPresignedUrlPlugin;
|
|
|
23
23
|
require("graphile-build");
|
|
24
24
|
const logger_1 = require("@pgpmjs/logger");
|
|
25
25
|
const grafast_1 = require("grafast");
|
|
26
|
+
const mime_bytes_1 = require("mime-bytes");
|
|
27
|
+
const default_bucket_1 = require("./default-bucket");
|
|
28
|
+
const managed_upload_1 = require("./managed-upload");
|
|
29
|
+
const physical_bucket_1 = require("./physical-bucket");
|
|
26
30
|
const request_pg_client_1 = require("./request-pg-client");
|
|
27
31
|
const s3_signer_1 = require("./s3-signer");
|
|
28
32
|
const storage_module_cache_1 = require("./storage-module-cache");
|
|
@@ -71,87 +75,20 @@ async function resolveDatabaseId(pgClient) {
|
|
|
71
75
|
});
|
|
72
76
|
return result.rows[0]?.id ?? null;
|
|
73
77
|
}
|
|
74
|
-
function resolveS3(options) {
|
|
75
|
-
if (typeof options.s3 === 'function') {
|
|
76
|
-
const resolved = options.s3();
|
|
77
|
-
options.s3 = resolved;
|
|
78
|
-
return resolved;
|
|
79
|
-
}
|
|
80
|
-
return options.s3;
|
|
81
|
-
}
|
|
82
|
-
/**
|
|
83
|
-
* Mint the physical S3 bucket name for a logical bucket's first provision.
|
|
84
|
-
*
|
|
85
|
-
* This is a naming *policy*, consulted exactly once per bucket — before the
|
|
86
|
-
* physical bucket exists. Once provisioned, the recorded `physical_name` on
|
|
87
|
-
* the row is authoritative and this function must not be consulted again.
|
|
88
|
-
*/
|
|
89
|
-
function mintPhysicalBucketName(options, databaseId, bucketKey) {
|
|
90
|
-
if (options.resolveBucketName) {
|
|
91
|
-
return options.resolveBucketName(databaseId, bucketKey);
|
|
92
|
-
}
|
|
93
|
-
// Single-bucket deployment: the globally configured bucket is the physical bucket.
|
|
94
|
-
return resolveS3(options).bucket;
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Build the S3 config for a *known* physical bucket. `physicalName` is
|
|
98
|
-
* required — callers must resolve the coordinate (stored row value, or a
|
|
99
|
-
* freshly provisioned name) before getting here. No name is ever recomputed.
|
|
100
|
-
*/
|
|
101
|
-
function resolveS3ForDatabase(options, storageConfig, physicalName) {
|
|
102
|
-
const globalS3 = resolveS3(options);
|
|
103
|
-
const publicUrlPrefix = storageConfig.publicUrlPrefix != null
|
|
104
|
-
? storageConfig.publicUrlPrefix
|
|
105
|
-
: globalS3.publicUrlPrefix;
|
|
106
|
-
if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
107
|
-
return globalS3;
|
|
108
|
-
}
|
|
109
|
-
return {
|
|
110
|
-
...globalS3,
|
|
111
|
-
bucket: physicalName,
|
|
112
|
-
...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
78
|
/**
|
|
116
|
-
*
|
|
117
|
-
* bucket, and record the exact name on the source row. Returns the recorded
|
|
118
|
-
* physical name.
|
|
119
|
-
*
|
|
120
|
-
* Only called when the row has no `physical_name` yet. Afterwards the stored
|
|
121
|
-
* value is the durable coordinate: route resolution and every later read use
|
|
122
|
-
* it verbatim; nothing is recomputed.
|
|
79
|
+
* Resolve the bucket an upload mutation writes into.
|
|
123
80
|
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* `jwt_private.current_database_id()` and would otherwise raise
|
|
129
|
-
* DATABASE_CLAIM_REQUIRED; `withRequestPgClient` applies that claim inside the
|
|
130
|
-
* write's transaction without switching off the privileged role.
|
|
131
|
-
* `bucket` (the cached config) is mutated in place so subsequent reads observe
|
|
132
|
-
* the recorded name without a DB round-trip.
|
|
81
|
+
* A named `bucketKey` is the caller's override and is read directly, as before.
|
|
82
|
+
* An omitted one asks the database for the tenant's reserved default tag for the
|
|
83
|
+
* requested access, so a missing or ambiguous default raises in SQL rather than
|
|
84
|
+
* falling back to a server-global bucket name here.
|
|
133
85
|
*/
|
|
134
|
-
async function
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
|
|
138
|
-
await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
|
|
139
|
-
(0, storage_module_cache_1.markS3BucketProvisioned)(s3BucketName);
|
|
140
|
-
log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
|
|
86
|
+
async function resolveUploadBucket(pgClient, storageConfig, databaseId, bucketKey, ownerId, isPublic) {
|
|
87
|
+
if (bucketKey) {
|
|
88
|
+
return (0, storage_module_cache_1.getBucketConfig)(pgClient, storageConfig, databaseId, bucketKey, ownerId || undefined);
|
|
141
89
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
// The catalog-sync trigger on this UPDATE needs `jwt.claims.database_id`, so the
|
|
145
|
-
// write runs under the resolved database claim (privileged role preserved).
|
|
146
|
-
await (0, request_pg_client_1.withRequestPgClient)(withPgClient, { 'jwt.claims.database_id': databaseId }, (client) => client.query({
|
|
147
|
-
text: `UPDATE ${storageConfig.bucketsQualifiedName}
|
|
148
|
-
SET physical_name = $1
|
|
149
|
-
WHERE id = $2 AND physical_name IS NULL`,
|
|
150
|
-
values: [s3BucketName, bucket.id],
|
|
151
|
-
}));
|
|
152
|
-
bucket.physical_name = s3BucketName;
|
|
153
|
-
log.info(`Recorded physical_name="${s3BucketName}" on bucket ${bucket.id}`);
|
|
154
|
-
return s3BucketName;
|
|
90
|
+
const coordinate = await (0, default_bucket_1.resolveDefaultBucket)(pgClient, databaseId, storageConfig.scope, ownerId, isPublic, null);
|
|
91
|
+
return (0, storage_module_cache_1.getBucketConfig)(pgClient, storageConfig, databaseId, coordinate.resolvedKey, ownerId || undefined);
|
|
155
92
|
}
|
|
156
93
|
// --- Plugin factory ---
|
|
157
94
|
function createPresignedUrlPlugin(options) {
|
|
@@ -170,6 +107,14 @@ function createPresignedUrlPlugin(options) {
|
|
|
170
107
|
if (!isRootMutation)
|
|
171
108
|
return fields;
|
|
172
109
|
const { graphql: { GraphQLString, GraphQLNonNull, GraphQLInt, GraphQLBoolean, GraphQLObjectType, GraphQLInputObjectType, GraphQLList, }, } = build;
|
|
110
|
+
// The projection document is jsonb-shaped. PostGraphile registers a JSON
|
|
111
|
+
// scalar whenever the schema has a jsonb column, which any storage-equipped
|
|
112
|
+
// database does; if it is absent the payload simply omits the field rather
|
|
113
|
+
// than failing schema build over a field nothing can have asked for yet.
|
|
114
|
+
const jsonType = build.getTypeByName('JSON') ?? null;
|
|
115
|
+
if (!jsonType) {
|
|
116
|
+
log.warn('No JSON scalar in this schema; upload payloads will omit the `file` projection');
|
|
117
|
+
}
|
|
173
118
|
const bucketCodecs = Object.values(build.input.pgRegistry.pgCodecs).filter((codec) => codec.attributes && codec.extensions?.tags?.storageBuckets);
|
|
174
119
|
if (bucketCodecs.length === 0)
|
|
175
120
|
return fields;
|
|
@@ -203,7 +148,8 @@ function createPresignedUrlPlugin(options) {
|
|
|
203
148
|
const InputType = new GraphQLInputObjectType({
|
|
204
149
|
name: `Upload${filesTypeName}Input`,
|
|
205
150
|
fields: {
|
|
206
|
-
bucketKey: { type:
|
|
151
|
+
bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
|
|
152
|
+
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.' },
|
|
207
153
|
...(hasOwnerId
|
|
208
154
|
? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
|
|
209
155
|
: {}),
|
|
@@ -223,6 +169,16 @@ function createPresignedUrlPlugin(options) {
|
|
|
223
169
|
deduplicated: { type: new GraphQLNonNull(GraphQLBoolean), description: 'Whether this file was deduplicated (content already exists)' },
|
|
224
170
|
expiresAt: { type: GraphQLString, description: 'Presigned URL expiry time (null if deduplicated)' },
|
|
225
171
|
previousVersionId: { type: GraphQLString, description: 'ID of the previous version (when using custom keys)' },
|
|
172
|
+
...(jsonType
|
|
173
|
+
? {
|
|
174
|
+
file: {
|
|
175
|
+
type: jsonType,
|
|
176
|
+
description: 'The projection document for the created file: {id, key, bucket_id, mime, size, filename, url?}. ' +
|
|
177
|
+
'Store this verbatim in an image/upload column — its `id` is what keeps the object from being ' +
|
|
178
|
+
'garbage collected while the column still references it.',
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
: {}),
|
|
226
182
|
},
|
|
227
183
|
});
|
|
228
184
|
const capturedFilesCodec = filesCodec;
|
|
@@ -236,6 +192,7 @@ function createPresignedUrlPlugin(options) {
|
|
|
236
192
|
plan(_$mutation, fieldArgs) {
|
|
237
193
|
const $input = fieldArgs.getRaw('input');
|
|
238
194
|
const $bucketKey = (0, grafast_1.access)($input, 'bucketKey');
|
|
195
|
+
const $isPublic = (0, grafast_1.access)($input, 'isPublic');
|
|
239
196
|
const $contentHash = (0, grafast_1.access)($input, 'contentHash');
|
|
240
197
|
const $contentType = (0, grafast_1.access)($input, 'contentType');
|
|
241
198
|
const $size = (0, grafast_1.access)($input, 'size');
|
|
@@ -246,6 +203,7 @@ function createPresignedUrlPlugin(options) {
|
|
|
246
203
|
const $pgSettings = (0, grafast_1.context)().get('pgSettings');
|
|
247
204
|
const $combined = (0, grafast_1.object)({
|
|
248
205
|
bucketKey: $bucketKey,
|
|
206
|
+
isPublic: $isPublic,
|
|
249
207
|
ownerId: $ownerId,
|
|
250
208
|
contentHash: $contentHash,
|
|
251
209
|
contentType: $contentType,
|
|
@@ -268,16 +226,16 @@ function createPresignedUrlPlugin(options) {
|
|
|
268
226
|
const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedFilesCodec, allConfigs);
|
|
269
227
|
if (!storageConfig)
|
|
270
228
|
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
271
|
-
// Bucket
|
|
272
|
-
const bucket = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => (
|
|
229
|
+
// Bucket resolution + read under the request role (RLS-gated visibility).
|
|
230
|
+
const bucket = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => resolveUploadBucket(pgClient, storageConfig, databaseId, vals.bucketKey ?? null, vals.ownerId ?? null, vals.isPublic === true));
|
|
273
231
|
if (!bucket)
|
|
274
232
|
throw new Error('BUCKET_NOT_FOUND');
|
|
275
233
|
// First provision mints + records the coordinate; afterwards the
|
|
276
234
|
// stored physical_name is authoritative and nothing is recomputed.
|
|
277
235
|
const physicalName = bucket.physical_name === null
|
|
278
|
-
? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
|
|
236
|
+
? await (0, physical_bucket_1.provisionAndRecordPhysicalBucket)(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
|
|
279
237
|
: bucket.physical_name;
|
|
280
|
-
const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
|
|
238
|
+
const s3ForDb = (0, physical_bucket_1.resolveS3ForDatabase)(options, storageConfig, physicalName);
|
|
281
239
|
// File row INSERT under the request role (RLS enforced).
|
|
282
240
|
return (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (txClient) => processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
|
|
283
241
|
contentHash: vals.contentHash,
|
|
@@ -309,12 +267,14 @@ function createPresignedUrlPlugin(options) {
|
|
|
309
267
|
deduplicated: { type: new GraphQLNonNull(GraphQLBoolean) },
|
|
310
268
|
expiresAt: { type: GraphQLString },
|
|
311
269
|
previousVersionId: { type: GraphQLString },
|
|
270
|
+
...(jsonType ? { file: { type: jsonType, description: 'The projection document for the created file.' } } : {}),
|
|
312
271
|
},
|
|
313
272
|
});
|
|
314
273
|
const BulkInputType = new GraphQLInputObjectType({
|
|
315
274
|
name: `Upload${filesTypeName}BulkInput`,
|
|
316
275
|
fields: {
|
|
317
|
-
bucketKey: { type:
|
|
276
|
+
bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
|
|
277
|
+
isPublic: { type: GraphQLBoolean, description: 'Which default bucket to resolve when bucketKey is omitted. Ignored when bucketKey is given.' },
|
|
318
278
|
...(hasOwnerId
|
|
319
279
|
? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
|
|
320
280
|
: {}),
|
|
@@ -338,12 +298,14 @@ function createPresignedUrlPlugin(options) {
|
|
|
338
298
|
plan(_$mutation, fieldArgs) {
|
|
339
299
|
const $input = fieldArgs.getRaw('input');
|
|
340
300
|
const $bucketKey = (0, grafast_1.access)($input, 'bucketKey');
|
|
301
|
+
const $isPublic = (0, grafast_1.access)($input, 'isPublic');
|
|
341
302
|
const $ownerId = hasOwnerId ? (0, grafast_1.access)($input, 'ownerId') : (0, grafast_1.lambda)(null, () => null);
|
|
342
303
|
const $files = (0, grafast_1.access)($input, 'files');
|
|
343
304
|
const $withPgClient = (0, grafast_1.context)().get('withPgClient');
|
|
344
305
|
const $pgSettings = (0, grafast_1.context)().get('pgSettings');
|
|
345
306
|
const $combined = (0, grafast_1.object)({
|
|
346
307
|
bucketKey: $bucketKey,
|
|
308
|
+
isPublic: $isPublic,
|
|
347
309
|
ownerId: $ownerId,
|
|
348
310
|
files: $files,
|
|
349
311
|
withPgClient: $withPgClient,
|
|
@@ -362,8 +324,8 @@ function createPresignedUrlPlugin(options) {
|
|
|
362
324
|
const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedFilesCodec, allConfigs);
|
|
363
325
|
if (!storageConfig)
|
|
364
326
|
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
365
|
-
// Bucket
|
|
366
|
-
const bucket = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => (
|
|
327
|
+
// Bucket resolution + read under the request role (RLS-gated visibility).
|
|
328
|
+
const bucket = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => resolveUploadBucket(pgClient, storageConfig, databaseId, vals.bucketKey ?? null, vals.ownerId ?? null, vals.isPublic === true));
|
|
367
329
|
if (!bucket)
|
|
368
330
|
throw new Error('BUCKET_NOT_FOUND');
|
|
369
331
|
// Enforce bulk upload limits
|
|
@@ -378,9 +340,9 @@ function createPresignedUrlPlugin(options) {
|
|
|
378
340
|
// First provision mints + records the coordinate; afterwards the
|
|
379
341
|
// stored physical_name is authoritative and nothing is recomputed.
|
|
380
342
|
const physicalName = bucket.physical_name === null
|
|
381
|
-
? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
|
|
343
|
+
? await (0, physical_bucket_1.provisionAndRecordPhysicalBucket)(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
|
|
382
344
|
: bucket.physical_name;
|
|
383
|
-
const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
|
|
345
|
+
const s3ForDb = (0, physical_bucket_1.resolveS3ForDatabase)(options, storageConfig, physicalName);
|
|
384
346
|
// File row INSERTs under the request role (RLS enforced).
|
|
385
347
|
return (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, async (txClient) => {
|
|
386
348
|
const results = [];
|
|
@@ -509,7 +471,7 @@ function createPresignedUrlPlugin(options) {
|
|
|
509
471
|
log.warn(`Bucket ${fileRow.bucket_id} has no physical_name; skipping S3 delete`);
|
|
510
472
|
return;
|
|
511
473
|
}
|
|
512
|
-
const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
|
|
474
|
+
const s3ForDb = (0, physical_bucket_1.resolveS3ForDatabase)(options, storageConfig, physicalName);
|
|
513
475
|
await (0, s3_signer_1.deleteS3Object)(s3ForDb, fileRow.key);
|
|
514
476
|
log.info(`Sync S3 delete succeeded for key=${fileRow.key}`);
|
|
515
477
|
});
|
|
@@ -548,6 +510,16 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
548
510
|
throw new Error('INVALID_FILENAME');
|
|
549
511
|
}
|
|
550
512
|
}
|
|
513
|
+
// The bytes are not here to be examined — the client PUTs them straight to S3 —
|
|
514
|
+
// so this checks the two claims that *are* here against each other. It is the
|
|
515
|
+
// cheap half of the rule: an upload declaring `image/jpeg` under the name
|
|
516
|
+
// `payload.html` is refused before a row exists, without reading a byte. The
|
|
517
|
+
// bytes themselves are checked on confirmation, before the row leaves
|
|
518
|
+
// `requested`.
|
|
519
|
+
const agreement = (0, mime_bytes_1.checkTypeAgreement)({ filename, declaredMime: contentType });
|
|
520
|
+
if (!agreement.ok) {
|
|
521
|
+
throw new Error(`UPLOAD_TYPE_MISMATCH: ${agreement.violation.message}`);
|
|
522
|
+
}
|
|
551
523
|
// Validate content type against bucket's allowed_mime_types
|
|
552
524
|
if (bucket.allowed_mime_types && bucket.allowed_mime_types.length > 0) {
|
|
553
525
|
const allowed = bucket.allowed_mime_types;
|
|
@@ -568,6 +540,11 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
568
540
|
if (bucket.max_file_size && size > bucket.max_file_size) {
|
|
569
541
|
throw new Error(`FILE_TOO_LARGE: exceeds bucket max of ${bucket.max_file_size} bytes`);
|
|
570
542
|
}
|
|
543
|
+
// The projection document the caller stores in an image/upload column. Built
|
|
544
|
+
// from the same values the files row carries, so the column and the row cannot
|
|
545
|
+
// disagree, and it names the files row by id — which is what stops GC from
|
|
546
|
+
// collecting an object a document still points at.
|
|
547
|
+
const projectFile = (fileId, key) => (0, managed_upload_1.buildFileProjection)({ id: fileId, key, bucketId: bucket.id, mime: contentType, size, filename }, bucket, s3ForDb);
|
|
571
548
|
// Determine S3 key
|
|
572
549
|
let s3Key;
|
|
573
550
|
let isCustomKey = false;
|
|
@@ -608,6 +585,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
608
585
|
deduplicated: true,
|
|
609
586
|
expiresAt: null,
|
|
610
587
|
previousVersionId: null,
|
|
588
|
+
file: projectFile(existing.id, s3Key),
|
|
611
589
|
};
|
|
612
590
|
}
|
|
613
591
|
previousVersionId = existing.id;
|
|
@@ -633,6 +611,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
633
611
|
deduplicated: true,
|
|
634
612
|
expiresAt: null,
|
|
635
613
|
previousVersionId: null,
|
|
614
|
+
file: projectFile(existingFile.id, s3Key),
|
|
636
615
|
};
|
|
637
616
|
}
|
|
638
617
|
}
|
|
@@ -673,6 +652,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
673
652
|
deduplicated: false,
|
|
674
653
|
expiresAt,
|
|
675
654
|
previousVersionId,
|
|
655
|
+
file: projectFile(fileId, s3Key),
|
|
676
656
|
};
|
|
677
657
|
}
|
|
678
658
|
exports.PresignedUrlPlugin = createPresignedUrlPlugin;
|
package/s3-signer.d.ts
CHANGED
|
@@ -36,6 +36,35 @@ 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>;
|
|
52
|
+
/**
|
|
53
|
+
* Read the leading bytes of an object.
|
|
54
|
+
*
|
|
55
|
+
* A ranged GET, because the only reason to touch bytes the client uploaded
|
|
56
|
+
* directly is to see what they actually are: a magic-byte signature lives in the
|
|
57
|
+
* first few dozen bytes, so validating a 2GB video costs the same as validating
|
|
58
|
+
* an icon.
|
|
59
|
+
*
|
|
60
|
+
* Returns null when the object is not there — the presigned lane's ordinary
|
|
61
|
+
* "client never PUT it" case, which is an expiry rather than a failure.
|
|
62
|
+
*
|
|
63
|
+
* @param s3Config - S3 client and bucket configuration
|
|
64
|
+
* @param key - S3 object key
|
|
65
|
+
* @param byteCount - How many leading bytes to read
|
|
66
|
+
*/
|
|
67
|
+
export declare function readObjectPrefix(s3Config: S3Config, key: string, byteCount: number): Promise<Buffer | null>;
|
|
39
68
|
/**
|
|
40
69
|
* Check if an object exists in S3 and optionally verify its content-type.
|
|
41
70
|
*
|
package/s3-signer.js
CHANGED
|
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.generatePresignedPutUrl = generatePresignedPutUrl;
|
|
4
4
|
exports.generatePresignedGetUrl = generatePresignedGetUrl;
|
|
5
5
|
exports.deleteS3Object = deleteS3Object;
|
|
6
|
+
exports.copyS3Object = copyS3Object;
|
|
7
|
+
exports.readObjectPrefix = readObjectPrefix;
|
|
6
8
|
exports.headObject = headObject;
|
|
7
9
|
const client_s3_1 = require("@aws-sdk/client-s3");
|
|
8
10
|
const s3_request_presigner_1 = require("@aws-sdk/s3-request-presigner");
|
|
@@ -74,6 +76,66 @@ async function deleteS3Object(s3Config, key) {
|
|
|
74
76
|
}));
|
|
75
77
|
log.debug(`Deleted S3 object: bucket=${s3Config.bucket}, key=${key}`);
|
|
76
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Copy an object within the same physical bucket, preserving its content type.
|
|
81
|
+
*
|
|
82
|
+
* Used to promote a staged upload to its content-addressed key: the bytes are
|
|
83
|
+
* hashed as they stream in, so the final key is only known once the stream ends.
|
|
84
|
+
* Server-side copy keeps that promotion off the application's wire.
|
|
85
|
+
*
|
|
86
|
+
* @param s3Config - S3 client and bucket configuration
|
|
87
|
+
* @param sourceKey - The staged key the bytes were written to
|
|
88
|
+
* @param destinationKey - The final key (the content hash)
|
|
89
|
+
* @param contentType - MIME type to record on the destination object
|
|
90
|
+
*/
|
|
91
|
+
async function copyS3Object(s3Config, sourceKey, destinationKey, contentType) {
|
|
92
|
+
await s3Config.client.send(new client_s3_1.CopyObjectCommand({
|
|
93
|
+
Bucket: s3Config.bucket,
|
|
94
|
+
Key: destinationKey,
|
|
95
|
+
CopySource: `${s3Config.bucket}/${sourceKey}`,
|
|
96
|
+
ContentType: contentType,
|
|
97
|
+
MetadataDirective: 'REPLACE',
|
|
98
|
+
}));
|
|
99
|
+
log.debug(`Copied S3 object: bucket=${s3Config.bucket}, ${sourceKey} → ${destinationKey}`);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Read the leading bytes of an object.
|
|
103
|
+
*
|
|
104
|
+
* A ranged GET, because the only reason to touch bytes the client uploaded
|
|
105
|
+
* directly is to see what they actually are: a magic-byte signature lives in the
|
|
106
|
+
* first few dozen bytes, so validating a 2GB video costs the same as validating
|
|
107
|
+
* an icon.
|
|
108
|
+
*
|
|
109
|
+
* Returns null when the object is not there — the presigned lane's ordinary
|
|
110
|
+
* "client never PUT it" case, which is an expiry rather than a failure.
|
|
111
|
+
*
|
|
112
|
+
* @param s3Config - S3 client and bucket configuration
|
|
113
|
+
* @param key - S3 object key
|
|
114
|
+
* @param byteCount - How many leading bytes to read
|
|
115
|
+
*/
|
|
116
|
+
async function readObjectPrefix(s3Config, key, byteCount) {
|
|
117
|
+
try {
|
|
118
|
+
const response = await s3Config.client.send(new client_s3_1.GetObjectCommand({
|
|
119
|
+
Bucket: s3Config.bucket,
|
|
120
|
+
Key: key,
|
|
121
|
+
Range: `bytes=0-${byteCount - 1}`,
|
|
122
|
+
}));
|
|
123
|
+
const body = response.Body;
|
|
124
|
+
if (!body)
|
|
125
|
+
return Buffer.alloc(0);
|
|
126
|
+
const chunks = [];
|
|
127
|
+
for await (const chunk of body) {
|
|
128
|
+
chunks.push(Buffer.from(chunk));
|
|
129
|
+
}
|
|
130
|
+
return Buffer.concat(chunks);
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
if (e.name === 'NoSuchKey' || e.name === 'NotFound' || e.$metadata?.httpStatusCode === 404) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
throw e;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
77
139
|
/**
|
|
78
140
|
* Check if an object exists in S3 and optionally verify its content-type.
|
|
79
141
|
*
|
package/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.
|