graphile-presigned-url-plugin 0.7.0 → 0.9.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/plugin.js CHANGED
@@ -6,13 +6,9 @@
6
6
  *
7
7
  * 1. `requestUploadUrl` mutation — generates a presigned PUT URL for direct
8
8
  * client-to-S3 upload. Checks bucket access via RLS, deduplicates by
9
- * content hash, tracks the request in upload_requests.
9
+ * content hash via UNIQUE(bucket_id, key) constraint.
10
10
  *
11
- * 2. `confirmUpload` mutation confirms a file was uploaded to S3, verifies
12
- * the object exists with correct content-type, transitions file status
13
- * from 'pending' to 'ready'.
14
- *
15
- * 3. `downloadUrl` computed field on File types — generates presigned GET URLs
11
+ * 2. `downloadUrl` computed field on File types generates presigned GET URLs
16
12
  * for private files, returns public URL prefix + key for public files.
17
13
  *
18
14
  * Uses the extendSchema + grafast plan pattern (same as PublicKeySignature).
@@ -30,7 +26,9 @@ const log = new logger_1.Logger('graphile-presigned-url:plugin');
30
26
  const MAX_CONTENT_HASH_LENGTH = 128;
31
27
  const MAX_CONTENT_TYPE_LENGTH = 255;
32
28
  const MAX_BUCKET_KEY_LENGTH = 255;
29
+ const MAX_CUSTOM_KEY_LENGTH = 1024;
33
30
  const SHA256_HEX_REGEX = /^[a-f0-9]{64}$/;
31
+ const CUSTOM_KEY_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.\-\/]*$/;
34
32
  // --- Helpers ---
35
33
  /**
36
34
  * Validate a SHA-256 hex string.
@@ -39,12 +37,46 @@ function isValidSha256(hash) {
39
37
  return SHA256_HEX_REGEX.test(hash);
40
38
  }
41
39
  /**
42
- * Build the S3 key from content hash and content type extension.
40
+ * Build the S3 key from content hash.
43
41
  * Format: {contentHash} (flat namespace, content-addressed)
44
42
  */
45
43
  function buildS3Key(contentHash) {
46
44
  return contentHash;
47
45
  }
