Skip to content

Commit e6ec421

Browse files
authored
Merge pull request #968 from constructive-io/feat/per-database-s3-bucket
feat: wire per-database S3 bucket name and publicUrlPrefix into presigned URL plugin
2 parents e0b55cc + 526d676 commit e6ec421

6 files changed

Lines changed: 133 additions & 24 deletions

File tree

graphile/graphile-presigned-url-plugin/src/download-url-field.ts

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import type { GraphileConfig } from 'graphile-config';
1717
import { Logger } from '@pgpmjs/logger';
1818

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

@@ -44,6 +44,32 @@ function resolveS3(options: PresignedUrlPluginOptions): S3Config {
4444
return options.s3;
4545
}
4646

47+
/**
48+
* Build a per-database S3Config by overlaying storage_module overrides
49+
* onto the global S3Config. Same logic as plugin.ts resolveS3ForDatabase.
50+
*/
51+
function resolveS3ForDatabase(
52+
options: PresignedUrlPluginOptions,
53+
storageConfig: StorageModuleConfig,
54+
databaseId: string,
55+
): S3Config {
56+
const globalS3 = resolveS3(options);
57+
const bucket = options.resolveBucketName
58+
? options.resolveBucketName(databaseId)
59+
: globalS3.bucket;
60+
const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix;
61+
62+
if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
63+
return globalS3;
64+
}
65+
66+
return {
67+
...globalS3,
68+
bucket,
69+
...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
70+
};
71+
}
72+
4773
export function createDownloadUrlPlugin(
4874
options: PresignedUrlPluginOptions,
4975
): GraphileConfig.Plugin {
@@ -100,39 +126,41 @@ export function createDownloadUrlPlugin(
100126
return null;
101127
}
102128

103-
const s3 = resolveS3(options);
104-
105-
if (isPublic && s3.publicUrlPrefix) {
106-
// Public file: return direct URL
107-
return `${s3.publicUrlPrefix}/${key}`;
108-
}
109-
110-
// Resolve download URL expiry from storage module config (per-database)
129+
// Resolve per-database config (bucket, publicUrlPrefix, expiry)
130+
let s3ForDb = resolveS3(options); // fallback to global
111131
let downloadUrlExpirySeconds = 3600; // fallback default
112132
try {
113133
const withPgClient = context.pgSettings
114134
? context.withPgClient
115135
: null;
116136
if (withPgClient) {
117-
const config = await withPgClient(null, async (pgClient: any) => {
137+
const resolved = await withPgClient(null, async (pgClient: any) => {
118138
const dbResult = await pgClient.query(
119139
`SELECT jwt_private.current_database_id() AS id`,
120140
);
121141
const databaseId = dbResult.rows[0]?.id;
122142
if (!databaseId) return null;
123-
return getStorageModuleConfig(pgClient, databaseId);
143+
const config = await getStorageModuleConfig(pgClient, databaseId);
144+
if (!config) return null;
145+
return { config, databaseId };
124146
});
125-
if (config) {
126-
downloadUrlExpirySeconds = config.downloadUrlExpirySeconds;
147+
if (resolved) {
148+
downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds;
149+
s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.databaseId);
127150
}
128151
}
129152
} catch {
130-
// Fall back to default if config lookup fails
153+
// Fall back to global config if lookup fails
154+
}
155+
156+
if (isPublic && s3ForDb.publicUrlPrefix) {
157+
// Public file: return direct CDN URL (per-database prefix)
158+
return `${s3ForDb.publicUrlPrefix}/${key}`;
131159
}
132160

133-
// Private file: generate presigned GET URL
161+
// Private file: generate presigned GET URL (per-database bucket)
134162
return generatePresignedGetUrl(
135-
resolveS3(options),
163+
s3ForDb,
136164
key,
137165
downloadUrlExpirySeconds,
138166
filename || undefined,

graphile/graphile-presigned-url-plugin/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,5 @@ export type {
4242
S3Config,
4343
S3ConfigOrGetter,
4444
PresignedUrlPluginOptions,
45+
BucketNameResolver,
4546
} from './types';

graphile/graphile-presigned-url-plugin/src/plugin.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import type { GraphileConfig } from 'graphile-config';
2222
import { extendSchema, gql } from 'graphile-utils';
2323
import { Logger } from '@pgpmjs/logger';
2424

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

@@ -82,6 +82,36 @@ function resolveS3(options: PresignedUrlPluginOptions): S3Config {
8282
return options.s3;
8383
}
8484

85+
/**
86+
* Build a per-database S3Config by overlaying storage_module overrides
87+
* onto the global S3Config.
88+
*
89+
* - Bucket name: from resolveBucketName(databaseId) if provided, else global
90+
* - publicUrlPrefix: from storageConfig.publicUrlPrefix if set, else global
91+
* - S3 client (credentials, endpoint): always global (shared IAM key)
92+
*/
93+
function resolveS3ForDatabase(
94+
options: PresignedUrlPluginOptions,
95+
storageConfig: StorageModuleConfig,
96+
databaseId: string,
97+
): S3Config {
98+
const globalS3 = resolveS3(options);
99+
const bucket = options.resolveBucketName
100+
? options.resolveBucketName(databaseId)
101+
: globalS3.bucket;
102+
const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix;
103+
104+
if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
105+
return globalS3;
106+
}
107+
108+
return {
109+
...globalS3,
110+
bucket,
111+
...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
112+
};
113+
}
114+
85115
export function createPresignedUrlPlugin(
86116
options: PresignedUrlPluginOptions,
87117
): GraphileConfig.Plugin {
@@ -284,9 +314,10 @@ export function createPresignedUrlPlugin(
284314

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

287-
// --- Generate presigned PUT URL ---
317+
// --- Generate presigned PUT URL (per-database bucket) ---
318+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
288319
const uploadUrl = await generatePresignedPutUrl(
289-
resolveS3(options),
320+
s3ForDb,
290321
s3Key,
291322
contentType,
292323
size,
@@ -375,8 +406,9 @@ export function createPresignedUrlPlugin(
375406
};
376407
}
377408

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

381413
if (!s3Head) {
382414
throw new Error('FILE_NOT_IN_S3: the file has not been uploaded yet');

graphile/graphile-presigned-url-plugin/src/types.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,10 +135,29 @@ export interface S3Config {
135135
*/
136136
export type S3ConfigOrGetter = S3Config | (() => S3Config);
137137

138+
/**
139+
* Function to derive the actual S3 bucket name for a given database.
140+
*
141+
* When provided, the presigned URL plugin calls this on every request
142+
* to determine which S3 bucket to use — enabling per-database bucket
143+
* isolation. If not provided, falls back to `s3Config.bucket` (global).
144+
*
145+
* @param databaseId - The metaschema database UUID
146+
* @returns The S3 bucket name for this database
147+
*/
148+
export type BucketNameResolver = (databaseId: string) => string;
149+
138150
/**
139151
* Plugin options for the presigned URL plugin.
140152
*/
141153
export interface PresignedUrlPluginOptions {
142154
/** S3 configuration (concrete or lazy getter) */
143155
s3: S3ConfigOrGetter;
156+
157+
/**
158+
* Optional function to resolve S3 bucket name per-database.
159+
* When set, each database gets its own S3 bucket instead of sharing
160+
* the global `s3Config.bucket`. The S3 credentials (client) remain shared.
161+
*/
162+
resolveBucketName?: BucketNameResolver;
144163
}

graphile/graphile-settings/src/presets/constructive-preset.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { PresignedUrlPreset } from 'graphile-presigned-url-plugin';
1919
import { BucketProvisionerPreset } from 'graphile-bucket-provisioner-plugin';
2020
import { SqlExpressionValidatorPreset } from 'graphile-sql-expression-validator';
2121
import { constructiveUploadFieldDefinitions } from '../upload-resolver';
22-
import { getPresignedUrlS3Config } from '../presigned-url-resolver';
22+
import { getPresignedUrlS3Config, createBucketNameResolver } from '../presigned-url-resolver';
2323
import { getBucketProvisionerConnection } from '../bucket-provisioner-resolver';
2424

2525
/**
@@ -90,7 +90,7 @@ export const ConstructivePreset: GraphileConfig.Preset = {
9090
uploadFieldDefinitions: constructiveUploadFieldDefinitions,
9191
maxFileSize: 10 * 1024 * 1024, // 10MB
9292
}),
93-
PresignedUrlPreset({ s3: getPresignedUrlS3Config }),
93+
PresignedUrlPreset({ s3: getPresignedUrlS3Config, resolveBucketName: createBucketNameResolver() }),
9494
BucketProvisionerPreset({
9595
connection: getBucketProvisionerConnection,
9696
allowedOrigins: ['http://localhost:3000'],

graphile/graphile-settings/src/presigned-url-resolver.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@
55
* (getEnvOptions → pgpmDefaults + config files + env vars) and lazily
66
* initializes an S3Client on first use.
77
*
8+
* Also provides a per-database bucket name resolver that derives the
9+
* S3 bucket name from the database UUID + a configurable prefix.
10+
*
811
* Follows the same lazy-init pattern as upload-resolver.ts.
912
*/
1013

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

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

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

5360
return s3Config;
5461
}
62+
63+
/**
64+
* Create a per-database bucket name resolver.
65+
*
66+
* Uses the BUCKET_NAME env var as a prefix. For each database, the S3 bucket
67+
* name becomes `{prefix}-{databaseId}` (e.g., "myapp-abc123def456").
68+
*
69+
* In local development with MinIO (default BUCKET_NAME="test-bucket"),
70+
* all databases share the same bucket for simplicity — the resolver
71+
* returns the prefix as-is when it looks like a local dev bucket.
72+
*
73+
* In production, set BUCKET_NAME to your org prefix (e.g., "myapp")
74+
* and each database gets its own isolated S3 bucket.
75+
*/
76+
export function createBucketNameResolver(): BucketNameResolver {
77+
const { cdn } = getEnvOptions();
78+
const prefix = cdn?.bucketName || 'test-bucket';
79+
80+
return (databaseId: string): string => {
81+
return `${prefix}-${databaseId}`;
82+
};
83+
}

0 commit comments

Comments
 (0)