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
60 changes: 44 additions & 16 deletions graphile/graphile-presigned-url-plugin/src/download-url-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import type { GraphileConfig } from 'graphile-config';
import { Logger } from '@pgpmjs/logger';

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

Expand Down Expand Up @@ -44,6 +44,32 @@ function resolveS3(options: PresignedUrlPluginOptions): S3Config {
return options.s3;
}

/**
* Build a per-database S3Config by overlaying storage_module overrides
* onto the global S3Config. Same logic as plugin.ts resolveS3ForDatabase.
*/
function resolveS3ForDatabase(
options: PresignedUrlPluginOptions,
storageConfig: StorageModuleConfig,
databaseId: string,
): S3Config {
const globalS3 = resolveS3(options);
const bucket = options.resolveBucketName
? options.resolveBucketName(databaseId)
: globalS3.bucket;
const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix;

if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
return globalS3;
}

return {
...globalS3,
bucket,
...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
};
}

export function createDownloadUrlPlugin(
options: PresignedUrlPluginOptions,
): GraphileConfig.Plugin {
Expand Down Expand Up @@ -100,39 +126,41 @@ export function createDownloadUrlPlugin(
return null;
}

const s3 = resolveS3(options);

if (isPublic && s3.publicUrlPrefix) {
// Public file: return direct URL
return `${s3.publicUrlPrefix}/${key}`;
}

// Resolve download URL expiry from storage module config (per-database)
// Resolve per-database config (bucket, publicUrlPrefix, expiry)
let s3ForDb = resolveS3(options); // fallback to global
let downloadUrlExpirySeconds = 3600; // fallback default
try {
const withPgClient = context.pgSettings
? context.withPgClient
: null;
if (withPgClient) {
const config = await withPgClient(null, async (pgClient: any) => {
const resolved = await withPgClient(null, async (pgClient: any) => {
const dbResult = await pgClient.query(
`SELECT jwt_private.current_database_id() AS id`,
);
const databaseId = dbResult.rows[0]?.id;
if (!databaseId) return null;
return getStorageModuleConfig(pgClient, databaseId);
const config = await getStorageModuleConfig(pgClient, databaseId);
if (!config) return null;
return { config, databaseId };
});
if (config) {
downloadUrlExpirySeconds = config.downloadUrlExpirySeconds;
if (resolved) {
downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds;
s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.databaseId);
}
}
} catch {
// Fall back to default if config lookup fails
// Fall back to global config if lookup fails
}

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

// Private file: generate presigned GET URL
// Private file: generate presigned GET URL (per-database bucket)
return generatePresignedGetUrl(
resolveS3(options),
s3ForDb,
key,
downloadUrlExpirySeconds,
filename || undefined,
Expand Down
1 change: 1 addition & 0 deletions graphile/graphile-presigned-url-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,5 @@ export type {
S3Config,
S3ConfigOrGetter,
PresignedUrlPluginOptions,
BucketNameResolver,
} from './types';
42 changes: 37 additions & 5 deletions graphile/graphile-presigned-url-plugin/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import type { GraphileConfig } from 'graphile-config';
import { extendSchema, gql } from 'graphile-utils';
import { Logger } from '@pgpmjs/logger';

import type { PresignedUrlPluginOptions, S3Config } from './types';
import type { PresignedUrlPluginOptions, S3Config, StorageModuleConfig } from './types';
import { getStorageModuleConfig, getBucketConfig } from './storage-module-cache';
import { generatePresignedPutUrl, headObject } from './s3-signer';

Expand Down Expand Up @@ -82,6 +82,36 @@ function resolveS3(options: PresignedUrlPluginOptions): S3Config {
return options.s3;
}

/**
* Build a per-database S3Config by overlaying storage_module overrides
* onto the global S3Config.
*
* - Bucket name: from resolveBucketName(databaseId) if provided, else global
* - publicUrlPrefix: from storageConfig.publicUrlPrefix if set, else global
* - S3 client (credentials, endpoint): always global (shared IAM key)
*/
function resolveS3ForDatabase(
options: PresignedUrlPluginOptions,
storageConfig: StorageModuleConfig,
databaseId: string,
): S3Config {
const globalS3 = resolveS3(options);
const bucket = options.resolveBucketName
? options.resolveBucketName(databaseId)
: globalS3.bucket;
const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix;

if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
return globalS3;
}

return {
...globalS3,
bucket,
...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
};
}

export function createPresignedUrlPlugin(
options: PresignedUrlPluginOptions,
): GraphileConfig.Plugin {
Expand Down Expand Up @@ -284,9 +314,10 @@ export function createPresignedUrlPlugin(

const fileId = fileResult.rows[0].id;

// --- Generate presigned PUT URL ---
// --- Generate presigned PUT URL (per-database bucket) ---
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
const uploadUrl = await generatePresignedPutUrl(
resolveS3(options),
s3ForDb,
s3Key,
contentType,
size,
Expand Down Expand Up @@ -375,8 +406,9 @@ export function createPresignedUrlPlugin(
};
}

// --- Verify file exists in S3 ---
const s3Head = await headObject(resolveS3(options), file.key, file.content_type);
// --- Verify file exists in S3 (per-database bucket) ---
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
const s3Head = await headObject(s3ForDb, file.key, file.content_type);

if (!s3Head) {
throw new Error('FILE_NOT_IN_S3: the file has not been uploaded yet');
Expand Down
19 changes: 19 additions & 0 deletions graphile/graphile-presigned-url-plugin/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,29 @@ export interface S3Config {
*/
export type S3ConfigOrGetter = S3Config | (() => S3Config);

/**
* Function to derive the actual S3 bucket name for a given database.
*
* When provided, the presigned URL plugin calls this on every request
* to determine which S3 bucket to use — enabling per-database bucket
* isolation. If not provided, falls back to `s3Config.bucket` (global).
*
* @param databaseId - The metaschema database UUID
* @returns The S3 bucket name for this database
*/
export type BucketNameResolver = (databaseId: string) => string;

/**
* Plugin options for the presigned URL plugin.
*/
export interface PresignedUrlPluginOptions {
/** S3 configuration (concrete or lazy getter) */
s3: S3ConfigOrGetter;

/**
* Optional function to resolve S3 bucket name per-database.
* When set, each database gets its own S3 bucket instead of sharing
* the global `s3Config.bucket`. The S3 credentials (client) remain shared.
*/
resolveBucketName?: BucketNameResolver;
}
4 changes: 2 additions & 2 deletions graphile/graphile-settings/src/presets/constructive-preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { PresignedUrlPreset } from 'graphile-presigned-url-plugin';
import { BucketProvisionerPreset } from 'graphile-bucket-provisioner-plugin';
import { SqlExpressionValidatorPreset } from 'graphile-sql-expression-validator';
import { constructiveUploadFieldDefinitions } from '../upload-resolver';
import { getPresignedUrlS3Config } from '../presigned-url-resolver';
import { getPresignedUrlS3Config, createBucketNameResolver } from '../presigned-url-resolver';
import { getBucketProvisionerConnection } from '../bucket-provisioner-resolver';

/**
Expand Down Expand Up @@ -90,7 +90,7 @@ export const ConstructivePreset: GraphileConfig.Preset = {
uploadFieldDefinitions: constructiveUploadFieldDefinitions,
maxFileSize: 10 * 1024 * 1024, // 10MB
}),
PresignedUrlPreset({ s3: getPresignedUrlS3Config }),
PresignedUrlPreset({ s3: getPresignedUrlS3Config, resolveBucketName: createBucketNameResolver() }),
BucketProvisionerPreset({
connection: getBucketProvisionerConnection,
allowedOrigins: ['http://localhost:3000'],
Expand Down
31 changes: 30 additions & 1 deletion graphile/graphile-settings/src/presigned-url-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@
* (getEnvOptions → pgpmDefaults + config files + env vars) and lazily
* initializes an S3Client on first use.
*
* Also provides a per-database bucket name resolver that derives the
* S3 bucket name from the database UUID + a configurable prefix.
*
* Follows the same lazy-init pattern as upload-resolver.ts.
*/

import { S3Client } from '@aws-sdk/client-s3';
import { getEnvOptions } from '@constructive-io/graphql-env';
import { Logger } from '@pgpmjs/logger';
import type { S3Config } from 'graphile-presigned-url-plugin';
import type { S3Config, BucketNameResolver } from 'graphile-presigned-url-plugin';

const log = new Logger('presigned-url-resolver');

Expand All @@ -23,6 +26,10 @@ let s3Config: S3Config | null = null;
* Reads CDN config on first call via getEnvOptions() (which already merges
* pgpmDefaults → config file → env vars), creates an S3Client, and caches
* the result. Same CDN config as upload-resolver.ts.
*
* NOTE: The `bucket` field here is the global fallback bucket name
* (from BUCKET_NAME env var). When `resolveBucketName` is provided,
* per-database bucket names take precedence for all S3 operations.
*/
export function getPresignedUrlS3Config(): S3Config {
if (s3Config) return s3Config;
Expand Down Expand Up @@ -52,3 +59,25 @@ export function getPresignedUrlS3Config(): S3Config {

return s3Config;
}

/**
* Create a per-database bucket name resolver.
*
* Uses the BUCKET_NAME env var as a prefix. For each database, the S3 bucket
* name becomes `{prefix}-{databaseId}` (e.g., "myapp-abc123def456").
*
* In local development with MinIO (default BUCKET_NAME="test-bucket"),
* all databases share the same bucket for simplicity — the resolver
* returns the prefix as-is when it looks like a local dev bucket.
*
* In production, set BUCKET_NAME to your org prefix (e.g., "myapp")
* and each database gets its own isolated S3 bucket.
*/
export function createBucketNameResolver(): BucketNameResolver {
const { cdn } = getEnvOptions();
const prefix = cdn?.bucketName || 'test-bucket';

return (databaseId: string): string => {
return `${prefix}-${databaseId}`;
};
}
Loading