46
+ /**
47
+ * Validate a custom S3 key.
48
+ * Must be 1-1024 chars, no path traversal, no leading slash, no null bytes.
49
+ */
50
+ function validateCustomKey(key) {
51
+ if (key.length === 0 || key.length > MAX_CUSTOM_KEY_LENGTH) {
52
+ return 'INVALID_KEY_LENGTH: must be 1-1024 characters';
53
+ }
54
+ if (key.includes('..')) {
55
+ return 'INVALID_KEY: path traversal (..) not allowed';
56
+ }
57
+ if (key.startsWith('/')) {
58
+ return 'INVALID_KEY: leading slash not allowed';
59
+ }
60
+ if (key.includes('\0')) {
61
+ return 'INVALID_KEY: null bytes not allowed';
62
+ }
63
+ if (!CUSTOM_KEY_REGEX.test(key)) {
64
+ return 'INVALID_KEY: must start with alphanumeric and contain only alphanumeric, dots, hyphens, underscores, and slashes';
65
+ }
66
+ return null;
67
+ }
68
+ /**
69
+ * Derive an ltree path from a custom S3 key's directory portion.
70
+ * e.g., "reports/2024/Q1/revenue.pdf" → "reports.2024.Q1"
71
+ * Returns null if the key has no directory component.
72
+ */
73
+ function derivePathFromKey(key) {
74
+ const lastSlash = key.lastIndexOf('/');
75
+ if (lastSlash <= 0)
76
+ return null;
77
+ const dir = key.substring(0, lastSlash);
78
+ return dir.replace(/\//g, '.');
79
+ }
48
80
  /**
49
81
  * Resolve the database_id from the JWT context.
50
82
  * The server middleware sets jwt.claims.database_id, which is accessible
@@ -136,6 +168,14 @@ function createPresignedUrlPlugin(options) {
136
168
  size: Int!
137
169
  """Original filename (optional, for display and Content-Disposition)"""
138
170
  filename: String
171
+ """
172
+ Custom S3 key (e.g., "reports/2024/Q1.pdf").
173
+ Only allowed when the bucket has allow_custom_keys=true.
174
+ When omitted, key defaults to contentHash (content-addressed dedup).
175
+ When provided, the file is stored at this key.
176
+ Re-uploading to an existing key auto-creates a new version.
177
+ """
178
+ key: String
139
179
  }
140
180
 
141
181
  type RequestUploadUrlPayload {
@@ -149,22 +189,52 @@ function createPresignedUrlPlugin(options) {
149
189
  deduplicated: Boolean!
150
190
  """Presigned URL expiry time (null if deduplicated)"""
151
191
  expiresAt: Datetime
152
- """File status 'pending' for fresh uploads, 'ready' or 'processed' for deduplicated files. Clients can use this to know immediately whether the file is usable."""
153
- status: String!
192
+ """ID of the previous version (set when re-uploading to an existing custom key)"""
193
+ previousVersionId: UUID
154
194
  }
155
195
 
156
- input ConfirmUploadInput {
157
- """The file ID returned by requestUploadUrl"""
158
- fileId: UUID!
196
+ input BulkUploadFileInput {
197
+ """SHA-256 content hash computed by the client (hex-encoded, 64 chars)"""
198
+ contentHash: String!
199
+ """MIME type of the file (e.g., "image/png")"""
200
+ contentType: String!
201
+ """File size in bytes"""
202
+ size: Int!
203
+ """Original filename (optional, for display and Content-Disposition)"""
204
+ filename: String
205
+ """Custom S3 key (only when bucket has allow_custom_keys=true)"""
206
+ key: String
159
207
  }
160
208
 
161
- type ConfirmUploadPayload {
162
- """The confirmed file ID"""
209
+ input RequestBulkUploadUrlsInput {
210
+ """Bucket key (e.g., "public", "private")"""
211
+ bucketKey: String!
212
+ """Owner entity ID for entity-scoped uploads"""
213
+ ownerId: UUID
214
+ """Array of files to upload"""
215
+ files: [BulkUploadFileInput!]!
216
+ }
217
+
218
+ type BulkUploadFilePayload {
219
+ """Presigned PUT URL (null if file was deduplicated)"""
220
+ uploadUrl: String
221
+ """The file ID"""
163
222
  fileId: UUID!
164
- """New file status"""
165
- status: String!
166
- """Whether confirmation succeeded"""
167
- success: Boolean!
223
+ """The S3 object key"""
224
+ key: String!
225
+ """Whether this file was deduplicated"""
226
+ deduplicated: Boolean!
227
+ """Presigned URL expiry time (null if deduplicated)"""
228
+ expiresAt: Datetime
229
+ """ID of the previous version (set when re-uploading to an existing custom key)"""
230
+ previousVersionId: UUID
231
+ """Index of this file in the input array (for client correlation)"""
232
+ index: Int!
233
+ }
234
+
235
+ type RequestBulkUploadUrlsPayload {
236
+ """Array of results, one per input file"""
237
+ files: [BulkUploadFilePayload!]!
168
238
  }
169
239
 
170
240
  extend type Mutation {
@@ -179,13 +249,13 @@ function createPresignedUrlPlugin(options) {
179
249
  ): RequestUploadUrlPayload
180
250
 
181
251
  """
182
- Confirm that a file has been uploaded to S3.
183
- Verifies the object exists in S3, checks content-type,
184
- and transitions the file status from 'pending' to 'ready'.
252
+ Request presigned URLs for uploading multiple files in a single batch.
253
+ Subject to per-storage-module limits (max_bulk_files, max_bulk_total_size).
254
+ Each file is processed independently some may dedup while others get fresh URLs.
185
255
  """
186
- confirmUpload(
187
- input: ConfirmUploadInput!
188
- ): ConfirmUploadPayload
256
+ requestBulkUploadUrls(
257
+ input: RequestBulkUploadUrlsInput!
258
+ ): RequestBulkUploadUrlsPayload
189
259
  }
190
260
  `,
191
261
  plans: {
@@ -200,28 +270,33 @@ function createPresignedUrlPlugin(options) {
200
270
  pgSettings: $pgSettings,
201
271
  });
202
272
  return (0, grafast_1.lambda)($combined, async ({ input, withPgClient, pgSettings }) => {
203
- // --- Input validation ---
204
- const { bucketKey, ownerId, contentHash, contentType, size, filename } = input;
273
+ const result = await processUpload(options, input, withPgClient, pgSettings);
274
+ return result;
275
+ });
276
+ },
277
+ requestBulkUploadUrls(_$mutation, fieldArgs) {
278
+ const $input = fieldArgs.getRaw('input');
279
+ const $withPgClient = (0, grafast_1.context)().get('withPgClient');
280
+ const $pgSettings = (0, grafast_1.context)().get('pgSettings');
281
+ const $combined = (0, grafast_1.object)({
282
+ input: $input,
283
+ withPgClient: $withPgClient,
284
+ pgSettings: $pgSettings,
285
+ });
286
+ return (0, grafast_1.lambda)($combined, async ({ input, withPgClient, pgSettings }) => {
287
+ const { bucketKey, ownerId, files } = input;
205
288
  if (!bucketKey || typeof bucketKey !== 'string' || bucketKey.length > MAX_BUCKET_KEY_LENGTH) {
206
289
  throw new Error('INVALID_BUCKET_KEY');
207
290
  }
208
- if (!contentHash || typeof contentHash !== 'string' || contentHash.length > MAX_CONTENT_HASH_LENGTH) {
209
- throw new Error('INVALID_CONTENT_HASH');
210
- }
211
- if (!isValidSha256(contentHash)) {
212
- throw new Error('INVALID_CONTENT_HASH_FORMAT: must be a 64-char lowercase hex SHA-256');
213
- }
214
- if (!contentType || typeof contentType !== 'string' || contentType.length > MAX_CONTENT_TYPE_LENGTH) {
215
- throw new Error('INVALID_CONTENT_TYPE');
291
+ if (!Array.isArray(files) || files.length === 0) {
292
+ throw new Error('INVALID_FILES: must provide at least one file');
216
293
  }
217
294
  return withPgClient(pgSettings, async (pgClient) => {
218
295
  return pgClient.withTransaction(async (txClient) => {
219
- // --- Resolve storage module config (all limits come from here) ---
220
296
  const databaseId = await resolveDatabaseId(txClient);
221
297
  if (!databaseId) {
222
298
  throw new Error('DATABASE_NOT_FOUND');
223
299
  }
224
- // --- Resolve storage module (app-level or entity-scoped) ---
225
300
  const storageConfig = ownerId
226
301
  ? await (0, storage_module_cache_1.getStorageModuleConfigForOwner)(txClient, databaseId, ownerId)
227
302
  : await (0, storage_module_cache_1.getStorageModuleConfig)(txClient, databaseId);
@@ -230,198 +305,34 @@ function createPresignedUrlPlugin(options) {
230
305
  ? 'STORAGE_MODULE_NOT_FOUND_FOR_OWNER: no storage module found for the given ownerId'
231
306
  : 'STORAGE_MODULE_NOT_PROVISIONED');
232
307
  }
233
- // --- Validate size against storage module default (bucket override checked below) ---
234
- if (typeof size !== 'number' || size <= 0 || size > storageConfig.defaultMaxFileSize) {
235
- throw new Error(`INVALID_FILE_SIZE: must be between 1 and ${storageConfig.defaultMaxFileSize} bytes`);
308
+ // --- Validate bulk limits ---
309
+ if (files.length > storageConfig.maxBulkFiles) {
310
+ throw new Error(`BULK_LIMIT_EXCEEDED: max ${storageConfig.maxBulkFiles} files per batch`);
236
311
  }
237
- if (filename !== undefined && filename !== null) {
238
- if (typeof filename !== 'string' || filename.length > storageConfig.maxFilenameLength) {
239
- throw new Error('INVALID_FILENAME');
240
- }
312
+ const totalSize = files.reduce((sum, f) => sum + (f.size || 0), 0);
313
+ if (totalSize > storageConfig.maxBulkTotalSize) {
314
+ throw new Error(`BULK_SIZE_EXCEEDED: total size ${totalSize} exceeds max ${storageConfig.maxBulkTotalSize} bytes`);
241
315
  }
242
- // --- Look up the bucket (cached; first miss queries via RLS) ---
243
316
  const bucket = await (0, storage_module_cache_1.getBucketConfig)(txClient, storageConfig, databaseId, bucketKey, ownerId);
244
317
  if (!bucket) {
245
318
  throw new Error('BUCKET_NOT_FOUND');
246
319
  }
247
- // --- Validate content type against bucket's allowed_mime_types ---
248
- if (bucket.allowed_mime_types && bucket.allowed_mime_types.length > 0) {
249
- const allowed = bucket.allowed_mime_types;
250
- const isAllowed = allowed.some((pattern) => {
251
- if (pattern === '*/*')
252
- return true;
253
- if (pattern.endsWith('/*')) {
254
- const prefix = pattern.slice(0, -1);
255
- return contentType.startsWith(prefix);
256
- }
257
- return contentType === pattern;
258
- });
259
- if (!isAllowed) {
260
- throw new Error(`CONTENT_TYPE_NOT_ALLOWED: ${contentType} not in bucket allowed types`);
261
- }
262
- }
263
- // --- Validate size against bucket's max_file_size ---
264
- if (bucket.max_file_size && size > bucket.max_file_size) {
265
- throw new Error(`FILE_TOO_LARGE: exceeds bucket max of ${bucket.max_file_size} bytes`);
266
- }
267
- const s3Key = buildS3Key(contentHash);
268
- // --- Dedup check: look for existing file with same key (content hash) in this bucket ---
269
- const dedupResult = await txClient.query({
270
- text: `SELECT id, status
271
- FROM ${storageConfig.filesQualifiedName}
272
- WHERE key = $1
273
- AND bucket_id = $2
274
- AND status IN ('ready', 'processed')
275
- LIMIT 1`,
276
- values: [s3Key, bucket.id],
277
- });
278
- if (dedupResult.rows.length > 0) {
279
- const existingFile = dedupResult.rows[0];
280
- log.info(`Dedup hit: file ${existingFile.id} for hash ${contentHash}`);
281
- // Track the dedup request
282
- await txClient.query({
283
- text: `INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
284
- (file_id, bucket_id, key, content_type, content_hash, status, expires_at)
285
- VALUES ($1, $2, $3, $4, $5, 'confirmed', NOW())`,
286
- values: [existingFile.id, bucket.id, s3Key, contentType, contentHash],
287
- });
288
- return {
289
- uploadUrl: null,
290
- fileId: existingFile.id,
291
- key: s3Key,
292
- deduplicated: true,
293
- expiresAt: null,
294
- status: existingFile.status,
295
- };
296
- }
297
- // --- Create file record (status=pending) ---
298
- // For app-level storage (no owner_id column), omit owner_id from the INSERT.
299
- const hasOwnerColumn = storageConfig.membershipType !== null;
300
- const fileResult = await txClient.query({
301
- text: hasOwnerColumn
302
- ? `INSERT INTO ${storageConfig.filesQualifiedName}
303
- (bucket_id, key, mime_type, size, filename, owner_id, is_public, status)
304
- VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending')
305
- RETURNING id`
306
- : `INSERT INTO ${storageConfig.filesQualifiedName}
307
- (bucket_id, key, mime_type, size, filename, is_public, status)
308
- VALUES ($1, $2, $3, $4, $5, $6, 'pending')
309
- RETURNING id`,
310
- values: hasOwnerColumn
311
- ? [
312
- bucket.id,
313
- s3Key,
314
- contentType,
315
- size,
316
- filename || null,
317
- bucket.owner_id,
318
- bucket.is_public,
319
- ]
320
- : [
321
- bucket.id,
322
- s3Key,
323
- contentType,
324
- size,
325
- filename || null,
326
- bucket.is_public,
327
- ],
328
- });
329
- const fileId = fileResult.rows[0].id;
330
- // --- Ensure the S3 bucket exists (lazy provisioning) ---
320
+ // --- Ensure S3 bucket exists once for the batch ---
331
321
  const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
