Skip to content

Commit 7b6b9b4

Browse files
committed
refactor: make all validation/expiry config dynamic from storage_module table, fix RC versions to stable
1 parent 2901f84 commit 7b6b9b4

6 files changed

Lines changed: 84 additions & 36 deletions

File tree

graphile/graphile-presigned-url-plugin/package.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,13 @@
4646
"lru-cache": "^11.2.7"
4747
},
4848
"peerDependencies": {
49-
"grafast": "1.0.0-rc.9",
50-
"graphile-build": "5.0.0-rc.6",
51-
"graphile-build-pg": "5.0.0-rc.8",
52-
"graphile-config": "1.0.0-rc.6",
53-
"graphile-utils": "5.0.0-rc.8",
49+
"grafast": "1.0.0",
50+
"graphile-build": "5.0.0",
51+
"graphile-build-pg": "5.0.0",
52+
"graphile-config": "1.0.0",
53+
"graphile-utils": "5.0.0",
5454
"graphql": "16.13.0",
55-
"postgraphile": "5.0.0-rc.10"
55+
"postgraphile": "5.0.0"
5656
},
5757
"devDependencies": {
5858
"@types/node": "^22.19.11",

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

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { Logger } from '@pgpmjs/logger';
1818

1919
import type { PresignedUrlPluginOptions } from './types';
2020
import { generatePresignedGetUrl } from './s3-signer';
21+
import { getStorageModuleConfig } from './storage-module-cache';
2122

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

@@ -34,7 +35,6 @@ export function createDownloadUrlPlugin(
3435
options: PresignedUrlPluginOptions,
3536
): GraphileConfig.Plugin {
3637
const { s3 } = options;
37-
const downloadUrlExpirySeconds = 3600; // 1 hour for GET URLs
3838

3939
return {
4040
name: 'PresignedUrlDownloadPlugin',
@@ -75,7 +75,7 @@ export function createDownloadUrlPlugin(
7575
'URL to download this file. For public files, returns the public URL. ' +
7676
'For private files, returns a time-limited presigned URL.',
7777
type: GraphQLString,
78-
resolve(parent: any) {
78+
async resolve(parent: any, _args: any, context: any) {
7979
const key = parent.key || parent.get?.('key');
8080
const isPublic = parent.is_public ?? parent.get?.('is_public');
8181
const filename = parent.filename || parent.get?.('filename');
@@ -93,6 +93,29 @@ export function createDownloadUrlPlugin(
9393
return `${s3.publicUrlPrefix}/${key}`;
9494
}
9595

96+
// Resolve download URL expiry from storage module config (per-database)
97+
let downloadUrlExpirySeconds = 3600; // fallback default
98+
try {
99+
const withPgClient = context.pgSettings
100+
? context.withPgClient
101+
: null;
102+
if (withPgClient) {
103+
const config = await withPgClient(null, async (pgClient: any) => {
104+
const dbResult = await pgClient.query(
105+
`SELECT jwt_private.current_database_id() AS id`,
106+
);
107+
const databaseId = dbResult.rows[0]?.id;
108+
if (!databaseId) return null;
109+
return getStorageModuleConfig(pgClient, databaseId);
110+
});
111+
if (config) {
112+
downloadUrlExpirySeconds = config.downloadUrlExpirySeconds;
113+
}
114+
}
115+
} catch {
116+
// Fall back to default if config lookup fails
117+
}
118+
96119
// Private file: generate presigned GET URL
97120
return generatePresignedGetUrl(
98121
s3,

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

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,10 @@ import { generatePresignedPutUrl, headObject } from './s3-signer';
2828

2929
const log = new Logger('graphile-presigned-url:plugin');
3030

31-
// --- Validation constants ---
31+
// --- Protocol-level constants (not configurable) ---
3232

3333
const MAX_CONTENT_HASH_LENGTH = 128;
3434
const MAX_CONTENT_TYPE_LENGTH = 255;
35-
const MAX_FILENAME_LENGTH = 1024;
3635
const MAX_BUCKET_KEY_LENGTH = 255;
3736
const SHA256_HEX_REGEX = /^[a-f0-9]{64}$/;
3837

@@ -71,11 +70,7 @@ async function resolveDatabaseId(pgClient: any): Promise<string | null> {
7170
export function createPresignedUrlPlugin(
7271
options: PresignedUrlPluginOptions,
7372
): GraphileConfig.Plugin {
74-
const {
75-
s3,
76-
urlExpirySeconds = 900,
77-
maxFileSize = 200 * 1024 * 1024,
78-
} = options;
73+
const { s3 } = options;
7974

8075
return extendSchema(() => ({
8176
typeDefs: gql`
@@ -168,19 +163,11 @@ export function createPresignedUrlPlugin(
168163
if (!contentType || typeof contentType !== 'string' || contentType.length > MAX_CONTENT_TYPE_LENGTH) {
169164
throw new Error('INVALID_CONTENT_TYPE');
170165
}
171-
if (typeof size !== 'number' || size <= 0 || size > maxFileSize) {
172-
throw new Error(`INVALID_FILE_SIZE: must be between 1 and ${maxFileSize} bytes`);
173-
}
174-
if (filename !== undefined && filename !== null) {
175-
if (typeof filename !== 'string' || filename.length > MAX_FILENAME_LENGTH) {
176-
throw new Error('INVALID_FILENAME');
177-
}
178-
}
179166

180167
return withPgClient(pgSettings, async (pgClient: any) => {
181168
await pgClient.query('BEGIN');
182169
try {
183-
// --- Resolve storage module config ---
170+
// --- Resolve storage module config (all limits come from here) ---
184171
const databaseId = await resolveDatabaseId(pgClient);
185172
if (!databaseId) {
186173
throw new Error('DATABASE_NOT_FOUND');
@@ -191,6 +178,16 @@ export function createPresignedUrlPlugin(
191178
throw new Error('STORAGE_MODULE_NOT_PROVISIONED');
192179
}
193180

181+
// --- Validate size against storage module default (bucket override checked below) ---
182+
if (typeof size !== 'number' || size <= 0 || size > storageConfig.defaultMaxFileSize) {
183+
throw new Error(`INVALID_FILE_SIZE: must be between 1 and ${storageConfig.defaultMaxFileSize} bytes`);
184+
}
185+
if (filename !== undefined && filename !== null) {
186+
if (typeof filename !== 'string' || filename.length > storageConfig.maxFilenameLength) {
187+
throw new Error('INVALID_FILENAME');
188+
}
189+
}
190+
194191
// --- Look up the bucket (RLS enforced) ---
195192
const bucketResult = await pgClient.query(
196193
`SELECT id, type, is_public, owner_id, allowed_mime_types, max_file_size
@@ -288,10 +285,10 @@ export function createPresignedUrlPlugin(
288285
s3Key,
289286
contentType,
290287
size,
291-
urlExpirySeconds,
288+
storageConfig.uploadUrlExpirySeconds,
292289
);
293290

294-
const expiresAt = new Date(Date.now() + urlExpirySeconds * 1000).toISOString();
291+
const expiresAt = new Date(Date.now() + storageConfig.uploadUrlExpirySeconds * 1000).toISOString();
295292

296293
// --- Track the upload request ---
297294
await pgClient.query(

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,6 @@ import { createDownloadUrlPlugin } from './download-url-field';
2929
* bucket: 'my-bucket',
3030
* publicUrlPrefix: 'https://cdn.example.com',
3131
* },
32-
* urlExpirySeconds: 900,
33-
* maxFileSize: 200 * 1024 * 1024,
3432
* }),
3533
* ],
3634
* };

graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ import type { StorageModuleConfig } from './types';
44

55
const log = new Logger('graphile-presigned-url:cache');
66

7+
// --- Defaults ---
8+
const DEFAULT_UPLOAD_URL_EXPIRY_SECONDS = 900; // 15 minutes
9+
const DEFAULT_DOWNLOAD_URL_EXPIRY_SECONDS = 3600; // 1 hour
10+
const DEFAULT_MAX_FILE_SIZE = 200 * 1024 * 1024; // 200MB
11+
const DEFAULT_MAX_FILENAME_LENGTH = 1024;
12+
const DEFAULT_CACHE_TTL_SECONDS = process.env.NODE_ENV === 'development' ? 300 : 3600;
13+
714
const FIVE_MINUTES_MS = 1000 * 60 * 5;
815
const ONE_HOUR_MS = 1000 * 60 * 60;
916

@@ -36,7 +43,12 @@ const STORAGE_MODULE_QUERY = `
3643
fs.schema_name AS files_schema,
3744
ft.name AS files_table,
3845
urs.schema_name AS upload_requests_schema,
39-
urt.name AS upload_requests_table
46+
urt.name AS upload_requests_table,
47+
sm.upload_url_expiry_seconds,
48+
sm.download_url_expiry_seconds,
49+
sm.default_max_file_size,
50+
sm.max_filename_length,
51+
sm.cache_ttl_seconds
4052
FROM metaschema_modules_public.storage_module sm
4153
JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
4254
JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
@@ -56,6 +68,11 @@ interface StorageModuleRow {
5668
files_table: string;
5769
upload_requests_schema: string;
5870
upload_requests_table: string;
71+
upload_url_expiry_seconds: number | null;
72+
download_url_expiry_seconds: number | null;
73+
default_max_file_size: number | null;
74+
max_filename_length: number | null;
75+
cache_ttl_seconds: number | null;
5976
}
6077

6178
/**
@@ -84,6 +101,8 @@ export async function getStorageModuleConfig(
84101
}
85102

86103
const row = result.rows[0] as StorageModuleRow;
104+
const cacheTtlSeconds = row.cache_ttl_seconds ?? DEFAULT_CACHE_TTL_SECONDS;
105+
87106
const config: StorageModuleConfig = {
88107
id: row.id,
89108
bucketsQualifiedName: `"${row.buckets_schema}"."${row.buckets_table}"`,
@@ -93,6 +112,11 @@ export async function getStorageModuleConfig(
93112
bucketsTableName: row.buckets_table,
94113
filesTableName: row.files_table,
95114
uploadRequestsTableName: row.upload_requests_table,
115+
uploadUrlExpirySeconds: row.upload_url_expiry_seconds ?? DEFAULT_UPLOAD_URL_EXPIRY_SECONDS,
116+
downloadUrlExpirySeconds: row.download_url_expiry_seconds ?? DEFAULT_DOWNLOAD_URL_EXPIRY_SECONDS,
117+
defaultMaxFileSize: row.default_max_file_size ?? DEFAULT_MAX_FILE_SIZE,
118+
maxFilenameLength: row.max_filename_length ?? DEFAULT_MAX_FILENAME_LENGTH,
119+
cacheTtlSeconds,
96120
};
97121

98122
storageModuleCache.set(cacheKey, config);

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

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@ export interface StorageModuleConfig {
3333
filesTableName: string;
3434
/** Upload requests table name */
3535
uploadRequestsTableName: string;
36+
37+
// --- Per-database configurable settings ---
38+
39+
/** Presigned PUT URL expiry in seconds (default: 900 = 15 min) */
40+
uploadUrlExpirySeconds: number;
41+
/** Presigned GET URL expiry in seconds (default: 3600 = 1 hour) */
42+
downloadUrlExpirySeconds: number;
43+
/** Default max file size in bytes (default: 200MB). Bucket-level max_file_size overrides this. */
44+
defaultMaxFileSize: number;
45+
/** Max filename length in characters (default: 1024) */
46+
maxFilenameLength: number;
47+
/** Cache TTL in seconds for this config entry (default: 300 dev / 3600 prod) */
48+
cacheTtlSeconds: number;
3649
}
3750

3851
/**
@@ -111,11 +124,4 @@ export interface S3Config {
111124
export interface PresignedUrlPluginOptions {
112125
/** S3 configuration */
113126
s3: S3Config;
114-
/** Presigned URL expiry in seconds (default: 900 = 15 minutes) */
115-
urlExpirySeconds?: number;
116-
/** Maximum file size in bytes (default: 200MB) */
117-
maxFileSize?: number;
118-
/** Performance threshold for content hashing in bytes.
119-
* Above this size, use UUID key instead of content hash. (default: 200MB) */
120-
hashThresholdBytes?: number;
121127
}

0 commit comments

Comments
 (0)