@constructive-io/graphql-server 4.1.1 → 4.3.1
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/esm/index.js +1 -0
- package/esm/middleware/api.js +1 -0
- package/esm/middleware/error-handler.js +2 -3
- package/esm/middleware/graphile.js +90 -19
- package/esm/middleware/multipart-bridge.js +30 -0
- package/esm/middleware/upload.js +259 -0
- package/esm/server.js +16 -6
- package/index.d.ts +1 -0
- package/index.js +3 -1
- package/middleware/api.js +1 -0
- package/middleware/error-handler.js +2 -3
- package/middleware/graphile.d.ts +2 -2
- package/middleware/graphile.js +93 -19
- package/middleware/multipart-bridge.d.ts +26 -0
- package/middleware/multipart-bridge.js +34 -0
- package/middleware/types.d.ts +2 -2
- package/middleware/upload.d.ts +20 -0
- package/middleware/upload.js +266 -0
- package/package.json +20 -17
- package/server.js +15 -5
- package/types.d.ts +1 -0
package/esm/index.js
CHANGED
|
@@ -4,6 +4,7 @@ export * from './options';
|
|
|
4
4
|
// Export middleware for use in testing packages
|
|
5
5
|
export { createApiMiddleware, getSubdomain, getApiConfig } from './middleware/api';
|
|
6
6
|
export { createAuthenticateMiddleware } from './middleware/auth';
|
|
7
|
+
export { createUploadAuthenticateMiddleware } from './middleware/upload';
|
|
7
8
|
export { cors } from './middleware/cors';
|
|
8
9
|
export { graphile } from './middleware/graphile';
|
|
9
10
|
export { flush, flushService } from './middleware/flush';
|
package/esm/middleware/api.js
CHANGED
|
@@ -124,6 +124,7 @@ const toRlsModule = (row) => {
|
|
|
124
124
|
};
|
|
125
125
|
};
|
|
126
126
|
const toApiStructure = (row, opts, rlsModuleRow) => ({
|
|
127
|
+
apiId: row.api_id,
|
|
127
128
|
dbname: row.dbname || opts.pg?.database || '',
|
|
128
129
|
anonRole: row.anon_role || 'anon',
|
|
129
130
|
roleName: row.role_name || 'authenticated',
|
|
@@ -75,12 +75,11 @@ export const errorHandler = (err, req, res, _next) => {
|
|
|
75
75
|
sendResponse(req, res, response);
|
|
76
76
|
};
|
|
77
77
|
export const notFoundHandler = (req, res, _next) => {
|
|
78
|
-
const message = `Route not found: ${req.method} ${req.path}`;
|
|
79
78
|
log.warn({ event: 'route_not_found', path: req.path, method: req.method, requestId: req.requestId });
|
|
80
79
|
if (wantsJson(req)) {
|
|
81
|
-
res.status(404).json({ error: { code: 'NOT_FOUND', message, requestId: req.requestId } });
|
|
80
|
+
res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Not found', requestId: req.requestId } });
|
|
82
81
|
}
|
|
83
82
|
else {
|
|
84
|
-
res.status(404).send(errorPage404Message(
|
|
83
|
+
res.status(404).send(errorPage404Message('The requested page was not found'));
|
|
85
84
|
}
|
|
86
85
|
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { getNodeEnv } from '@constructive-io/graphql-env';
|
|
1
3
|
import { Logger } from '@pgpmjs/logger';
|
|
2
4
|
import { createGraphileInstance, graphileCache } from 'graphile-cache';
|
|
3
5
|
import { ConstructivePreset, makePgService } from 'graphile-settings';
|
|
@@ -5,24 +7,71 @@ import { buildConnectionString } from 'pg-cache';
|
|
|
5
7
|
import { getPgEnvOptions } from 'pg-env';
|
|
6
8
|
import './types'; // for Request type
|
|
7
9
|
import { HandlerCreationError } from '../errors/api-errors';
|
|
10
|
+
const maskErrorLog = new Logger('graphile:maskError');
|
|
11
|
+
const SAFE_ERROR_CODES = new Set([
|
|
12
|
+
// GraphQL standard
|
|
13
|
+
'GRAPHQL_VALIDATION_FAILED',
|
|
14
|
+
'GRAPHQL_PARSE_FAILED',
|
|
15
|
+
'PERSISTED_QUERY_NOT_FOUND',
|
|
16
|
+
'PERSISTED_QUERY_NOT_SUPPORTED',
|
|
17
|
+
// Auth
|
|
18
|
+
'UNAUTHENTICATED',
|
|
19
|
+
'FORBIDDEN',
|
|
20
|
+
'BAD_USER_INPUT',
|
|
21
|
+
'INCORRECT_PASSWORD',
|
|
22
|
+
'PASSWORD_INSECURE',
|
|
23
|
+
'ACCOUNT_LOCKED_EXCEED_ATTEMPTS',
|
|
24
|
+
'ACCOUNT_DISABLED',
|
|
25
|
+
'ACCOUNT_EXISTS',
|
|
26
|
+
'PASSWORD_LEN',
|
|
27
|
+
'INVITE_NOT_FOUND',
|
|
28
|
+
'INVITE_LIMIT',
|
|
29
|
+
'INVITE_EMAIL_NOT_FOUND',
|
|
30
|
+
'INVALID_CREDENTIALS',
|
|
31
|
+
// PublicKeySignature
|
|
32
|
+
'FEATURE_DISABLED',
|
|
33
|
+
'INVALID_PUBLIC_KEY',
|
|
34
|
+
'INVALID_MESSAGE',
|
|
35
|
+
'INVALID_SIGNATURE',
|
|
36
|
+
'NO_ACCOUNT_EXISTS',
|
|
37
|
+
'BAD_SIGNIN',
|
|
38
|
+
// Upload
|
|
39
|
+
'UPLOAD_MIMETYPE',
|
|
40
|
+
// PostgreSQL constraint violations (surfaced by PostGraphile)
|
|
41
|
+
'23505', // unique_violation
|
|
42
|
+
'23503', // foreign_key_violation
|
|
43
|
+
'23502', // not_null_violation
|
|
44
|
+
'23514', // check_violation
|
|
45
|
+
'23P01', // exclusion_violation
|
|
46
|
+
]);
|
|
8
47
|
/**
|
|
9
|
-
*
|
|
48
|
+
* Production-aware error masking function.
|
|
10
49
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* - grafserv defaultMaskError: node_modules/grafserv/dist/options.js
|
|
16
|
-
* - SafeError interface: grafast isSafeError() - errors implementing SafeError
|
|
17
|
-
* are shown as-is even with default masking
|
|
18
|
-
*
|
|
19
|
-
* If you need to restore masking behavior, see the upstream implementation which:
|
|
20
|
-
* 1. Returns GraphQLError instances as-is
|
|
21
|
-
* 2. Returns SafeError instances with their message exposed
|
|
22
|
-
* 3. Masks other errors with a hash/ID and logs the original
|
|
50
|
+
* In development: returns errors as-is for debugging.
|
|
51
|
+
* In production: returns errors with explicit codes from the SAFE_ERROR_CODES
|
|
52
|
+
* allowlist as-is, but masks unexpected/database errors with a reference ID
|
|
53
|
+
* and logs the original.
|
|
23
54
|
*/
|
|
24
55
|
const maskError = (error) => {
|
|
25
|
-
|
|
56
|
+
if (getNodeEnv() === 'development') {
|
|
57
|
+
return error;
|
|
58
|
+
}
|
|
59
|
+
// Only expose errors with codes on the safe allowlist.
|
|
60
|
+
// Note: grafserv strips originalError and internal extensions before
|
|
61
|
+
// serializing to the client, so returning the full error object is safe here.
|
|
62
|
+
if (error.extensions?.code && SAFE_ERROR_CODES.has(error.extensions.code)) {
|
|
63
|
+
return error;
|
|
64
|
+
}
|
|
65
|
+
// Mask unexpected/database errors with a reference ID
|
|
66
|
+
const errorId = crypto.randomBytes(8).toString('hex');
|
|
67
|
+
maskErrorLog.error(`[masked-error:${errorId}]`, error);
|
|
68
|
+
return {
|
|
69
|
+
message: `An unexpected error occurred. Reference: ${errorId}`,
|
|
70
|
+
extensions: {
|
|
71
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
72
|
+
errorId,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
26
75
|
};
|
|
27
76
|
// =============================================================================
|
|
28
77
|
// Single-Flight Pattern: In-Flight Tracking
|
|
@@ -54,7 +103,7 @@ export function clearInFlightMap() {
|
|
|
54
103
|
creating.clear();
|
|
55
104
|
}
|
|
56
105
|
const log = new Logger('graphile');
|
|
57
|
-
const reqLabel = (req) => req.requestId ? `[${req.requestId}]` : '[req]';
|
|
106
|
+
const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
|
|
58
107
|
/**
|
|
59
108
|
* Build a PostGraphile v5 preset for a tenant
|
|
60
109
|
*/
|
|
@@ -70,6 +119,7 @@ const buildPreset = (connectionString, schemas, anonRole, roleName) => ({
|
|
|
70
119
|
graphqlPath: '/graphql',
|
|
71
120
|
graphiqlPath: '/graphiql',
|
|
72
121
|
graphiql: true,
|
|
122
|
+
graphiqlOnGraphQLGET: false,
|
|
73
123
|
maskError,
|
|
74
124
|
},
|
|
75
125
|
grafast: {
|
|
@@ -147,13 +197,26 @@ export const graphile = (opts) => {
|
|
|
147
197
|
return instance.handler(req, res, next);
|
|
148
198
|
}
|
|
149
199
|
catch (error) {
|
|
150
|
-
|
|
151
|
-
|
|
200
|
+
log.warn(`${label} Coalesced request failed for PostGraphile[${key}], retrying`);
|
|
201
|
+
// Fall through to Phase C to retry creation
|
|
152
202
|
}
|
|
153
203
|
}
|
|
154
204
|
// =========================================================================
|
|
155
205
|
// Phase C: Create New Handler (first request for this key)
|
|
156
206
|
// =========================================================================
|
|
207
|
+
// Re-check cache after coalesced request failure (another retry may have succeeded)
|
|
208
|
+
const recheckedCache = graphileCache.get(key);
|
|
209
|
+
if (recheckedCache) {
|
|
210
|
+
log.debug(`${label} PostGraphile cache hit on re-check key=${key}`);
|
|
211
|
+
return recheckedCache.handler(req, res, next);
|
|
212
|
+
}
|
|
213
|
+
// Re-check in-flight map (another retry may have started creation)
|
|
214
|
+
const retryInFlight = creating.get(key);
|
|
215
|
+
if (retryInFlight) {
|
|
216
|
+
log.debug(`${label} Re-coalescing request for PostGraphile[${key}]`);
|
|
217
|
+
const retryInstance = await retryInFlight;
|
|
218
|
+
return retryInstance.handler(req, res, next);
|
|
219
|
+
}
|
|
157
220
|
log.info(`${label} Building PostGraphile v5 handler key=${key} db=${dbname} schemas=${schemaLabel} role=${roleName} anon=${anonRole}`);
|
|
158
221
|
const pgConfig = getPgEnvOptions({
|
|
159
222
|
...opts.pg,
|
|
@@ -172,7 +235,10 @@ export const graphile = (opts) => {
|
|
|
172
235
|
}
|
|
173
236
|
catch (error) {
|
|
174
237
|
log.error(`${label} Failed to create PostGraphile[${key}]:`, error);
|
|
175
|
-
throw new HandlerCreationError(`Failed to create handler for ${key}: ${error instanceof Error ? error.message : String(error)}`, {
|
|
238
|
+
throw new HandlerCreationError(`Failed to create handler for ${key}: ${error instanceof Error ? error.message : String(error)}`, {
|
|
239
|
+
cacheKey: key,
|
|
240
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
241
|
+
});
|
|
176
242
|
}
|
|
177
243
|
finally {
|
|
178
244
|
// Always clean up in-flight tracker
|
|
@@ -181,7 +247,12 @@ export const graphile = (opts) => {
|
|
|
181
247
|
}
|
|
182
248
|
catch (e) {
|
|
183
249
|
log.error(`${label} PostGraphile middleware error`, e);
|
|
184
|
-
|
|
250
|
+
if (!res.headersSent) {
|
|
251
|
+
return res.status(500).json({
|
|
252
|
+
error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' }
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
next(e);
|
|
185
256
|
}
|
|
186
257
|
};
|
|
187
258
|
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge middleware between graphql-upload and grafserv (PostGraphile v5).
|
|
3
|
+
*
|
|
4
|
+
* graphql-upload's `graphqlUploadExpress()` parses multipart/form-data requests
|
|
5
|
+
* and sets `req.body` to a standard GraphQL operation object, but leaves the
|
|
6
|
+
* original Content-Type header unchanged. grafserv rejects any Content-Type it
|
|
7
|
+
* doesn't recognize (415 Unsupported Media Type) because its allowlist only
|
|
8
|
+
* includes `application/json` and `application/graphql`.
|
|
9
|
+
*
|
|
10
|
+
* This middleware rewrites the Content-Type header to `application/json` after
|
|
11
|
+
* graphql-upload has finished parsing, so grafserv accepts the request.
|
|
12
|
+
*
|
|
13
|
+
* This is the standard bridge pattern for grafserv + graphql-upload.
|
|
14
|
+
* See: https://github.com/graphql/graphql-http/discussions/36
|
|
15
|
+
*
|
|
16
|
+
* Must be registered immediately after `graphqlUploadExpress()` in the
|
|
17
|
+
* middleware chain — it relies on graphql-upload having already parsed the
|
|
18
|
+
* multipart body into `req.body`.
|
|
19
|
+
*
|
|
20
|
+
* Security note: multipart/form-data is a CORS "simple request" content type,
|
|
21
|
+
* which means browsers send it without a preflight OPTIONS check. The CSRF risk
|
|
22
|
+
* is mitigated by the CORS middleware that runs before this middleware in the
|
|
23
|
+
* Express pipeline.
|
|
24
|
+
*/
|
|
25
|
+
export const multipartBridge = (req, _res, next) => {
|
|
26
|
+
if (req.body != null && req.headers['content-type']?.startsWith('multipart/form-data')) {
|
|
27
|
+
req.headers['content-type'] = 'application/json';
|
|
28
|
+
}
|
|
29
|
+
next();
|
|
30
|
+
};
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { Logger } from '@pgpmjs/logger';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import multer from 'multer';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { escapeIdentifier } from 'pg';
|
|
6
|
+
import { getPgPool } from 'pg-cache';
|
|
7
|
+
import pgQueryContext from 'pg-query-context';
|
|
8
|
+
import { streamToStorage } from 'graphile-settings';
|
|
9
|
+
import './types';
|
|
10
|
+
const uploadLog = new Logger('upload');
|
|
11
|
+
const authLog = new Logger('upload-auth');
|
|
12
|
+
const envFileSize = process.env.MAX_UPLOAD_FILE_SIZE
|
|
13
|
+
? parseInt(process.env.MAX_UPLOAD_FILE_SIZE, 10)
|
|
14
|
+
: NaN;
|
|
15
|
+
const MAX_FILE_SIZE = envFileSize > 0 ? envFileSize : 10 * 1024 * 1024;
|
|
16
|
+
const BLOCKED_MIME_TYPES = new Set([
|
|
17
|
+
'application/x-executable',
|
|
18
|
+
'application/x-sharedlib',
|
|
19
|
+
'application/x-mach-binary',
|
|
20
|
+
'application/x-dosexec',
|
|
21
|
+
'text/html',
|
|
22
|
+
'application/xhtml+xml',
|
|
23
|
+
'application/javascript',
|
|
24
|
+
'text/javascript'
|
|
25
|
+
]);
|
|
26
|
+
const parseFile = multer({
|
|
27
|
+
storage: multer.diskStorage({
|
|
28
|
+
destination: os.tmpdir(),
|
|
29
|
+
filename: (_req, _file, cb) => {
|
|
30
|
+
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
|
|
31
|
+
cb(null, `upload-${uniqueSuffix}.tmp`);
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
limits: { fileSize: MAX_FILE_SIZE },
|
|
35
|
+
}).single('file');
|
|
36
|
+
const parseFileWithErrors = (req, res, next) => {
|
|
37
|
+
parseFile(req, res, (err) => {
|
|
38
|
+
if (!err)
|
|
39
|
+
return next();
|
|
40
|
+
if (err.code === 'LIMIT_FILE_SIZE') {
|
|
41
|
+
return res.status(413).json({ error: `File exceeds maximum size of ${MAX_FILE_SIZE / (1024 * 1024)} MB` });
|
|
42
|
+
}
|
|
43
|
+
if (err.code === 'LIMIT_UNEXPECTED_FILE') {
|
|
44
|
+
return res.status(400).json({ error: 'Unexpected file field. Send a single file as "file".' });
|
|
45
|
+
}
|
|
46
|
+
return res.status(400).json({ error: 'File upload failed' });
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
const RLS_MODULE_BASE_SQL = `
|
|
50
|
+
SELECT
|
|
51
|
+
rm.authenticate,
|
|
52
|
+
rm.authenticate_strict,
|
|
53
|
+
ps.schema_name as private_schema_name
|
|
54
|
+
FROM metaschema_modules_public.rls_module rm
|
|
55
|
+
LEFT JOIN metaschema_public.schema ps ON rm.private_schema_id = ps.id`;
|
|
56
|
+
const RLS_MODULE_BY_DATABASE_ID_SQL = `${RLS_MODULE_BASE_SQL}
|
|
57
|
+
JOIN services_public.apis a ON rm.api_id = a.id
|
|
58
|
+
WHERE a.database_id = $1
|
|
59
|
+
ORDER BY a.id
|
|
60
|
+
LIMIT 1
|
|
61
|
+
`;
|
|
62
|
+
const RLS_MODULE_BY_API_ID_SQL = `${RLS_MODULE_BASE_SQL}
|
|
63
|
+
WHERE rm.api_id = $1
|
|
64
|
+
LIMIT 1
|
|
65
|
+
`;
|
|
66
|
+
const RLS_MODULE_BY_DBNAME_SQL = `${RLS_MODULE_BASE_SQL}
|
|
67
|
+
JOIN services_public.apis a ON rm.api_id = a.id
|
|
68
|
+
WHERE a.dbname = $1
|
|
69
|
+
ORDER BY a.id
|
|
70
|
+
LIMIT 1
|
|
71
|
+
`;
|
|
72
|
+
const toRlsModule = (row) => {
|
|
73
|
+
if (!row || !row.private_schema_name)
|
|
74
|
+
return undefined;
|
|
75
|
+
return {
|
|
76
|
+
authenticate: row.authenticate ?? undefined,
|
|
77
|
+
authenticateStrict: row.authenticate_strict ?? undefined,
|
|
78
|
+
privateSchema: { schemaName: row.private_schema_name },
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
const getBearerToken = (authorization) => {
|
|
82
|
+
if (!authorization)
|
|
83
|
+
return null;
|
|
84
|
+
const [authType, authToken] = authorization.split(' ');
|
|
85
|
+
if (authType?.toLowerCase() !== 'bearer' || !authToken) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return authToken;
|
|
89
|
+
};
|
|
90
|
+
const queryRlsModuleByDatabaseId = async (pool, databaseId) => {
|
|
91
|
+
const result = await pool.query(RLS_MODULE_BY_DATABASE_ID_SQL, [databaseId]);
|
|
92
|
+
return toRlsModule(result.rows[0] ?? null);
|
|
93
|
+
};
|
|
94
|
+
const queryRlsModuleByApiId = async (pool, apiId) => {
|
|
95
|
+
const result = await pool.query(RLS_MODULE_BY_API_ID_SQL, [apiId]);
|
|
96
|
+
return toRlsModule(result.rows[0] ?? null);
|
|
97
|
+
};
|
|
98
|
+
const queryRlsModuleByDbname = async (pool, dbname) => {
|
|
99
|
+
const result = await pool.query(RLS_MODULE_BY_DBNAME_SQL, [dbname]);
|
|
100
|
+
return toRlsModule(result.rows[0] ?? null);
|
|
101
|
+
};
|
|
102
|
+
const resolveUploadRlsModule = async (opts, req) => {
|
|
103
|
+
const api = req.api;
|
|
104
|
+
if (!api)
|
|
105
|
+
return undefined;
|
|
106
|
+
// Use API-scoped RLS module when available (e.g., meta API).
|
|
107
|
+
if (api.rlsModule) {
|
|
108
|
+
return api.rlsModule;
|
|
109
|
+
}
|
|
110
|
+
const pool = getPgPool(opts.pg);
|
|
111
|
+
if (api.apiId) {
|
|
112
|
+
const byApiId = await queryRlsModuleByApiId(pool, api.apiId);
|
|
113
|
+
if (byApiId)
|
|
114
|
+
return byApiId;
|
|
115
|
+
}
|
|
116
|
+
if (api.databaseId) {
|
|
117
|
+
const byDatabaseId = await queryRlsModuleByDatabaseId(pool, api.databaseId);
|
|
118
|
+
if (byDatabaseId)
|
|
119
|
+
return byDatabaseId;
|
|
120
|
+
}
|
|
121
|
+
if (api.dbname) {
|
|
122
|
+
return queryRlsModuleByDbname(pool, api.dbname);
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
};
|
|
126
|
+
const authError = (res) => res.status(401).json({ error: 'Authentication required' });
|
|
127
|
+
/**
|
|
128
|
+
* Upload-specific authentication middleware.
|
|
129
|
+
*
|
|
130
|
+
* This middleware enforces strict auth semantics for `POST /upload` while
|
|
131
|
+
* preserving existing GraphQL auth behavior for other routes.
|
|
132
|
+
*/
|
|
133
|
+
export const createUploadAuthenticateMiddleware = (opts) => {
|
|
134
|
+
return async (req, res, next) => {
|
|
135
|
+
const api = req.api;
|
|
136
|
+
if (!api) {
|
|
137
|
+
res.status(500).send('Missing API info');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (req.token?.user_id) {
|
|
141
|
+
next();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const authToken = getBearerToken(req.headers.authorization);
|
|
145
|
+
if (!authToken) {
|
|
146
|
+
authError(res);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
let rlsModule;
|
|
150
|
+
try {
|
|
151
|
+
rlsModule = await resolveUploadRlsModule(opts, req);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
authLog.error('[upload-auth] Failed to resolve RLS module for upload route', error);
|
|
155
|
+
authError(res);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (!rlsModule) {
|
|
159
|
+
authLog.info(`[upload-auth] No RLS module found for db=${api.dbname} databaseId=${api.databaseId ?? 'none'}`);
|
|
160
|
+
authError(res);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const authFn = opts.server?.strictAuth ? rlsModule.authenticateStrict : rlsModule.authenticate;
|
|
164
|
+
const privateSchema = rlsModule.privateSchema?.schemaName;
|
|
165
|
+
if (!authFn || !privateSchema) {
|
|
166
|
+
authLog.warn(`[upload-auth] Missing auth function or private schema for db=${api.dbname}; strictAuth=${opts.server?.strictAuth ?? false}`);
|
|
167
|
+
authError(res);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const SAFE_IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
|
|
171
|
+
if (!SAFE_IDENTIFIER.test(privateSchema) || !SAFE_IDENTIFIER.test(authFn)) {
|
|
172
|
+
authLog.error(`[upload-auth] Invalid SQL identifier: schema=${privateSchema} fn=${authFn}`);
|
|
173
|
+
authError(res);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const pool = getPgPool({
|
|
177
|
+
...opts.pg,
|
|
178
|
+
database: api.dbname,
|
|
179
|
+
});
|
|
180
|
+
const context = {};
|
|
181
|
+
if (req.clientIp) {
|
|
182
|
+
context['jwt.claims.ip_address'] = req.clientIp;
|
|
183
|
+
}
|
|
184
|
+
if (req.get('origin')) {
|
|
185
|
+
context['jwt.claims.origin'] = req.get('origin');
|
|
186
|
+
}
|
|
187
|
+
if (req.get('User-Agent')) {
|
|
188
|
+
context['jwt.claims.user_agent'] = req.get('User-Agent');
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const result = await pgQueryContext({
|
|
192
|
+
client: pool,
|
|
193
|
+
context,
|
|
194
|
+
query: `SELECT * FROM ${escapeIdentifier(privateSchema)}.${escapeIdentifier(authFn)}($1)`,
|
|
195
|
+
variables: [authToken],
|
|
196
|
+
});
|
|
197
|
+
if (!result?.rowCount) {
|
|
198
|
+
authError(res);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
req.token = result.rows[0];
|
|
202
|
+
if (!req.token?.user_id) {
|
|
203
|
+
authError(res);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
next();
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
authLog.warn('[upload-auth] Upload authentication failed', error);
|
|
210
|
+
authError(res);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
/**
|
|
215
|
+
* REST file upload endpoint.
|
|
216
|
+
*
|
|
217
|
+
* Accepts a single file via multipart/form-data, streams it to S3/MinIO,
|
|
218
|
+
* and returns file metadata. The frontend uses this in a two-step flow:
|
|
219
|
+
*
|
|
220
|
+
* 1. POST /upload -> { url, filename, mime, size }
|
|
221
|
+
* 2. GraphQL mutation -> patch row with the returned metadata
|
|
222
|
+
*/
|
|
223
|
+
export const uploadRoute = [
|
|
224
|
+
parseFileWithErrors,
|
|
225
|
+
(async (req, res, next) => {
|
|
226
|
+
if (!req.token?.user_id) {
|
|
227
|
+
return res.status(401).json({ error: 'Authentication required' });
|
|
228
|
+
}
|
|
229
|
+
if (!req.file) {
|
|
230
|
+
return res.status(400).json({ error: 'No file provided. Send a "file" field.' });
|
|
231
|
+
}
|
|
232
|
+
if (req.file.mimetype && BLOCKED_MIME_TYPES.has(req.file.mimetype)) {
|
|
233
|
+
fs.unlink(req.file.path, () => { });
|
|
234
|
+
return res.status(415).json({ error: 'File type not allowed' });
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
const readStream = fs.createReadStream(req.file.path);
|
|
238
|
+
const result = await streamToStorage(readStream, req.file.originalname);
|
|
239
|
+
uploadLog.debug(`[upload] Uploaded file for user=${req.token.user_id} filename=${req.file.originalname} mime=${result.mime} size=${req.file.size}`);
|
|
240
|
+
res.json({
|
|
241
|
+
url: result.url,
|
|
242
|
+
filename: result.filename,
|
|
243
|
+
mime: result.mime,
|
|
244
|
+
size: req.file.size,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
uploadLog.error('[upload] Upload processing failed', error);
|
|
249
|
+
if (!res.headersSent) {
|
|
250
|
+
res.status(500).json({ error: 'Upload processing failed' });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
if (req.file?.path) {
|
|
255
|
+
fs.unlink(req.file.path, () => { });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}),
|
|
259
|
+
];
|
package/esm/server.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { getEnvOptions
|
|
1
|
+
import { getEnvOptions } from '@constructive-io/graphql-env';
|
|
2
2
|
import { Logger } from '@pgpmjs/logger';
|
|
3
3
|
import { healthz, poweredBy, svcCache, trustProxy } from '@pgpmjs/server-utils';
|
|
4
4
|
import { middleware as parseDomains } from '@constructive-io/url-domains';
|
|
5
5
|
import { randomUUID } from 'crypto';
|
|
6
6
|
import express from 'express';
|
|
7
|
-
// @ts-ignore
|
|
8
7
|
import graphqlUpload from 'graphql-upload';
|
|
9
8
|
import { graphileCache, closeAllCaches } from 'graphile-cache';
|
|
10
9
|
import { getPgPool } from 'pg-cache';
|
|
@@ -16,9 +15,10 @@ import { errorHandler, notFoundHandler } from './middleware/error-handler';
|
|
|
16
15
|
import { favicon } from './middleware/favicon';
|
|
17
16
|
import { flush, flushService } from './middleware/flush';
|
|
18
17
|
import { graphile } from './middleware/graphile';
|
|
18
|
+
import { multipartBridge } from './middleware/multipart-bridge';
|
|
19
|
+
import { createUploadAuthenticateMiddleware, uploadRoute } from './middleware/upload';
|
|
19
20
|
import { normalizeServerOptions } from './options';
|
|
20
21
|
const log = new Logger('server');
|
|
21
|
-
const isDev = () => getNodeEnv() === 'development';
|
|
22
22
|
/**
|
|
23
23
|
* Creates and starts a GraphQL server instance
|
|
24
24
|
*
|
|
@@ -64,9 +64,13 @@ class Server {
|
|
|
64
64
|
const app = express();
|
|
65
65
|
const api = createApiMiddleware(effectiveOpts);
|
|
66
66
|
const authenticate = createAuthenticateMiddleware(effectiveOpts);
|
|
67
|
+
const uploadAuthenticate = createUploadAuthenticateMiddleware(effectiveOpts);
|
|
68
|
+
const SAFE_REQUEST_ID = /^[a-zA-Z0-9\-_]{1,128}$/;
|
|
67
69
|
const requestLogger = (req, res, next) => {
|
|
68
70
|
const headerRequestId = req.header('x-request-id');
|
|
69
|
-
const reqId = headerRequestId
|
|
71
|
+
const reqId = (headerRequestId && SAFE_REQUEST_ID.test(headerRequestId))
|
|
72
|
+
? headerRequestId
|
|
73
|
+
: randomUUID();
|
|
70
74
|
const start = process.hrtime.bigint();
|
|
71
75
|
req.requestId = reqId;
|
|
72
76
|
const host = req.hostname || req.headers.host || 'unknown';
|
|
@@ -96,7 +100,7 @@ class Server {
|
|
|
96
100
|
metaSchemas: apiOpts.metaSchemas?.join(',') || 'default',
|
|
97
101
|
exposedSchemas: apiOpts.exposedSchemas?.join(',') || 'none',
|
|
98
102
|
anonRole: apiOpts.anonRole,
|
|
99
|
-
roleName: apiOpts.roleName
|
|
103
|
+
roleName: apiOpts.roleName,
|
|
100
104
|
});
|
|
101
105
|
healthz(app);
|
|
102
106
|
app.use(favicon);
|
|
@@ -113,11 +117,17 @@ class Server {
|
|
|
113
117
|
}
|
|
114
118
|
app.use(poweredBy('constructive'));
|
|
115
119
|
app.use(cors(fallbackOrigin));
|
|
116
|
-
app.use(graphqlUpload.graphqlUploadExpress(
|
|
120
|
+
app.use('/graphql', graphqlUpload.graphqlUploadExpress({
|
|
121
|
+
maxFileSize: 10 * 1024 * 1024, // 10 MB
|
|
122
|
+
maxFiles: 10,
|
|
123
|
+
}));
|
|
124
|
+
// Rewrite Content-Type after graphql-upload so grafserv accepts the request
|
|
125
|
+
app.use('/graphql', multipartBridge);
|
|
117
126
|
app.use(parseDomains());
|
|
118
127
|
app.use(requestIp.mw());
|
|
119
128
|
app.use(requestLogger);
|
|
120
129
|
app.use(api);
|
|
130
|
+
app.post('/upload', uploadAuthenticate, ...uploadRoute);
|
|
121
131
|
app.use(authenticate);
|
|
122
132
|
app.use(graphile(effectiveOpts));
|
|
123
133
|
app.use(flush);
|
package/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from './server';
|
|
|
2
2
|
export * from './options';
|
|
3
3
|
export { createApiMiddleware, getSubdomain, getApiConfig } from './middleware/api';
|
|
4
4
|
export { createAuthenticateMiddleware } from './middleware/auth';
|
|
5
|
+
export { createUploadAuthenticateMiddleware } from './middleware/upload';
|
|
5
6
|
export { cors } from './middleware/cors';
|
|
6
7
|
export { graphile } from './middleware/graphile';
|
|
7
8
|
export { flush, flushService } from './middleware/flush';
|
package/index.js
CHANGED
|
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.flushService = exports.flush = exports.graphile = exports.cors = exports.createAuthenticateMiddleware = exports.getApiConfig = exports.getSubdomain = exports.createApiMiddleware = void 0;
|
|
17
|
+
exports.flushService = exports.flush = exports.graphile = exports.cors = exports.createUploadAuthenticateMiddleware = exports.createAuthenticateMiddleware = exports.getApiConfig = exports.getSubdomain = exports.createApiMiddleware = void 0;
|
|
18
18
|
__exportStar(require("./server"), exports);
|
|
19
19
|
// Export options module - types, defaults, type guards, and utility functions
|
|
20
20
|
__exportStar(require("./options"), exports);
|
|
@@ -25,6 +25,8 @@ Object.defineProperty(exports, "getSubdomain", { enumerable: true, get: function
|
|
|
25
25
|
Object.defineProperty(exports, "getApiConfig", { enumerable: true, get: function () { return api_1.getApiConfig; } });
|
|
26
26
|
var auth_1 = require("./middleware/auth");
|
|
27
27
|
Object.defineProperty(exports, "createAuthenticateMiddleware", { enumerable: true, get: function () { return auth_1.createAuthenticateMiddleware; } });
|
|
28
|
+
var upload_1 = require("./middleware/upload");
|
|
29
|
+
Object.defineProperty(exports, "createUploadAuthenticateMiddleware", { enumerable: true, get: function () { return upload_1.createUploadAuthenticateMiddleware; } });
|
|
28
30
|
var cors_1 = require("./middleware/cors");
|
|
29
31
|
Object.defineProperty(exports, "cors", { enumerable: true, get: function () { return cors_1.cors; } });
|
|
30
32
|
var graphile_1 = require("./middleware/graphile");
|
package/middleware/api.js
CHANGED
|
@@ -132,6 +132,7 @@ const toRlsModule = (row) => {
|
|
|
132
132
|
};
|
|
133
133
|
};
|
|
134
134
|
const toApiStructure = (row, opts, rlsModuleRow) => ({
|
|
135
|
+
apiId: row.api_id,
|
|
135
136
|
dbname: row.dbname || opts.pg?.database || '',
|
|
136
137
|
anonRole: row.anon_role || 'anon',
|
|
137
138
|
roleName: row.role_name || 'authenticated',
|
|
@@ -82,13 +82,12 @@ const errorHandler = (err, req, res, _next) => {
|
|
|
82
82
|
};
|
|
83
83
|
exports.errorHandler = errorHandler;
|
|
84
84
|
const notFoundHandler = (req, res, _next) => {
|
|
85
|
-
const message = `Route not found: ${req.method} ${req.path}`;
|
|
86
85
|
log.warn({ event: 'route_not_found', path: req.path, method: req.method, requestId: req.requestId });
|
|
87
86
|
if (wantsJson(req)) {
|
|
88
|
-
res.status(404).json({ error: { code: 'NOT_FOUND', message, requestId: req.requestId } });
|
|
87
|
+
res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Not found', requestId: req.requestId } });
|
|
89
88
|
}
|
|
90
89
|
else {
|
|
91
|
-
res.status(404).send((0, _404_message_1.default)(
|
|
90
|
+
res.status(404).send((0, _404_message_1.default)('The requested page was not found'));
|
|
92
91
|
}
|
|
93
92
|
};
|
|
94
93
|
exports.notFoundHandler = notFoundHandler;
|
package/middleware/graphile.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { ConstructiveOptions } from '@constructive-io/graphql-types';
|
|
2
|
-
import { RequestHandler } from 'express';
|
|
1
|
+
import type { ConstructiveOptions } from '@constructive-io/graphql-types';
|
|
2
|
+
import type { RequestHandler } from 'express';
|
|
3
3
|
import './types';
|
|
4
4
|
/**
|
|
5
5
|
* Returns the number of currently in-flight handler creation operations.
|
package/middleware/graphile.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.graphile = void 0;
|
|
4
7
|
exports.getInFlightCount = getInFlightCount;
|
|
5
8
|
exports.getInFlightKeys = getInFlightKeys;
|
|
6
9
|
exports.clearInFlightMap = clearInFlightMap;
|
|
10
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
11
|
+
const graphql_env_1 = require("@constructive-io/graphql-env");
|
|
7
12
|
const logger_1 = require("@pgpmjs/logger");
|
|
8
13
|
const graphile_cache_1 = require("graphile-cache");
|
|
9
14
|
const graphile_settings_1 = require("graphile-settings");
|
|
@@ -11,24 +16,71 @@ const pg_cache_1 = require("pg-cache");
|
|
|
11
16
|
const pg_env_1 = require("pg-env");
|
|
12
17
|
require("./types"); // for Request type
|
|
13
18
|
const api_errors_1 = require("../errors/api-errors");
|
|
19
|
+
const maskErrorLog = new logger_1.Logger('graphile:maskError');
|
|
20
|
+
const SAFE_ERROR_CODES = new Set([
|
|
21
|
+
// GraphQL standard
|
|
22
|
+
'GRAPHQL_VALIDATION_FAILED',
|
|
23
|
+
'GRAPHQL_PARSE_FAILED',
|
|
24
|
+
'PERSISTED_QUERY_NOT_FOUND',
|
|
25
|
+
'PERSISTED_QUERY_NOT_SUPPORTED',
|
|
26
|
+
// Auth
|
|
27
|
+
'UNAUTHENTICATED',
|
|
28
|
+
'FORBIDDEN',
|
|
29
|
+
'BAD_USER_INPUT',
|
|
30
|
+
'INCORRECT_PASSWORD',
|
|
31
|
+
'PASSWORD_INSECURE',
|
|
32
|
+
'ACCOUNT_LOCKED_EXCEED_ATTEMPTS',
|
|
33
|
+
'ACCOUNT_DISABLED',
|
|
34
|
+
'ACCOUNT_EXISTS',
|
|
35
|
+
'PASSWORD_LEN',
|
|
36
|
+
'INVITE_NOT_FOUND',
|
|
37
|
+
'INVITE_LIMIT',
|
|
38
|
+
'INVITE_EMAIL_NOT_FOUND',
|
|
39
|
+
'INVALID_CREDENTIALS',
|
|
40
|
+
// PublicKeySignature
|
|
41
|
+
'FEATURE_DISABLED',
|
|
42
|
+
'INVALID_PUBLIC_KEY',
|
|
43
|
+
'INVALID_MESSAGE',
|
|
44
|
+
'INVALID_SIGNATURE',
|
|
45
|
+
'NO_ACCOUNT_EXISTS',
|
|
46
|
+
'BAD_SIGNIN',
|
|
47
|
+
// Upload
|
|
48
|
+
'UPLOAD_MIMETYPE',
|
|
49
|
+
// PostgreSQL constraint violations (surfaced by PostGraphile)
|
|
50
|
+
'23505', // unique_violation
|
|
51
|
+
'23503', // foreign_key_violation
|
|
52
|
+
'23502', // not_null_violation
|
|
53
|
+
'23514', // check_violation
|
|
54
|
+
'23P01', // exclusion_violation
|
|
55
|
+
]);
|
|
14
56
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* By default, grafserv masks errors for security (hiding sensitive database errors
|
|
18
|
-
* from clients). We disable this masking to show full error messages.
|
|
57
|
+
* Production-aware error masking function.
|
|
19
58
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* -
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* If you need to restore masking behavior, see the upstream implementation which:
|
|
26
|
-
* 1. Returns GraphQLError instances as-is
|
|
27
|
-
* 2. Returns SafeError instances with their message exposed
|
|
28
|
-
* 3. Masks other errors with a hash/ID and logs the original
|
|
59
|
+
* In development: returns errors as-is for debugging.
|
|
60
|
+
* In production: returns errors with explicit codes from the SAFE_ERROR_CODES
|
|
61
|
+
* allowlist as-is, but masks unexpected/database errors with a reference ID
|
|
62
|
+
* and logs the original.
|
|
29
63
|
*/
|
|
30
64
|
const maskError = (error) => {
|
|
31
|
-
|
|
65
|
+
if ((0, graphql_env_1.getNodeEnv)() === 'development') {
|
|
66
|
+
return error;
|
|
67
|
+
}
|
|
68
|
+
// Only expose errors with codes on the safe allowlist.
|
|
69
|
+
// Note: grafserv strips originalError and internal extensions before
|
|
70
|
+
// serializing to the client, so returning the full error object is safe here.
|
|
71
|
+
if (error.extensions?.code && SAFE_ERROR_CODES.has(error.extensions.code)) {
|
|
72
|
+
return error;
|
|
73
|
+
}
|
|
74
|
+
// Mask unexpected/database errors with a reference ID
|
|
75
|
+
const errorId = node_crypto_1.default.randomBytes(8).toString('hex');
|
|
76
|
+
maskErrorLog.error(`[masked-error:${errorId}]`, error);
|
|
77
|
+
return {
|
|
78
|
+
message: `An unexpected error occurred. Reference: ${errorId}`,
|
|
79
|
+
extensions: {
|
|
80
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
81
|
+
errorId,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
32
84
|
};
|
|
33
85
|
// =============================================================================
|
|
34
86
|
// Single-Flight Pattern: In-Flight Tracking
|
|
@@ -60,7 +112,7 @@ function clearInFlightMap() {
|
|
|
60
112
|
creating.clear();
|
|
61
113
|
}
|
|
62
114
|
const log = new logger_1.Logger('graphile');
|
|
63
|
-
const reqLabel = (req) => req.requestId ? `[${req.requestId}]` : '[req]';
|
|
115
|
+
const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
|
|
64
116
|
/**
|
|
65
117
|
* Build a PostGraphile v5 preset for a tenant
|
|
66
118
|
*/
|
|
@@ -76,6 +128,7 @@ const buildPreset = (connectionString, schemas, anonRole, roleName) => ({
|
|
|
76
128
|
graphqlPath: '/graphql',
|
|
77
129
|
graphiqlPath: '/graphiql',
|
|
78
130
|
graphiql: true,
|
|
131
|
+
graphiqlOnGraphQLGET: false,
|
|
79
132
|
maskError,
|
|
80
133
|
},
|
|
81
134
|
grafast: {
|
|
@@ -153,13 +206,26 @@ const graphile = (opts) => {
|
|
|
153
206
|
return instance.handler(req, res, next);
|
|
154
207
|
}
|
|
155
208
|
catch (error) {
|
|
156
|
-
|
|
157
|
-
|
|
209
|
+
log.warn(`${label} Coalesced request failed for PostGraphile[${key}], retrying`);
|
|
210
|
+
// Fall through to Phase C to retry creation
|
|
158
211
|
}
|
|
159
212
|
}
|
|
160
213
|
// =========================================================================
|
|
161
214
|
// Phase C: Create New Handler (first request for this key)
|
|
162
215
|
// =========================================================================
|
|
216
|
+
// Re-check cache after coalesced request failure (another retry may have succeeded)
|
|
217
|
+
const recheckedCache = graphile_cache_1.graphileCache.get(key);
|
|
218
|
+
if (recheckedCache) {
|
|
219
|
+
log.debug(`${label} PostGraphile cache hit on re-check key=${key}`);
|
|
220
|
+
return recheckedCache.handler(req, res, next);
|
|
221
|
+
}
|
|
222
|
+
// Re-check in-flight map (another retry may have started creation)
|
|
223
|
+
const retryInFlight = creating.get(key);
|
|
224
|
+
if (retryInFlight) {
|
|
225
|
+
log.debug(`${label} Re-coalescing request for PostGraphile[${key}]`);
|
|
226
|
+
const retryInstance = await retryInFlight;
|
|
227
|
+
return retryInstance.handler(req, res, next);
|
|
228
|
+
}
|
|
163
229
|
log.info(`${label} Building PostGraphile v5 handler key=${key} db=${dbname} schemas=${schemaLabel} role=${roleName} anon=${anonRole}`);
|
|
164
230
|
const pgConfig = (0, pg_env_1.getPgEnvOptions)({
|
|
165
231
|
...opts.pg,
|
|
@@ -178,7 +244,10 @@ const graphile = (opts) => {
|
|
|
178
244
|
}
|
|
179
245
|
catch (error) {
|
|
180
246
|
log.error(`${label} Failed to create PostGraphile[${key}]:`, error);
|
|
181
|
-
throw new api_errors_1.HandlerCreationError(`Failed to create handler for ${key}: ${error instanceof Error ? error.message : String(error)}`, {
|
|
247
|
+
throw new api_errors_1.HandlerCreationError(`Failed to create handler for ${key}: ${error instanceof Error ? error.message : String(error)}`, {
|
|
248
|
+
cacheKey: key,
|
|
249
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
250
|
+
});
|
|
182
251
|
}
|
|
183
252
|
finally {
|
|
184
253
|
// Always clean up in-flight tracker
|
|
@@ -187,7 +256,12 @@ const graphile = (opts) => {
|
|
|
187
256
|
}
|
|
188
257
|
catch (e) {
|
|
189
258
|
log.error(`${label} PostGraphile middleware error`, e);
|
|
190
|
-
|
|
259
|
+
if (!res.headersSent) {
|
|
260
|
+
return res.status(500).json({
|
|
261
|
+
error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' }
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
next(e);
|
|
191
265
|
}
|
|
192
266
|
};
|
|
193
267
|
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { RequestHandler } from 'express';
|
|
2
|
+
/**
|
|
3
|
+
* Bridge middleware between graphql-upload and grafserv (PostGraphile v5).
|
|
4
|
+
*
|
|
5
|
+
* graphql-upload's `graphqlUploadExpress()` parses multipart/form-data requests
|
|
6
|
+
* and sets `req.body` to a standard GraphQL operation object, but leaves the
|
|
7
|
+
* original Content-Type header unchanged. grafserv rejects any Content-Type it
|
|
8
|
+
* doesn't recognize (415 Unsupported Media Type) because its allowlist only
|
|
9
|
+
* includes `application/json` and `application/graphql`.
|
|
10
|
+
*
|
|
11
|
+
* This middleware rewrites the Content-Type header to `application/json` after
|
|
12
|
+
* graphql-upload has finished parsing, so grafserv accepts the request.
|
|
13
|
+
*
|
|
14
|
+
* This is the standard bridge pattern for grafserv + graphql-upload.
|
|
15
|
+
* See: https://github.com/graphql/graphql-http/discussions/36
|
|
16
|
+
*
|
|
17
|
+
* Must be registered immediately after `graphqlUploadExpress()` in the
|
|
18
|
+
* middleware chain — it relies on graphql-upload having already parsed the
|
|
19
|
+
* multipart body into `req.body`.
|
|
20
|
+
*
|
|
21
|
+
* Security note: multipart/form-data is a CORS "simple request" content type,
|
|
22
|
+
* which means browsers send it without a preflight OPTIONS check. The CSRF risk
|
|
23
|
+
* is mitigated by the CORS middleware that runs before this middleware in the
|
|
24
|
+
* Express pipeline.
|
|
25
|
+
*/
|
|
26
|
+
export declare const multipartBridge: RequestHandler;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.multipartBridge = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Bridge middleware between graphql-upload and grafserv (PostGraphile v5).
|
|
6
|
+
*
|
|
7
|
+
* graphql-upload's `graphqlUploadExpress()` parses multipart/form-data requests
|
|
8
|
+
* and sets `req.body` to a standard GraphQL operation object, but leaves the
|
|
9
|
+
* original Content-Type header unchanged. grafserv rejects any Content-Type it
|
|
10
|
+
* doesn't recognize (415 Unsupported Media Type) because its allowlist only
|
|
11
|
+
* includes `application/json` and `application/graphql`.
|
|
12
|
+
*
|
|
13
|
+
* This middleware rewrites the Content-Type header to `application/json` after
|
|
14
|
+
* graphql-upload has finished parsing, so grafserv accepts the request.
|
|
15
|
+
*
|
|
16
|
+
* This is the standard bridge pattern for grafserv + graphql-upload.
|
|
17
|
+
* See: https://github.com/graphql/graphql-http/discussions/36
|
|
18
|
+
*
|
|
19
|
+
* Must be registered immediately after `graphqlUploadExpress()` in the
|
|
20
|
+
* middleware chain — it relies on graphql-upload having already parsed the
|
|
21
|
+
* multipart body into `req.body`.
|
|
22
|
+
*
|
|
23
|
+
* Security note: multipart/form-data is a CORS "simple request" content type,
|
|
24
|
+
* which means browsers send it without a preflight OPTIONS check. The CSRF risk
|
|
25
|
+
* is mitigated by the CORS middleware that runs before this middleware in the
|
|
26
|
+
* Express pipeline.
|
|
27
|
+
*/
|
|
28
|
+
const multipartBridge = (req, _res, next) => {
|
|
29
|
+
if (req.body != null && req.headers['content-type']?.startsWith('multipart/form-data')) {
|
|
30
|
+
req.headers['content-type'] = 'application/json';
|
|
31
|
+
}
|
|
32
|
+
next();
|
|
33
|
+
};
|
|
34
|
+
exports.multipartBridge = multipartBridge;
|
package/middleware/types.d.ts
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PgpmOptions } from '@pgpmjs/types';
|
|
2
|
+
import type { RequestHandler } from 'express';
|
|
3
|
+
import './types';
|
|
4
|
+
/**
|
|
5
|
+
* Upload-specific authentication middleware.
|
|
6
|
+
*
|
|
7
|
+
* This middleware enforces strict auth semantics for `POST /upload` while
|
|
8
|
+
* preserving existing GraphQL auth behavior for other routes.
|
|
9
|
+
*/
|
|
10
|
+
export declare const createUploadAuthenticateMiddleware: (opts: PgpmOptions) => RequestHandler;
|
|
11
|
+
/**
|
|
12
|
+
* REST file upload endpoint.
|
|
13
|
+
*
|
|
14
|
+
* Accepts a single file via multipart/form-data, streams it to S3/MinIO,
|
|
15
|
+
* and returns file metadata. The frontend uses this in a two-step flow:
|
|
16
|
+
*
|
|
17
|
+
* 1. POST /upload -> { url, filename, mime, size }
|
|
18
|
+
* 2. GraphQL mutation -> patch row with the returned metadata
|
|
19
|
+
*/
|
|
20
|
+
export declare const uploadRoute: RequestHandler[];
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.uploadRoute = exports.createUploadAuthenticateMiddleware = void 0;
|
|
7
|
+
const logger_1 = require("@pgpmjs/logger");
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const multer_1 = __importDefault(require("multer"));
|
|
10
|
+
const os_1 = __importDefault(require("os"));
|
|
11
|
+
const pg_1 = require("pg");
|
|
12
|
+
const pg_cache_1 = require("pg-cache");
|
|
13
|
+
const pg_query_context_1 = __importDefault(require("pg-query-context"));
|
|
14
|
+
const graphile_settings_1 = require("graphile-settings");
|
|
15
|
+
require("./types");
|
|
16
|
+
const uploadLog = new logger_1.Logger('upload');
|
|
17
|
+
const authLog = new logger_1.Logger('upload-auth');
|
|
18
|
+
const envFileSize = process.env.MAX_UPLOAD_FILE_SIZE
|
|
19
|
+
? parseInt(process.env.MAX_UPLOAD_FILE_SIZE, 10)
|
|
20
|
+
: NaN;
|
|
21
|
+
const MAX_FILE_SIZE = envFileSize > 0 ? envFileSize : 10 * 1024 * 1024;
|
|
22
|
+
const BLOCKED_MIME_TYPES = new Set([
|
|
23
|
+
'application/x-executable',
|
|
24
|
+
'application/x-sharedlib',
|
|
25
|
+
'application/x-mach-binary',
|
|
26
|
+
'application/x-dosexec',
|
|
27
|
+
'text/html',
|
|
28
|
+
'application/xhtml+xml',
|
|
29
|
+
'application/javascript',
|
|
30
|
+
'text/javascript'
|
|
31
|
+
]);
|
|
32
|
+
const parseFile = (0, multer_1.default)({
|
|
33
|
+
storage: multer_1.default.diskStorage({
|
|
34
|
+
destination: os_1.default.tmpdir(),
|
|
35
|
+
filename: (_req, _file, cb) => {
|
|
36
|
+
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
|
|
37
|
+
cb(null, `upload-${uniqueSuffix}.tmp`);
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
limits: { fileSize: MAX_FILE_SIZE },
|
|
41
|
+
}).single('file');
|
|
42
|
+
const parseFileWithErrors = (req, res, next) => {
|
|
43
|
+
parseFile(req, res, (err) => {
|
|
44
|
+
if (!err)
|
|
45
|
+
return next();
|
|
46
|
+
if (err.code === 'LIMIT_FILE_SIZE') {
|
|
47
|
+
return res.status(413).json({ error: `File exceeds maximum size of ${MAX_FILE_SIZE / (1024 * 1024)} MB` });
|
|
48
|
+
}
|
|
49
|
+
if (err.code === 'LIMIT_UNEXPECTED_FILE') {
|
|
50
|
+
return res.status(400).json({ error: 'Unexpected file field. Send a single file as "file".' });
|
|
51
|
+
}
|
|
52
|
+
return res.status(400).json({ error: 'File upload failed' });
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
const RLS_MODULE_BASE_SQL = `
|
|
56
|
+
SELECT
|
|
57
|
+
rm.authenticate,
|
|
58
|
+
rm.authenticate_strict,
|
|
59
|
+
ps.schema_name as private_schema_name
|
|
60
|
+
FROM metaschema_modules_public.rls_module rm
|
|
61
|
+
LEFT JOIN metaschema_public.schema ps ON rm.private_schema_id = ps.id`;
|
|
62
|
+
const RLS_MODULE_BY_DATABASE_ID_SQL = `${RLS_MODULE_BASE_SQL}
|
|
63
|
+
JOIN services_public.apis a ON rm.api_id = a.id
|
|
64
|
+
WHERE a.database_id = $1
|
|
65
|
+
ORDER BY a.id
|
|
66
|
+
LIMIT 1
|
|
67
|
+
`;
|
|
68
|
+
const RLS_MODULE_BY_API_ID_SQL = `${RLS_MODULE_BASE_SQL}
|
|
69
|
+
WHERE rm.api_id = $1
|
|
70
|
+
LIMIT 1
|
|
71
|
+
`;
|
|
72
|
+
const RLS_MODULE_BY_DBNAME_SQL = `${RLS_MODULE_BASE_SQL}
|
|
73
|
+
JOIN services_public.apis a ON rm.api_id = a.id
|
|
74
|
+
WHERE a.dbname = $1
|
|
75
|
+
ORDER BY a.id
|
|
76
|
+
LIMIT 1
|
|
77
|
+
`;
|
|
78
|
+
const toRlsModule = (row) => {
|
|
79
|
+
if (!row || !row.private_schema_name)
|
|
80
|
+
return undefined;
|
|
81
|
+
return {
|
|
82
|
+
authenticate: row.authenticate ?? undefined,
|
|
83
|
+
authenticateStrict: row.authenticate_strict ?? undefined,
|
|
84
|
+
privateSchema: { schemaName: row.private_schema_name },
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
const getBearerToken = (authorization) => {
|
|
88
|
+
if (!authorization)
|
|
89
|
+
return null;
|
|
90
|
+
const [authType, authToken] = authorization.split(' ');
|
|
91
|
+
if (authType?.toLowerCase() !== 'bearer' || !authToken) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return authToken;
|
|
95
|
+
};
|
|
96
|
+
const queryRlsModuleByDatabaseId = async (pool, databaseId) => {
|
|
97
|
+
const result = await pool.query(RLS_MODULE_BY_DATABASE_ID_SQL, [databaseId]);
|
|
98
|
+
return toRlsModule(result.rows[0] ?? null);
|
|
99
|
+
};
|
|
100
|
+
const queryRlsModuleByApiId = async (pool, apiId) => {
|
|
101
|
+
const result = await pool.query(RLS_MODULE_BY_API_ID_SQL, [apiId]);
|
|
102
|
+
return toRlsModule(result.rows[0] ?? null);
|
|
103
|
+
};
|
|
104
|
+
const queryRlsModuleByDbname = async (pool, dbname) => {
|
|
105
|
+
const result = await pool.query(RLS_MODULE_BY_DBNAME_SQL, [dbname]);
|
|
106
|
+
return toRlsModule(result.rows[0] ?? null);
|
|
107
|
+
};
|
|
108
|
+
const resolveUploadRlsModule = async (opts, req) => {
|
|
109
|
+
const api = req.api;
|
|
110
|
+
if (!api)
|
|
111
|
+
return undefined;
|
|
112
|
+
// Use API-scoped RLS module when available (e.g., meta API).
|
|
113
|
+
if (api.rlsModule) {
|
|
114
|
+
return api.rlsModule;
|
|
115
|
+
}
|
|
116
|
+
const pool = (0, pg_cache_1.getPgPool)(opts.pg);
|
|
117
|
+
if (api.apiId) {
|
|
118
|
+
const byApiId = await queryRlsModuleByApiId(pool, api.apiId);
|
|
119
|
+
if (byApiId)
|
|
120
|
+
return byApiId;
|
|
121
|
+
}
|
|
122
|
+
if (api.databaseId) {
|
|
123
|
+
const byDatabaseId = await queryRlsModuleByDatabaseId(pool, api.databaseId);
|
|
124
|
+
if (byDatabaseId)
|
|
125
|
+
return byDatabaseId;
|
|
126
|
+
}
|
|
127
|
+
if (api.dbname) {
|
|
128
|
+
return queryRlsModuleByDbname(pool, api.dbname);
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
};
|
|
132
|
+
const authError = (res) => res.status(401).json({ error: 'Authentication required' });
|
|
133
|
+
/**
|
|
134
|
+
* Upload-specific authentication middleware.
|
|
135
|
+
*
|
|
136
|
+
* This middleware enforces strict auth semantics for `POST /upload` while
|
|
137
|
+
* preserving existing GraphQL auth behavior for other routes.
|
|
138
|
+
*/
|
|
139
|
+
const createUploadAuthenticateMiddleware = (opts) => {
|
|
140
|
+
return async (req, res, next) => {
|
|
141
|
+
const api = req.api;
|
|
142
|
+
if (!api) {
|
|
143
|
+
res.status(500).send('Missing API info');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (req.token?.user_id) {
|
|
147
|
+
next();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const authToken = getBearerToken(req.headers.authorization);
|
|
151
|
+
if (!authToken) {
|
|
152
|
+
authError(res);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
let rlsModule;
|
|
156
|
+
try {
|
|
157
|
+
rlsModule = await resolveUploadRlsModule(opts, req);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
authLog.error('[upload-auth] Failed to resolve RLS module for upload route', error);
|
|
161
|
+
authError(res);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (!rlsModule) {
|
|
165
|
+
authLog.info(`[upload-auth] No RLS module found for db=${api.dbname} databaseId=${api.databaseId ?? 'none'}`);
|
|
166
|
+
authError(res);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const authFn = opts.server?.strictAuth ? rlsModule.authenticateStrict : rlsModule.authenticate;
|
|
170
|
+
const privateSchema = rlsModule.privateSchema?.schemaName;
|
|
171
|
+
if (!authFn || !privateSchema) {
|
|
172
|
+
authLog.warn(`[upload-auth] Missing auth function or private schema for db=${api.dbname}; strictAuth=${opts.server?.strictAuth ?? false}`);
|
|
173
|
+
authError(res);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const SAFE_IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
|
|
177
|
+
if (!SAFE_IDENTIFIER.test(privateSchema) || !SAFE_IDENTIFIER.test(authFn)) {
|
|
178
|
+
authLog.error(`[upload-auth] Invalid SQL identifier: schema=${privateSchema} fn=${authFn}`);
|
|
179
|
+
authError(res);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const pool = (0, pg_cache_1.getPgPool)({
|
|
183
|
+
...opts.pg,
|
|
184
|
+
database: api.dbname,
|
|
185
|
+
});
|
|
186
|
+
const context = {};
|
|
187
|
+
if (req.clientIp) {
|
|
188
|
+
context['jwt.claims.ip_address'] = req.clientIp;
|
|
189
|
+
}
|
|
190
|
+
if (req.get('origin')) {
|
|
191
|
+
context['jwt.claims.origin'] = req.get('origin');
|
|
192
|
+
}
|
|
193
|
+
if (req.get('User-Agent')) {
|
|
194
|
+
context['jwt.claims.user_agent'] = req.get('User-Agent');
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const result = await (0, pg_query_context_1.default)({
|
|
198
|
+
client: pool,
|
|
199
|
+
context,
|
|
200
|
+
query: `SELECT * FROM ${(0, pg_1.escapeIdentifier)(privateSchema)}.${(0, pg_1.escapeIdentifier)(authFn)}($1)`,
|
|
201
|
+
variables: [authToken],
|
|
202
|
+
});
|
|
203
|
+
if (!result?.rowCount) {
|
|
204
|
+
authError(res);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
req.token = result.rows[0];
|
|
208
|
+
if (!req.token?.user_id) {
|
|
209
|
+
authError(res);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
next();
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
authLog.warn('[upload-auth] Upload authentication failed', error);
|
|
216
|
+
authError(res);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
};
|
|
220
|
+
exports.createUploadAuthenticateMiddleware = createUploadAuthenticateMiddleware;
|
|
221
|
+
/**
|
|
222
|
+
* REST file upload endpoint.
|
|
223
|
+
*
|
|
224
|
+
* Accepts a single file via multipart/form-data, streams it to S3/MinIO,
|
|
225
|
+
* and returns file metadata. The frontend uses this in a two-step flow:
|
|
226
|
+
*
|
|
227
|
+
* 1. POST /upload -> { url, filename, mime, size }
|
|
228
|
+
* 2. GraphQL mutation -> patch row with the returned metadata
|
|
229
|
+
*/
|
|
230
|
+
exports.uploadRoute = [
|
|
231
|
+
parseFileWithErrors,
|
|
232
|
+
(async (req, res, next) => {
|
|
233
|
+
if (!req.token?.user_id) {
|
|
234
|
+
return res.status(401).json({ error: 'Authentication required' });
|
|
235
|
+
}
|
|
236
|
+
if (!req.file) {
|
|
237
|
+
return res.status(400).json({ error: 'No file provided. Send a "file" field.' });
|
|
238
|
+
}
|
|
239
|
+
if (req.file.mimetype && BLOCKED_MIME_TYPES.has(req.file.mimetype)) {
|
|
240
|
+
fs_1.default.unlink(req.file.path, () => { });
|
|
241
|
+
return res.status(415).json({ error: 'File type not allowed' });
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
const readStream = fs_1.default.createReadStream(req.file.path);
|
|
245
|
+
const result = await (0, graphile_settings_1.streamToStorage)(readStream, req.file.originalname);
|
|
246
|
+
uploadLog.debug(`[upload] Uploaded file for user=${req.token.user_id} filename=${req.file.originalname} mime=${result.mime} size=${req.file.size}`);
|
|
247
|
+
res.json({
|
|
248
|
+
url: result.url,
|
|
249
|
+
filename: result.filename,
|
|
250
|
+
mime: result.mime,
|
|
251
|
+
size: req.file.size,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
uploadLog.error('[upload] Upload processing failed', error);
|
|
256
|
+
if (!res.headersSent) {
|
|
257
|
+
res.status(500).json({ error: 'Upload processing failed' });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
if (req.file?.path) {
|
|
262
|
+
fs_1.default.unlink(req.file.path, () => { });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}),
|
|
266
|
+
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@constructive-io/graphql-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.1",
|
|
4
4
|
"author": "Constructive <developers@constructive.io>",
|
|
5
5
|
"description": "Constructive GraphQL Server",
|
|
6
6
|
"main": "index.js",
|
|
@@ -39,34 +39,36 @@
|
|
|
39
39
|
"backend"
|
|
40
40
|
],
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@constructive-io/graphql-env": "^3.1
|
|
43
|
-
"@constructive-io/graphql-types": "^3.
|
|
44
|
-
"@constructive-io/s3-utils": "^2.
|
|
45
|
-
"@constructive-io/upload-names": "^2.
|
|
46
|
-
"@constructive-io/url-domains": "^2.
|
|
42
|
+
"@constructive-io/graphql-env": "^3.2.1",
|
|
43
|
+
"@constructive-io/graphql-types": "^3.1.1",
|
|
44
|
+
"@constructive-io/s3-utils": "^2.7.0",
|
|
45
|
+
"@constructive-io/upload-names": "^2.7.0",
|
|
46
|
+
"@constructive-io/url-domains": "^2.7.0",
|
|
47
47
|
"@graphile-contrib/pg-many-to-many": "2.0.0-rc.1",
|
|
48
48
|
"@graphile/simplify-inflection": "8.0.0-rc.3",
|
|
49
|
-
"@pgpmjs/logger": "^2.
|
|
50
|
-
"@pgpmjs/server-utils": "^3.
|
|
51
|
-
"@pgpmjs/types": "^2.
|
|
49
|
+
"@pgpmjs/logger": "^2.2.0",
|
|
50
|
+
"@pgpmjs/server-utils": "^3.2.0",
|
|
51
|
+
"@pgpmjs/types": "^2.17.0",
|
|
52
52
|
"cors": "^2.8.5",
|
|
53
53
|
"deepmerge": "^4.3.1",
|
|
54
54
|
"express": "^5.2.1",
|
|
55
|
-
"gql-ast": "^3.
|
|
55
|
+
"gql-ast": "^3.1.0",
|
|
56
56
|
"grafast": "1.0.0-rc.7",
|
|
57
57
|
"grafserv": "1.0.0-rc.6",
|
|
58
58
|
"graphile-build": "5.0.0-rc.4",
|
|
59
59
|
"graphile-build-pg": "5.0.0-rc.5",
|
|
60
|
-
"graphile-cache": "^3.0
|
|
60
|
+
"graphile-cache": "^3.1.0",
|
|
61
61
|
"graphile-config": "1.0.0-rc.5",
|
|
62
|
-
"graphile-settings": "^4.
|
|
62
|
+
"graphile-settings": "^4.3.1",
|
|
63
|
+
"graphile-utils": "5.0.0-rc.5",
|
|
63
64
|
"graphql": "^16.9.0",
|
|
64
65
|
"graphql-upload": "^13.0.0",
|
|
65
66
|
"lru-cache": "^11.2.4",
|
|
67
|
+
"multer": "^2.0.1",
|
|
66
68
|
"pg": "^8.17.1",
|
|
67
|
-
"pg-cache": "^3.
|
|
68
|
-
"pg-env": "^1.
|
|
69
|
-
"pg-query-context": "^2.
|
|
69
|
+
"pg-cache": "^3.1.0",
|
|
70
|
+
"pg-env": "^1.5.0",
|
|
71
|
+
"pg-query-context": "^2.6.0",
|
|
70
72
|
"pg-sql2": "5.0.0-rc.4",
|
|
71
73
|
"postgraphile": "5.0.0-rc.7",
|
|
72
74
|
"postgraphile-plugin-connection-filter": "3.0.0-rc.1",
|
|
@@ -77,12 +79,13 @@
|
|
|
77
79
|
"@types/cors": "^2.8.17",
|
|
78
80
|
"@types/express": "^5.0.6",
|
|
79
81
|
"@types/graphql-upload": "^8.0.12",
|
|
82
|
+
"@types/multer": "^1.4.12",
|
|
80
83
|
"@types/pg": "^8.16.0",
|
|
81
84
|
"@types/request-ip": "^0.0.41",
|
|
82
|
-
"graphile-test": "4.
|
|
85
|
+
"graphile-test": "4.2.1",
|
|
83
86
|
"makage": "^0.1.10",
|
|
84
87
|
"nodemon": "^3.1.10",
|
|
85
88
|
"ts-node": "^10.9.2"
|
|
86
89
|
},
|
|
87
|
-
"gitHead": "
|
|
90
|
+
"gitHead": "2df0ba1ebe42ff08b498070d476da3527cf6b7cd"
|
|
88
91
|
}
|
package/server.js
CHANGED
|
@@ -10,7 +10,6 @@ const server_utils_1 = require("@pgpmjs/server-utils");
|
|
|
10
10
|
const url_domains_1 = require("@constructive-io/url-domains");
|
|
11
11
|
const crypto_1 = require("crypto");
|
|
12
12
|
const express_1 = __importDefault(require("express"));
|
|
13
|
-
// @ts-ignore
|
|
14
13
|
const graphql_upload_1 = __importDefault(require("graphql-upload"));
|
|
15
14
|
const graphile_cache_1 = require("graphile-cache");
|
|
16
15
|
const pg_cache_1 = require("pg-cache");
|
|
@@ -22,9 +21,10 @@ const error_handler_1 = require("./middleware/error-handler");
|
|
|
22
21
|
const favicon_1 = require("./middleware/favicon");
|
|
23
22
|
const flush_1 = require("./middleware/flush");
|
|
24
23
|
const graphile_1 = require("./middleware/graphile");
|
|
24
|
+
const multipart_bridge_1 = require("./middleware/multipart-bridge");
|
|
25
|
+
const upload_1 = require("./middleware/upload");
|
|
25
26
|
const options_1 = require("./options");
|
|
26
27
|
const log = new logger_1.Logger('server');
|
|
27
|
-
const isDev = () => (0, graphql_env_1.getNodeEnv)() === 'development';
|
|
28
28
|
/**
|
|
29
29
|
* Creates and starts a GraphQL server instance
|
|
30
30
|
*
|
|
@@ -71,9 +71,13 @@ class Server {
|
|
|
71
71
|
const app = (0, express_1.default)();
|
|
72
72
|
const api = (0, api_1.createApiMiddleware)(effectiveOpts);
|
|
73
73
|
const authenticate = (0, auth_1.createAuthenticateMiddleware)(effectiveOpts);
|
|
74
|
+
const uploadAuthenticate = (0, upload_1.createUploadAuthenticateMiddleware)(effectiveOpts);
|
|
75
|
+
const SAFE_REQUEST_ID = /^[a-zA-Z0-9\-_]{1,128}$/;
|
|
74
76
|
const requestLogger = (req, res, next) => {
|
|
75
77
|
const headerRequestId = req.header('x-request-id');
|
|
76
|
-
const reqId = headerRequestId
|
|
78
|
+
const reqId = (headerRequestId && SAFE_REQUEST_ID.test(headerRequestId))
|
|
79
|
+
? headerRequestId
|
|
80
|
+
: (0, crypto_1.randomUUID)();
|
|
77
81
|
const start = process.hrtime.bigint();
|
|
78
82
|
req.requestId = reqId;
|
|
79
83
|
const host = req.hostname || req.headers.host || 'unknown';
|
|
@@ -103,7 +107,7 @@ class Server {
|
|
|
103
107
|
metaSchemas: apiOpts.metaSchemas?.join(',') || 'default',
|
|
104
108
|
exposedSchemas: apiOpts.exposedSchemas?.join(',') || 'none',
|
|
105
109
|
anonRole: apiOpts.anonRole,
|
|
106
|
-
roleName: apiOpts.roleName
|
|
110
|
+
roleName: apiOpts.roleName,
|
|
107
111
|
});
|
|
108
112
|
(0, server_utils_1.healthz)(app);
|
|
109
113
|
app.use(favicon_1.favicon);
|
|
@@ -120,11 +124,17 @@ class Server {
|
|
|
120
124
|
}
|
|
121
125
|
app.use((0, server_utils_1.poweredBy)('constructive'));
|
|
122
126
|
app.use((0, cors_1.cors)(fallbackOrigin));
|
|
123
|
-
app.use(graphql_upload_1.default.graphqlUploadExpress(
|
|
127
|
+
app.use('/graphql', graphql_upload_1.default.graphqlUploadExpress({
|
|
128
|
+
maxFileSize: 10 * 1024 * 1024, // 10 MB
|
|
129
|
+
maxFiles: 10,
|
|
130
|
+
}));
|
|
131
|
+
// Rewrite Content-Type after graphql-upload so grafserv accepts the request
|
|
132
|
+
app.use('/graphql', multipart_bridge_1.multipartBridge);
|
|
124
133
|
app.use((0, url_domains_1.middleware)());
|
|
125
134
|
app.use(request_ip_1.default.mw());
|
|
126
135
|
app.use(requestLogger);
|
|
127
136
|
app.use(api);
|
|
137
|
+
app.post('/upload', uploadAuthenticate, ...upload_1.uploadRoute);
|
|
128
138
|
app.use(authenticate);
|
|
129
139
|
app.use((0, graphile_1.graphile)(effectiveOpts));
|
|
130
140
|
app.use(flush_1.flush);
|