332
322
  await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
333
- // --- Generate presigned PUT URL (per-database bucket) ---
334
- const uploadUrl = await (0, s3_signer_1.generatePresignedPutUrl)(s3ForDb, s3Key, contentType, size, storageConfig.uploadUrlExpirySeconds);
335
- const expiresAt = new Date(Date.now() + storageConfig.uploadUrlExpirySeconds * 1000).toISOString();
336
- // --- Track the upload request ---
337
- await txClient.query({
338
- text: `INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
339
- (file_id, bucket_id, key, content_type, content_hash, status, expires_at)
340
- VALUES ($1, $2, $3, $4, $5, 'issued', $6)`,
341
- values: [fileId, bucket.id, s3Key, contentType, contentHash, expiresAt],
342
- });
343
- return {
344
- uploadUrl,
345
- fileId,
346
- key: s3Key,
347
- deduplicated: false,
348
- expiresAt,
349
- status: 'pending',
350
- };
351
- });
352
- });
353
- });
354
- },
355
- confirmUpload(_$mutation, fieldArgs) {
356
- const $input = fieldArgs.getRaw('input');
357
- const $withPgClient = (0, grafast_1.context)().get('withPgClient');
358
- const $pgSettings = (0, grafast_1.context)().get('pgSettings');
359
- const $combined = (0, grafast_1.object)({
360
- input: $input,
361
- withPgClient: $withPgClient,
362
- pgSettings: $pgSettings,
363
- });
364
- return (0, grafast_1.lambda)($combined, async ({ input, withPgClient, pgSettings }) => {
365
- const { fileId } = input;
366
- if (!fileId || typeof fileId !== 'string') {
367
- throw new Error('INVALID_FILE_ID');
368
- }
369
- return withPgClient(pgSettings, async (pgClient) => {
370
- return pgClient.withTransaction(async (txClient) => {
371
- // --- Resolve storage module by file ID (probes all file tables) ---
372
- const databaseId = await resolveDatabaseId(txClient);
373
- if (!databaseId) {
374
- throw new Error('DATABASE_NOT_FOUND');
375
- }
376
- const resolved = await (0, storage_module_cache_1.resolveStorageModuleByFileId)(txClient, databaseId, fileId);
377
- if (!resolved) {
378
- throw new Error('FILE_NOT_FOUND');
379
- }
380
- const { storageConfig, file } = resolved;
381
- if (file.status !== 'pending') {
382
- // File is already confirmed or processed — idempotent success
383
- return {
384
- fileId: file.id,
385
- status: file.status,
386
- success: true,
323
+ // --- Process each file ---
324
+ const results = [];
325
+ for (let i = 0; i < files.length; i++) {
326
+ const fileInput = files[i];
327
+ const singleInput = {
328
+ ...fileInput,
329
+ bucketKey,
330
+ ownerId,
387
331
  };
332
+ const result = await processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, singleInput);
333
+ results.push({ ...result, index: i });
388
334
  }
389
- // --- Verify file exists in S3 (per-database bucket) ---
390
- const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
391
- const s3Head = await (0, s3_signer_1.headObject)(s3ForDb, file.key, file.mime_type);
392
- if (!s3Head) {
393
- throw new Error('FILE_NOT_IN_S3: the file has not been uploaded yet');
394
- }
395
- // --- Content-type verification ---
396
- if (s3Head.contentType && s3Head.contentType !== file.mime_type) {
397
- // Mark upload_request as rejected
398
- await txClient.query({
399
- text: `UPDATE ${storageConfig.uploadRequestsQualifiedName}
400
- SET status = 'rejected'
401
- WHERE file_id = $1 AND status = 'issued'`,
402
- values: [fileId],
403
- });
404
- throw new Error(`CONTENT_TYPE_MISMATCH: expected ${file.mime_type}, got ${s3Head.contentType}`);
405
- }
406
- // --- Transition file to 'ready' ---
407
- await txClient.query({
408
- text: `UPDATE ${storageConfig.filesQualifiedName}
409
- SET status = 'ready'
410
- WHERE id = $1`,
411
- values: [fileId],
412
- });
413
- // --- Update upload_request to 'confirmed' ---
414
- await txClient.query({
415
- text: `UPDATE ${storageConfig.uploadRequestsQualifiedName}
416
- SET status = 'confirmed', confirmed_at = NOW()
417
- WHERE file_id = $1 AND status = 'issued'`,
418
- values: [fileId],
419
- });
420
- return {
421
- fileId: file.id,
422
- status: 'ready',
423
- success: true,
424
- };
335
+ return { files: results };
425
336
  });
426
337
  });
427
338
  });
@@ -430,5 +341,212 @@ function createPresignedUrlPlugin(options) {
430
341
  },
431
342
  }));
