Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
generatePresignedPutUrl,
generatePresignedGetUrl,
headObject,
deleteS3Object,
} from '../src/s3-signer';
import type { S3Config } from '../src/types';

Expand Down Expand Up @@ -229,6 +230,41 @@ describe('s3-signer integration (MinIO)', () => {
});
});

describe('deleteS3Object', () => {
it('should delete an existing object from S3', async () => {
const key = 'test-delete-' + Date.now() + '.txt';
const content = 'file to delete';
const contentType = 'text/plain';

// Upload a file first
const putUrl = await generatePresignedPutUrl(
s3Config,
key,
contentType,
Buffer.byteLength(content),
);
const putRes = await uploadToPresignedUrl(putUrl, content, contentType);
expect(putRes.status).toBe(200);

// Verify it exists
const beforeHead = await headObject(s3Config, key);
expect(beforeHead).not.toBeNull();

// Delete it
await deleteS3Object(s3Config, key);

// Verify it's gone
const afterHead = await headObject(s3Config, key);
expect(afterHead).toBeNull();
});

it('should be idempotent (no error deleting non-existent key)', async () => {
await expect(
deleteS3Object(s3Config, 'non-existent-key-' + Date.now()),
).resolves.toBeUndefined();
});
});

describe('full round-trip: PUT → HEAD → GET', () => {
it('should upload, verify, and download a text payload', async () => {
const key = 'roundtrip-test-' + Date.now() + '.txt';
Expand Down
17 changes: 8 additions & 9 deletions graphile/graphile-presigned-url-plugin/src/download-url-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@
*/

import type { GraphileConfig } from 'graphile-config';
import 'graphile-build';
import { context as grafastContext, lambda, object } from 'grafast';
import { Logger } from '@pgpmjs/logger';

import type { PresignedUrlPluginOptions, S3Config, StorageModuleConfig } from './types';
import { generatePresignedGetUrl } from './s3-signer';
import { getStorageModuleConfig } from './storage-module-cache';
import { loadAllStorageModules, resolveStorageConfigFromCodec } from './storage-module-cache';

const log = new Logger('graphile-presigned-url:download-url');

Expand Down Expand Up @@ -110,6 +111,8 @@ export function createDownloadUrlPlugin(
graphql: { GraphQLString },
} = build;

const capturedCodec = pgCodec;

return build.extend(
fields,
{
Expand All @@ -121,12 +124,10 @@ export function createDownloadUrlPlugin(
'For private files, returns a time-limited presigned URL.',
type: GraphQLString,
plan($parent: any) {
// Access file attributes from the parent PgSelectSingleStep
const $key = $parent.get('key');
const $isPublic = $parent.get('is_public');
const $filename = $parent.get('filename');

// Access GraphQL context for per-database config resolution
const $withPgClient = (grafastContext() as any).get('withPgClient');
const $pgSettings = (grafastContext() as any).get('pgSettings');

Expand All @@ -141,9 +142,8 @@ export function createDownloadUrlPlugin(
return lambda($combined, async ({ key, isPublic, filename, withPgClient, pgSettings }: any) => {
if (!key) return null;

// Resolve per-database config (bucket, publicUrlPrefix, expiry)
let s3ForDb = resolveS3(options); // fallback to global
let downloadUrlExpirySeconds = 3600; // fallback default
let s3ForDb = resolveS3(options);
let downloadUrlExpirySeconds = 3600;
try {
if (withPgClient && pgSettings) {
const resolved = await withPgClient(null, async (pgClient: any) => {
Expand All @@ -152,7 +152,8 @@ export function createDownloadUrlPlugin(
});
const databaseId = dbResult.rows[0]?.id;
if (!databaseId) return null;
const config = await getStorageModuleConfig(pgClient, databaseId);
const allConfigs = await loadAllStorageModules(pgClient, databaseId);
const config = resolveStorageConfigFromCodec(capturedCodec, allConfigs);
if (!config) return null;
return { config, databaseId };
});
Expand All @@ -166,11 +167,9 @@ export function createDownloadUrlPlugin(
}

if (isPublic && s3ForDb.publicUrlPrefix) {
// Public file: return direct CDN URL (per-database prefix)
return `${s3ForDb.publicUrlPrefix}/${key}`;
}

// Private file: generate presigned GET URL (per-database bucket)
return generatePresignedGetUrl(
s3ForDb,
key,
Expand Down
11 changes: 6 additions & 5 deletions graphile/graphile-presigned-url-plugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/**
* Presigned URL Plugin for PostGraphile v5
*
* Provides presigned URL upload capabilities for PostGraphile v5:
* - requestUploadUrl mutation (presigned PUT URL generation + dedup)
* - downloadUrl computed field (presigned GET URL / public URL)
* Provides per-table S3 storage middleware for PostGraphile v5:
* - Upload fields on @storageBuckets types (requestUploadUrl, requestBulkUploadUrls)
* - Delete middleware on @storageFiles tables (S3 cleanup on delete)
* - downloadUrl computed field on @storageFiles types
*
* @example
* ```typescript
Expand All @@ -29,8 +30,8 @@
export { PresignedUrlPlugin, createPresignedUrlPlugin } from './plugin';
export { createDownloadUrlPlugin } from './download-url-field';
export { PresignedUrlPreset } from './preset';
export { getStorageModuleConfig, getStorageModuleConfigForOwner, getBucketConfig, resolveStorageModuleByFileId, clearStorageModuleCache, clearBucketCache, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
export { generatePresignedPutUrl, generatePresignedGetUrl, headObject } from './s3-signer';
export { getStorageModuleConfig, getStorageModuleConfigForOwner, getBucketConfig, resolveStorageModuleByFileId, loadAllStorageModules, resolveStorageConfigFromCodec, clearStorageModuleCache, clearBucketCache, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
export { generatePresignedPutUrl, generatePresignedGetUrl, deleteS3Object, headObject } from './s3-signer';
export type {
BucketConfig,
StorageModuleConfig,
Expand Down
Loading
Loading