432
343
  }
344
+ // --- Shared upload logic ---
345
+ /**
346
+ * Process a single upload request (used by both requestUploadUrl and requestBulkUploadUrls).
347
+ */
348
+ async function processUpload(options, input, withPgClient, pgSettings) {
349
+ const { bucketKey, ownerId, contentHash, contentType, size, filename, key: customKey } = input;
350
+ if (!bucketKey || typeof bucketKey !== 'string' || bucketKey.length > MAX_BUCKET_KEY_LENGTH) {
351
+ throw new Error('INVALID_BUCKET_KEY');
352
+ }
353
+ if (!contentHash || typeof contentHash !== 'string' || contentHash.length > MAX_CONTENT_HASH_LENGTH) {
354
+ throw new Error('INVALID_CONTENT_HASH');
355
+ }
356
+ if (!isValidSha256(contentHash)) {
357
+ throw new Error('INVALID_CONTENT_HASH_FORMAT: must be a 64-char lowercase hex SHA-256');
358
+ }
359
+ if (!contentType || typeof contentType !== 'string' || contentType.length > MAX_CONTENT_TYPE_LENGTH) {
360
+ throw new Error('INVALID_CONTENT_TYPE');
361
+ }
362
+ return withPgClient(pgSettings, async (pgClient) => {
363
+ return pgClient.withTransaction(async (txClient) => {
364
+ const databaseId = await resolveDatabaseId(txClient);
365
+ if (!databaseId) {
366
+ throw new Error('DATABASE_NOT_FOUND');
367
+ }
368
+ const storageConfig = ownerId
369
+ ? await (0, storage_module_cache_1.getStorageModuleConfigForOwner)(txClient, databaseId, ownerId)
370
+ : await (0, storage_module_cache_1.getStorageModuleConfig)(txClient, databaseId);
371
+ if (!storageConfig) {
372
+ throw new Error(ownerId
373
+ ? 'STORAGE_MODULE_NOT_FOUND_FOR_OWNER: no storage module found for the given ownerId'
374
+ : 'STORAGE_MODULE_NOT_PROVISIONED');
375
+ }
376
+ if (typeof size !== 'number' || size <= 0 || size > storageConfig.defaultMaxFileSize) {
377
+ throw new Error(`INVALID_FILE_SIZE: must be between 1 and ${storageConfig.defaultMaxFileSize} bytes`);
378
+ }
379
+ if (filename !== undefined && filename !== null) {
380
+ if (typeof filename !== 'string' || filename.length > storageConfig.maxFilenameLength) {
381
+ throw new Error('INVALID_FILENAME');
382
+ }
383
+ }
384
+ const bucket = await (0, storage_module_cache_1.getBucketConfig)(txClient, storageConfig, databaseId, bucketKey, ownerId);
385
+ if (!bucket) {
386
+ throw new Error('BUCKET_NOT_FOUND');
387
+ }
388
+ const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
389
+ await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
390
+ return processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, input);
391
+ });
392
+ });
393
+ }
394
+ /**
395
+ * Process a single file upload within an already-resolved context.
396
+ * Handles dedup, custom keys, versioning, and auto-path derivation.
397
+ */
398
+ async function processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, input) {
399
+ const { contentHash, contentType, size, filename, key: customKey } = input;
400
+ // --- Validate inputs ---
401
+ if (!contentHash || !isValidSha256(contentHash)) {
402
+ throw new Error('INVALID_CONTENT_HASH_FORMAT: must be a 64-char lowercase hex SHA-256');
403
+ }
404
+ if (!contentType || typeof contentType !== 'string' || contentType.length > MAX_CONTENT_TYPE_LENGTH) {
405
+ throw new Error('INVALID_CONTENT_TYPE');
406
+ }
407
+ if (typeof size !== 'number' || size <= 0 || size > storageConfig.defaultMaxFileSize) {
408
+ throw new Error(`INVALID_FILE_SIZE: must be between 1 and ${storageConfig.defaultMaxFileSize} bytes`);
409
+ }
410
+ if (filename !== undefined && filename !== null) {
411
+ if (typeof filename !== 'string' || filename.length > storageConfig.maxFilenameLength) {
412
+ throw new Error('INVALID_FILENAME');
413
+ }
414
+ }
415
+ // --- Validate content type against bucket's allowed_mime_types ---
416
+ if (bucket.allowed_mime_types && bucket.allowed_mime_types.length > 0) {
417
+ const allowed = bucket.allowed_mime_types;
418
+ const isAllowed = allowed.some((pattern) => {
419
+ if (pattern === '*/*')
420
+ return true;
421
+ if (pattern.endsWith('/*')) {
422
+ const prefix = pattern.slice(0, -1);
423
+ return contentType.startsWith(prefix);
424
+ }
425
+ return contentType === pattern;
426
+ });
427
+ if (!isAllowed) {
428
+ throw new Error(`CONTENT_TYPE_NOT_ALLOWED: ${contentType} not in bucket allowed types`);
429
+ }
430
+ }
431
+ // --- Validate size against bucket's max_file_size ---
432
+ if (bucket.max_file_size && size > bucket.max_file_size) {
433
+ throw new Error(`FILE_TOO_LARGE: exceeds bucket max of ${bucket.max_file_size} bytes`);
434
+ }
435
+ // --- Determine S3 key ---
436
+ let s3Key;
437
+ let isCustomKey = false;
438
+ if (customKey) {
439
+ if (!bucket.allow_custom_keys) {
440
+ throw new Error('CUSTOM_KEY_NOT_ALLOWED: bucket does not allow custom keys');
441
+ }
442
+ const keyError = validateCustomKey(customKey);
443
+ if (keyError) {
444
+ throw new Error(keyError);
445
+ }
446
+ s3Key = customKey;
447
+ isCustomKey = true;
448
+ }
449
+ else {
450
+ s3Key = buildS3Key(contentHash);
451
+ }
452
+ // --- Dedup / versioning check ---
453
+ let previousVersionId = null;
454
+ if (isCustomKey) {
455
+ // Custom key mode: check if a file with this key already exists in this bucket.
456
+ // If so, auto-version by linking via previous_version_id.
457
+ const existingResult = await txClient.query({
458
+ text: `SELECT id, content_hash
459
+ FROM ${storageConfig.filesQualifiedName}
460
+ WHERE key = $1
461
+ AND bucket_id = $2
462
+ ORDER BY created_at DESC
463
+ LIMIT 1`,
464
+ values: [s3Key, bucket.id],
465
+ });
466
+ if (existingResult.rows.length > 0) {
467
+ const existing = existingResult.rows[0];
468
+ // Same content hash = true dedup (no new upload needed)
469
+ if (existing.content_hash === contentHash) {
470
+ log.info(`Dedup hit (custom key): file ${existing.id} for key ${s3Key}`);
471
+ return {
472
+ uploadUrl: null,
473
+ fileId: existing.id,
474
+ key: s3Key,
475
+ deduplicated: true,
476
+ expiresAt: null,
477
+ previousVersionId: null,
478
+ };
479
+ }
480
+ // Different content = new version
481
+ previousVersionId = existing.id;
482
+ log.info(`Versioning: new version of key ${s3Key}, previous=${previousVersionId}`);
483
+ }
484
+ }
485
+ else {
486
+ // Hash-based mode: dedup by content_hash in this bucket
487
+ const dedupResult = await txClient.query({
488
+ text: `SELECT id
489
+ FROM ${storageConfig.filesQualifiedName}
490
+ WHERE content_hash = $1
491
+ AND bucket_id = $2
492
+ LIMIT 1`,
493
+ values: [contentHash, bucket.id],
494
+ });
495
+ if (dedupResult.rows.length > 0) {
496
+ const existingFile = dedupResult.rows[0];
497
+ log.info(`Dedup hit: file ${existingFile.id} for hash ${contentHash}`);
498
+ return {
499
+ uploadUrl: null,
500
+ fileId: existingFile.id,
501
+ key: s3Key,
502
+ deduplicated: true,
503
+ expiresAt: null,
504
+ previousVersionId: null,
505
+ };
506
+ }
507
+ }
508
+ // --- Auto-derive ltree path from custom key directory (only when has_path_shares) ---
509
+ const derivedPath = isCustomKey && storageConfig.hasPathShares ? derivePathFromKey(s3Key) : null;
510
+ // --- Create file record ---
511
+ const hasOwnerColumn = storageConfig.membershipType !== null;
512
+ const columns = ['bucket_id', 'key', 'content_hash', 'mime_type', 'size', 'filename', 'is_public'];
513
+ const values = [bucket.id, s3Key, contentHash, contentType, size, filename || null, bucket.is_public];
514
+ let paramIdx = values.length;
515
+ if (hasOwnerColumn) {
516
+ columns.push('owner_id');
517
+ values.push(bucket.owner_id);
518
+ paramIdx = values.length;
519
+ }
520
+ if (previousVersionId) {
521
+ columns.push('previous_version_id');
522
+ values.push(previousVersionId);
523
+ paramIdx = values.length;
524
+ }
525
+ if (derivedPath) {
526
+ columns.push('path');
527
+ values.push(derivedPath);
528
+ paramIdx = values.length;
529
+ }
530
+ const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
531
+ const fileResult = await txClient.query({
532
+ text: `INSERT INTO ${storageConfig.filesQualifiedName}
533
+ (${columns.join(', ')})
534
+ VALUES (${placeholders})
535
+ RETURNING id`,
536
+ values,
537
+ });
538
+ const fileId = fileResult.rows[0].id;
539
+ // --- Generate presigned PUT URL ---
540
+ const uploadUrl = await (0, s3_signer_1.generatePresignedPutUrl)(s3ForDb, s3Key, contentType, size, storageConfig.uploadUrlExpirySeconds);
541
+ const expiresAt = new Date(Date.now() + storageConfig.uploadUrlExpirySeconds * 1000).toISOString();
542
+ return {
543
+ uploadUrl,
544
+ fileId,
545
+ key: s3Key,
546
+ deduplicated: false,
547
+ expiresAt,
548
+ previousVersionId,
549
+ };
550
+ }
433
551
  exports.PresignedUrlPlugin = createPresignedUrlPlugin;
434
552
  exports.default = exports.PresignedUrlPlugin;
package/preset.d.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * PostGraphile v5 Presigned URL Preset
3
3
  *
4
4
  * Provides a convenient preset for including presigned URL upload support
5
- * in PostGraphile. Combines the main mutation plugin (requestUploadUrl,
6
- * confirmUpload) with the downloadUrl computed field plugin.
5
+ * in PostGraphile. Combines the main mutation plugin (requestUploadUrl)
6
+ * with the downloadUrl computed field plugin.
7
7
  */
8
8
  import type { GraphileConfig } from 'graphile-config';
9
9
  import type { PresignedUrlPluginOptions } from './types';