@qwickapps/server 1.9.0 → 1.10.0
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/dist/src/core/control-panel.d.ts.map +1 -1
- package/dist/src/core/control-panel.js +2 -1
- package/dist/src/core/control-panel.js.map +1 -1
- package/dist/src/core/gateway.d.ts.map +1 -1
- package/dist/src/core/gateway.js +25 -26
- package/dist/src/core/gateway.js.map +1 -1
- package/dist/src/plugins/auth/adapters/supertokens-adapter.d.ts.map +1 -1
- package/dist/src/plugins/auth/adapters/supertokens-adapter.js +50 -11
- package/dist/src/plugins/auth/adapters/supertokens-adapter.js.map +1 -1
- package/dist/src/plugins/auth/auth-plugin.d.ts.map +1 -1
- package/dist/src/plugins/auth/auth-plugin.js +6 -5
- package/dist/src/plugins/auth/auth-plugin.js.map +1 -1
- package/dist/src/plugins/auth/env-config.d.ts.map +1 -1
- package/dist/src/plugins/auth/env-config.js +4 -0
- package/dist/src/plugins/auth/env-config.js.map +1 -1
- package/dist/src/plugins/auth/types.d.ts +8 -0
- package/dist/src/plugins/auth/types.d.ts.map +1 -1
- package/dist/src/plugins/auth/types.js.map +1 -1
- package/dist/src/plugins/maintenance/seed-executor.d.ts.map +1 -1
- package/dist/src/plugins/maintenance/seed-executor.js +5 -4
- package/dist/src/plugins/maintenance/seed-executor.js.map +1 -1
- package/dist/src/plugins/maintenance-plugin.d.ts.map +1 -1
- package/dist/src/plugins/maintenance-plugin.js +26 -4
- package/dist/src/plugins/maintenance-plugin.js.map +1 -1
- package/dist/src/plugins/postgres-plugin.d.ts +17 -0
- package/dist/src/plugins/postgres-plugin.d.ts.map +1 -1
- package/dist/src/plugins/postgres-plugin.js +36 -11
- package/dist/src/plugins/postgres-plugin.js.map +1 -1
- package/dist/src/plugins/rate-limit/env-config.d.ts +1 -1
- package/dist/src/plugins/rate-limit/env-config.js +4 -4
- package/dist/src/plugins/rate-limit/env-config.js.map +1 -1
- package/dist/src/utils/index.d.ts +2 -0
- package/dist/src/utils/index.d.ts.map +1 -0
- package/dist/src/utils/index.js +2 -0
- package/dist/src/utils/index.js.map +1 -0
- package/dist/src/utils/url.d.ts +9 -0
- package/dist/src/utils/url.d.ts.map +1 -0
- package/dist/src/utils/url.js +25 -0
- package/dist/src/utils/url.js.map +1 -0
- package/dist/ui/src/dashboard/widgets/AuthStatusWidget.d.ts.map +1 -1
- package/dist/ui/src/dashboard/widgets/AuthStatusWidget.js +1 -1
- package/dist/ui/src/dashboard/widgets/AuthStatusWidget.js.map +1 -1
- package/dist/ui/src/dashboard/widgets/DatabaseOperationsWidget.d.ts.map +1 -1
- package/dist/ui/src/dashboard/widgets/DatabaseOperationsWidget.js +1 -1
- package/dist/ui/src/dashboard/widgets/DatabaseOperationsWidget.js.map +1 -1
- package/dist-ui/assets/{index-De-dCl_t.css → index-BB_TF4Cq.css} +1 -1
- package/dist-ui/assets/index-BdwcYEzG.js +532 -0
- package/dist-ui/assets/{index-DnEQCOGR.js.map → index-BdwcYEzG.js.map} +1 -1
- package/dist-ui/index.html +2 -2
- package/dist-ui-lib/index.js +69 -65
- package/dist-ui-lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/core/control-panel.ts +2 -1
- package/src/core/gateway.ts +28 -29
- package/src/plugins/auth/adapters/supertokens-adapter.ts +45 -0
- package/src/plugins/auth/env-config.ts +3 -0
- package/src/plugins/auth/types.ts +9 -0
- package/src/plugins/maintenance-plugin.ts +1 -1
- package/src/plugins/postgres-plugin.test.ts +78 -0
- package/src/plugins/postgres-plugin.ts +39 -11
- package/src/plugins/rate-limit/env-config.ts +4 -4
- package/src/utils/index.ts +1 -0
- package/src/utils/url.test.ts +43 -0
- package/src/utils/url.ts +21 -0
- package/ui/src/dashboard/widgets/DatabaseOperationsWidget.tsx +17 -8
- package/dist-ui/assets/index-DnEQCOGR.js +0 -532
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qwickapps/server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Plugin-based application server framework for building websites, APIs, admin dashboards, and full-stack products",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/src/index.js",
|
|
@@ -139,7 +139,7 @@
|
|
|
139
139
|
},
|
|
140
140
|
"repository": {
|
|
141
141
|
"type": "git",
|
|
142
|
-
"url": "https://github.com/qwickapps/server.git"
|
|
142
|
+
"url": "git+https://github.com/qwickapps/server.git"
|
|
143
143
|
},
|
|
144
144
|
"homepage": "https://github.com/qwickapps/server#readme",
|
|
145
145
|
"publishConfig": {
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from './plugin-registry.js';
|
|
38
38
|
import { bearerTokenAuth } from '../plugins/api-keys/index.js';
|
|
39
39
|
import { createCorePlugin } from '../plugins/core/index.js';
|
|
40
|
+
import { sanitizeUrl } from '../utils/url.js';
|
|
40
41
|
|
|
41
42
|
// Get the package root directory for serving UI assets
|
|
42
43
|
const _filename = fileURLToPath(import.meta.url);
|
|
@@ -506,7 +507,7 @@ function generateLandingPageHtml(options: {
|
|
|
506
507
|
const linksHtml = links
|
|
507
508
|
.map(
|
|
508
509
|
(link) =>
|
|
509
|
-
`<a href="${link.url}" class="link">${link.label}</a>`
|
|
510
|
+
`<a href="${sanitizeUrl(link.url)}" class="link">${link.label}</a>`
|
|
510
511
|
)
|
|
511
512
|
.join('');
|
|
512
513
|
|
package/src/core/gateway.ts
CHANGED
|
@@ -34,6 +34,7 @@ import express from 'express';
|
|
|
34
34
|
import { existsSync, readFileSync } from 'fs';
|
|
35
35
|
import { resolve, join, dirname } from 'path';
|
|
36
36
|
import { fileURLToPath } from 'url';
|
|
37
|
+
import { sanitizeUrl } from '../utils/url.js';
|
|
37
38
|
|
|
38
39
|
// Get QwickApps Server version from package.json
|
|
39
40
|
const _filename = fileURLToPath(import.meta.url);
|
|
@@ -287,7 +288,7 @@ function generateLandingPageHtml(
|
|
|
287
288
|
const linksHtml = links
|
|
288
289
|
.map(
|
|
289
290
|
(link) =>
|
|
290
|
-
`<a href="${link.url}" class="link">${link.label}</a>`
|
|
291
|
+
`<a href="${sanitizeUrl(link.url)}" class="link">${link.label}</a>`
|
|
291
292
|
)
|
|
292
293
|
.join('');
|
|
293
294
|
|
|
@@ -846,7 +847,7 @@ function generateMaintenancePageHtml(
|
|
|
846
847
|
}
|
|
847
848
|
|
|
848
849
|
const contactHtml = config.contactUrl
|
|
849
|
-
? `<a href="${config.contactUrl}" class="btn btn-secondary">
|
|
850
|
+
? `<a href="${sanitizeUrl(config.contactUrl)}" class="btn btn-secondary">
|
|
850
851
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
|
|
851
852
|
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
|
852
853
|
<polyline points="22,6 12,13 2,6"/>
|
|
@@ -1362,35 +1363,33 @@ export function createGateway(config: GatewayConfig): GatewayInstance {
|
|
|
1362
1363
|
},
|
|
1363
1364
|
});
|
|
1364
1365
|
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
// Payload CMS uses natural Next.js /api/* path
|
|
1373
|
-
app.use((req, res, next) => {
|
|
1374
|
-
if (req.path.startsWith('/qapi/')) {
|
|
1375
|
-
return qwickAppsApiProxy(req, res, next);
|
|
1376
|
-
}
|
|
1377
|
-
next();
|
|
1378
|
-
});
|
|
1379
|
-
logger.debug(`Setting up proxy: /qapi/* -> http://localhost:${cpPort} (QwickApps Server APIs)`);
|
|
1380
|
-
mountedApps.push({ path: '/qapi', type: 'proxy', target: `http://localhost:${cpPort}` });
|
|
1381
|
-
|
|
1382
|
-
// 2. Register Control Panel UI proxy BEFORE the root proxy
|
|
1383
|
-
app.use(cpPath, cpUiProxy);
|
|
1384
|
-
mountedApps.push({ path: cpPath, type: 'proxy', target: `http://localhost:${cpPort}` });
|
|
1385
|
-
|
|
1386
|
-
// 3. Setup WebSocket upgrade handling for control panel UI
|
|
1387
|
-
server!.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {
|
|
1388
|
-
if (req.url?.startsWith(cpPath)) {
|
|
1389
|
-
cpUiProxy.upgrade?.(req, socket as Socket, head);
|
|
1390
|
-
}
|
|
1391
|
-
});
|
|
1366
|
+
// Always register QwickApps Server API proxy (/qapi/*) and Control Panel proxy (/cpanel)
|
|
1367
|
+
// regardless of whether any apps are mounted. This ensures createControlPanel() callers
|
|
1368
|
+
// (which pass no apps) still get /cpanel and /api routes proxied from the gateway port.
|
|
1369
|
+
// 1. Register QwickApps Server API proxy at /qapi/*
|
|
1370
|
+
app.use((req, res, next) => {
|
|
1371
|
+
if (req.path.startsWith('/qapi/')) {
|
|
1372
|
+
return qwickAppsApiProxy(req, res, next);
|
|
1392
1373
|
}
|
|
1374
|
+
next();
|
|
1375
|
+
});
|
|
1376
|
+
logger.debug(`Setting up proxy: /qapi/* -> http://localhost:${cpPort} (QwickApps Server APIs)`);
|
|
1377
|
+
mountedApps.push({ path: '/qapi', type: 'proxy', target: `http://localhost:${cpPort}` });
|
|
1378
|
+
|
|
1379
|
+
// 2. Register Control Panel UI proxy
|
|
1380
|
+
app.use(cpPath, cpUiProxy);
|
|
1381
|
+
mountedApps.push({ path: cpPath, type: 'proxy', target: `http://localhost:${cpPort}` });
|
|
1393
1382
|
|
|
1383
|
+
// 3. Setup WebSocket upgrade handling for control panel UI
|
|
1384
|
+
server!.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {
|
|
1385
|
+
if (req.url?.startsWith(cpPath)) {
|
|
1386
|
+
cpUiProxy.upgrade?.(req, socket as Socket, head);
|
|
1387
|
+
}
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
const apps = config.apps || [];
|
|
1391
|
+
for (const appConfig of apps) {
|
|
1392
|
+
// For apps with a root path, skip re-registering qapi/cpanel proxies (already done above)
|
|
1394
1393
|
// Now register the app itself
|
|
1395
1394
|
if (appConfig.source.type === 'proxy') {
|
|
1396
1395
|
setupProxyApp(appConfig, server);
|
|
@@ -72,6 +72,51 @@ export function supertokensAdapter(config: SupertokensAdapterConfig): AuthAdapte
|
|
|
72
72
|
recipeList.push(EmailPassword.default.init());
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// Add Passwordless (magic link) recipe if enabled
|
|
76
|
+
if (config.enablePasswordless) {
|
|
77
|
+
const Passwordless = await import('supertokens-node/recipe/passwordless');
|
|
78
|
+
|
|
79
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
80
|
+
const passwordlessConfig: any = {
|
|
81
|
+
contactMethod: 'EMAIL',
|
|
82
|
+
flowType: 'MAGIC_LINK',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Override email delivery with Resend if API key is provided
|
|
86
|
+
if (config.resendApiKey) {
|
|
87
|
+
const resendApiKey = config.resendApiKey;
|
|
88
|
+
const fromEmail = config.resendFromEmail || 'noreply@faabzi.com';
|
|
89
|
+
const appName = config.appName;
|
|
90
|
+
|
|
91
|
+
passwordlessConfig.emailDelivery = {
|
|
92
|
+
service: {
|
|
93
|
+
sendEmail: async ({ email, urlWithLinkCode }: { email: string; urlWithLinkCode?: string }) => {
|
|
94
|
+
try {
|
|
95
|
+
await fetch('https://api.resend.com/emails', {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: {
|
|
98
|
+
Authorization: `Bearer ${resendApiKey}`,
|
|
99
|
+
'Content-Type': 'application/json',
|
|
100
|
+
},
|
|
101
|
+
body: JSON.stringify({
|
|
102
|
+
from: fromEmail,
|
|
103
|
+
to: email,
|
|
104
|
+
subject: `Sign in to ${appName}`,
|
|
105
|
+
html: `<p>Click the link below to sign in to ${appName}.</p><p>This link expires in 15 minutes.</p><p><a href="${urlWithLinkCode}" style="display:inline-block;padding:12px 24px;background-color:#1a1a1a;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;">Sign in</a></p><p>Or copy this URL: ${urlWithLinkCode}</p><p>If you did not request this, you can safely ignore this email.</p>`,
|
|
106
|
+
}),
|
|
107
|
+
});
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.error('[SupertokensAdapter] Failed to send magic link email via Resend:', err);
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
recipeList.push(Passwordless.default.init(passwordlessConfig));
|
|
118
|
+
}
|
|
119
|
+
|
|
75
120
|
// Add ThirdParty recipe if any social providers configured
|
|
76
121
|
if (config.socialProviders) {
|
|
77
122
|
// Build provider configurations using Supertokens ProviderInput type
|
|
@@ -149,6 +149,9 @@ function parseSupertokensEnv(): EnvParseResult<SupertokensAdapterConfig> {
|
|
|
149
149
|
apiBasePath: getEnv('SUPERTOKENS_API_BASE_PATH') ?? '/auth',
|
|
150
150
|
websiteBasePath: getEnv('SUPERTOKENS_WEBSITE_BASE_PATH') ?? '/auth',
|
|
151
151
|
enableEmailPassword: getEnvBool('SUPERTOKENS_ENABLE_EMAIL_PASSWORD', true),
|
|
152
|
+
enablePasswordless: getEnvBool('SUPERTOKENS_ENABLE_PASSWORDLESS', false),
|
|
153
|
+
resendApiKey: getEnv('RESEND_API_KEY'),
|
|
154
|
+
resendFromEmail: getEnv('RESEND_FROM_EMAIL'),
|
|
152
155
|
};
|
|
153
156
|
|
|
154
157
|
// Parse social providers
|
|
@@ -155,6 +155,15 @@ export interface SupertokensAdapterConfig {
|
|
|
155
155
|
/** Enable email/password auth (default: true) */
|
|
156
156
|
enableEmailPassword?: boolean;
|
|
157
157
|
|
|
158
|
+
/** Enable passwordless (magic link) auth (default: false) */
|
|
159
|
+
enablePasswordless?: boolean;
|
|
160
|
+
|
|
161
|
+
/** Resend API key for magic link email delivery (optional — uses SuperTokens core delivery if not set) */
|
|
162
|
+
resendApiKey?: string;
|
|
163
|
+
|
|
164
|
+
/** From email address for magic link emails */
|
|
165
|
+
resendFromEmail?: string;
|
|
166
|
+
|
|
158
167
|
/** Social login providers */
|
|
159
168
|
socialProviders?: {
|
|
160
169
|
google?: { clientId: string; clientSecret: string };
|
|
@@ -819,7 +819,7 @@ export function createMaintenancePlugin(config: MaintenancePluginConfig = {}): P
|
|
|
819
819
|
// Execute Payload migration command
|
|
820
820
|
const { spawn } = await import('child_process');
|
|
821
821
|
|
|
822
|
-
migrationProcess = spawn('
|
|
822
|
+
migrationProcess = spawn('pnpm', ['exec', 'payload', 'migrate', '--force-accept-warning'], {
|
|
823
823
|
cwd: process.cwd(),
|
|
824
824
|
env: {
|
|
825
825
|
...process.env,
|
|
@@ -36,6 +36,8 @@ import {
|
|
|
36
36
|
createPostgresPlugin,
|
|
37
37
|
getPostgres,
|
|
38
38
|
hasPostgres,
|
|
39
|
+
parseConnectionUrl,
|
|
40
|
+
isManagedDatabase,
|
|
39
41
|
type PostgresPluginConfig,
|
|
40
42
|
} from './postgres-plugin.js';
|
|
41
43
|
import type { PluginRegistry } from '../core/plugin-registry.js';
|
|
@@ -221,6 +223,82 @@ describe('PostgreSQL Plugin', () => {
|
|
|
221
223
|
});
|
|
222
224
|
});
|
|
223
225
|
|
|
226
|
+
describe('parseConnectionUrl', () => {
|
|
227
|
+
it('should parse a standard URL with explicit port', () => {
|
|
228
|
+
const result = parseConnectionUrl('postgresql://myuser:mypass@localhost:5432/mydb');
|
|
229
|
+
expect(result).toEqual({ user: 'myuser', password: 'mypass', host: 'localhost', port: 5432, database: 'mydb' });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should default port to 5432 when omitted (Neon URL)', () => {
|
|
233
|
+
const result = parseConnectionUrl('postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/neondb?sslmode=require');
|
|
234
|
+
expect(result.host).toBe('ep-xxx.us-east-2.aws.neon.tech');
|
|
235
|
+
expect(result.port).toBe(5432);
|
|
236
|
+
expect(result.database).toBe('neondb');
|
|
237
|
+
expect(result.user).toBe('user');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should parse a Supabase URL without port', () => {
|
|
241
|
+
const result = parseConnectionUrl('postgresql://user:pass@db.abc123.supabase.co/postgres');
|
|
242
|
+
expect(result.host).toBe('db.abc123.supabase.co');
|
|
243
|
+
expect(result.port).toBe(5432);
|
|
244
|
+
expect(result.database).toBe('postgres');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should normalise postgres:// scheme to postgresql://', () => {
|
|
248
|
+
const result = parseConnectionUrl('postgres://user:pass@localhost:5432/testdb');
|
|
249
|
+
expect(result.host).toBe('localhost');
|
|
250
|
+
expect(result.database).toBe('testdb');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should strip query-string params from the database name', () => {
|
|
254
|
+
const result = parseConnectionUrl('postgresql://user:pass@host.neon.tech/mydb?sslmode=require&connect_timeout=10');
|
|
255
|
+
expect(result.database).toBe('mydb');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('should decode percent-encoded characters in password', () => {
|
|
259
|
+
const result = parseConnectionUrl('postgresql://user:pass%40word@ep-xxx.neon.tech/mydb?sslmode=require');
|
|
260
|
+
expect(result.user).toBe('user');
|
|
261
|
+
expect(result.password).toBe('pass@word');
|
|
262
|
+
expect(result.host).toBe('ep-xxx.neon.tech');
|
|
263
|
+
expect(result.database).toBe('mydb');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should handle empty password without throwing', () => {
|
|
267
|
+
const result = parseConnectionUrl('postgresql://user:@localhost:5432/mydb');
|
|
268
|
+
expect(result.user).toBe('user');
|
|
269
|
+
expect(result.password).toBe('');
|
|
270
|
+
expect(result.host).toBe('localhost');
|
|
271
|
+
expect(result.port).toBe(5432);
|
|
272
|
+
expect(result.database).toBe('mydb');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('should throw on an invalid URL', () => {
|
|
276
|
+
expect(() => parseConnectionUrl('not-a-url')).toThrow('Invalid PostgreSQL connection URL format');
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe('isManagedDatabase', () => {
|
|
281
|
+
it('should return true for *.neon.tech hosts', () => {
|
|
282
|
+
expect(isManagedDatabase('ep-xxx.us-east-2.aws.neon.tech')).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('should return true for *.supabase.co hosts', () => {
|
|
286
|
+
expect(isManagedDatabase('db.abc123.supabase.co')).toBe(true);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('should return false for localhost', () => {
|
|
290
|
+
expect(isManagedDatabase('localhost')).toBe(false);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should return false for RDS hosts', () => {
|
|
294
|
+
expect(isManagedDatabase('mydb.cluster-xyz.us-east-1.rds.amazonaws.com')).toBe(false);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('should return false for a domain that merely contains but does not end with neon.tech', () => {
|
|
298
|
+
expect(isManagedDatabase('neon.tech.example.com')).toBe(false);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
224
302
|
describe('onStop', () => {
|
|
225
303
|
it('should close pool and unregister instance', async () => {
|
|
226
304
|
const plugin = createPostgresPlugin(mockConfig, 'test');
|
|
@@ -161,26 +161,43 @@ export interface PostgresInstance {
|
|
|
161
161
|
const instances = new Map<string, PostgresInstance>();
|
|
162
162
|
|
|
163
163
|
/**
|
|
164
|
-
* Parse database connection URL to extract components
|
|
164
|
+
* Parse database connection URL to extract components.
|
|
165
|
+
* Supports both `postgresql://` and `postgres://` schemes,
|
|
166
|
+
* optional port (defaults to 5432), and query-string parameters.
|
|
165
167
|
*/
|
|
166
|
-
function parseConnectionUrl(url: string): {
|
|
168
|
+
export function parseConnectionUrl(url: string): {
|
|
167
169
|
user: string;
|
|
168
170
|
password: string;
|
|
169
171
|
host: string;
|
|
170
172
|
port: number;
|
|
171
173
|
database: string;
|
|
172
174
|
} {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
+
// Normalise postgres:// → postgresql:// so the URL constructor accepts it
|
|
176
|
+
const normalised = url.replace(/^postgres:\/\//, 'postgresql://');
|
|
177
|
+
let parsed: URL;
|
|
178
|
+
try {
|
|
179
|
+
parsed = new URL(normalised);
|
|
180
|
+
} catch {
|
|
175
181
|
throw new Error('Invalid PostgreSQL connection URL format');
|
|
176
182
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
183
|
+
const user = decodeURIComponent(parsed.username);
|
|
184
|
+
const password = decodeURIComponent(parsed.password);
|
|
185
|
+
const host = parsed.hostname;
|
|
186
|
+
const port = parsed.port ? parseInt(parsed.port, 10) : 5432;
|
|
187
|
+
// pathname starts with '/' — strip it to get the database name
|
|
188
|
+
const database = decodeURIComponent(parsed.pathname.slice(1));
|
|
189
|
+
if (!user || !host || !database) {
|
|
190
|
+
throw new Error('Invalid PostgreSQL connection URL format');
|
|
191
|
+
}
|
|
192
|
+
return { user, password, host, port, database };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Returns true when the host belongs to a known managed-database provider
|
|
197
|
+
* (Neon, Supabase) where destructive operations like DROP DATABASE are unsafe.
|
|
198
|
+
*/
|
|
199
|
+
export function isManagedDatabase(host: string): boolean {
|
|
200
|
+
return host.endsWith('.neon.tech') || host.endsWith('.supabase.co');
|
|
184
201
|
}
|
|
185
202
|
|
|
186
203
|
/**
|
|
@@ -567,6 +584,8 @@ export function createPostgresPlugin(
|
|
|
567
584
|
}
|
|
568
585
|
}
|
|
569
586
|
|
|
587
|
+
const managed = connParams ? isManagedDatabase(connParams.host) : false;
|
|
588
|
+
|
|
570
589
|
try {
|
|
571
590
|
await targetInstance.query('SELECT 1');
|
|
572
591
|
res.json({
|
|
@@ -576,6 +595,7 @@ export function createPostgresPlugin(
|
|
|
576
595
|
user: connParams?.user,
|
|
577
596
|
host: connParams?.host,
|
|
578
597
|
port: connParams?.port,
|
|
598
|
+
managed,
|
|
579
599
|
autoInitializeEnabled: config.autoInitialize !== false,
|
|
580
600
|
adminCredentialsProvided: !!(config.adminUser && config.adminPassword),
|
|
581
601
|
});
|
|
@@ -587,6 +607,7 @@ export function createPostgresPlugin(
|
|
|
587
607
|
user: connParams?.user,
|
|
588
608
|
host: connParams?.host,
|
|
589
609
|
port: connParams?.port,
|
|
610
|
+
managed,
|
|
590
611
|
errorMessage: err instanceof Error ? err.message : String(err),
|
|
591
612
|
autoInitializeEnabled: config.autoInitialize !== false,
|
|
592
613
|
adminCredentialsProvided: !!(config.adminUser && config.adminPassword),
|
|
@@ -676,6 +697,13 @@ export function createPostgresPlugin(
|
|
|
676
697
|
}
|
|
677
698
|
|
|
678
699
|
const connParams = parseConnectionUrl(config.url);
|
|
700
|
+
|
|
701
|
+
if (isManagedDatabase(connParams.host)) {
|
|
702
|
+
return res.status(403).json({
|
|
703
|
+
message: 'Delete and recreate is not supported for managed databases (Neon, Supabase). Manage your database through the provider dashboard.',
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
679
707
|
const effectiveAdminUser = adminUser || config.adminUser;
|
|
680
708
|
const effectiveAdminPassword = adminPassword || config.adminPassword;
|
|
681
709
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* - RATE_LIMIT_CLEANUP_ENABLED: Enable cleanup job (default: true)
|
|
13
13
|
* - RATE_LIMIT_CLEANUP_INTERVAL_MS: Cleanup interval in ms (default: 300000 = 5 minutes)
|
|
14
14
|
* - RATE_LIMIT_API_ENABLED: Enable status API endpoints (default: true)
|
|
15
|
-
* - RATE_LIMIT_API_PREFIX: API route prefix (default: /rate-limit)
|
|
15
|
+
* - RATE_LIMIT_API_PREFIX: API route prefix (default: empty, framework adds /rate-limit automatically)
|
|
16
16
|
* - RATE_LIMIT_DEBUG: Enable debug logging (default: false)
|
|
17
17
|
*
|
|
18
18
|
* PostgreSQL Store (via postgres-plugin):
|
|
@@ -181,7 +181,7 @@ export function createRateLimitPluginFromEnv(options?: RateLimitEnvPluginOptions
|
|
|
181
181
|
const cleanupEnabled = getEnvBool('RATE_LIMIT_CLEANUP_ENABLED', true);
|
|
182
182
|
const cleanupIntervalMs = getEnvInt('RATE_LIMIT_CLEANUP_INTERVAL_MS', 300000);
|
|
183
183
|
const apiEnabled = getEnvBool('RATE_LIMIT_API_ENABLED', true);
|
|
184
|
-
const apiPrefix = getEnv('RATE_LIMIT_API_PREFIX') || '
|
|
184
|
+
const apiPrefix = getEnv('RATE_LIMIT_API_PREFIX') || '';
|
|
185
185
|
const debug = options?.debug ?? getEnvBool('RATE_LIMIT_DEBUG', false);
|
|
186
186
|
|
|
187
187
|
// PostgreSQL store config
|
|
@@ -252,10 +252,10 @@ export function getRateLimitConfigStatus(): RateLimitConfigStatus {
|
|
|
252
252
|
* Register config API routes
|
|
253
253
|
*/
|
|
254
254
|
function registerConfigRoutes(registry: PluginRegistry): void {
|
|
255
|
-
// GET /
|
|
255
|
+
// GET /config/status - Get current rate limit status
|
|
256
256
|
registry.addRoute({
|
|
257
257
|
method: 'get',
|
|
258
|
-
path: '/
|
|
258
|
+
path: '/config/status',
|
|
259
259
|
pluginId: 'rate-limit',
|
|
260
260
|
handler: (_req: Request, res: Response) => {
|
|
261
261
|
res.json(getRateLimitConfigStatus());
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { sanitizeUrl } from './url.js';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { sanitizeUrl } from './url.js';
|
|
2
|
+
|
|
3
|
+
describe('sanitizeUrl', () => {
|
|
4
|
+
it('allows https URLs', () => {
|
|
5
|
+
expect(sanitizeUrl('https://example.com')).toBe('https://example.com');
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
it('allows http URLs', () => {
|
|
9
|
+
expect(sanitizeUrl('http://example.com/path')).toBe('http://example.com/path');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('allows relative URLs starting with /', () => {
|
|
13
|
+
expect(sanitizeUrl('/relative/path')).toBe('/relative/path');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('blocks javascript: protocol', () => {
|
|
17
|
+
expect(sanitizeUrl('javascript:alert(document.cookie)')).toBe('#');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('blocks javascript: with uppercase', () => {
|
|
21
|
+
expect(sanitizeUrl('JavaScript:alert(1)')).toBe('#');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('blocks data: protocol', () => {
|
|
25
|
+
expect(sanitizeUrl('data:text/html,<h1>test</h1>')).toBe('#');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('blocks vbscript: protocol', () => {
|
|
29
|
+
expect(sanitizeUrl('vbscript:msgbox(1)')).toBe('#');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns fallback for empty string', () => {
|
|
33
|
+
expect(sanitizeUrl('')).toBe('#');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns custom fallback when provided', () => {
|
|
37
|
+
expect(sanitizeUrl('javascript:alert(1)', '/safe')).toBe('/safe');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns fallback for malformed URLs', () => {
|
|
41
|
+
expect(sanitizeUrl('not a url at all')).toBe('#');
|
|
42
|
+
});
|
|
43
|
+
});
|
package/src/utils/url.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitizes a URL to prevent javascript: and other dangerous protocol injections
|
|
3
|
+
* when interpolating user-supplied URLs into HTML href attributes.
|
|
4
|
+
*
|
|
5
|
+
* Allows: http:, https:, and relative URLs (starting with /).
|
|
6
|
+
* Returns fallback for any other protocol (e.g., javascript:, data:, vbscript:).
|
|
7
|
+
*/
|
|
8
|
+
export function sanitizeUrl(url: string, fallback = '#'): string {
|
|
9
|
+
if (!url) return fallback;
|
|
10
|
+
// Allow relative URLs
|
|
11
|
+
if (url.startsWith('/')) return url;
|
|
12
|
+
try {
|
|
13
|
+
const parsed = new URL(url);
|
|
14
|
+
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
|
15
|
+
return url;
|
|
16
|
+
}
|
|
17
|
+
return fallback;
|
|
18
|
+
} catch {
|
|
19
|
+
return fallback;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -33,6 +33,7 @@ interface DatabaseStatus {
|
|
|
33
33
|
user?: string;
|
|
34
34
|
host?: string;
|
|
35
35
|
port?: number;
|
|
36
|
+
managed?: boolean;
|
|
36
37
|
errorMessage?: string;
|
|
37
38
|
autoInitializeEnabled: boolean;
|
|
38
39
|
adminCredentialsProvided: boolean;
|
|
@@ -360,6 +361,12 @@ export const DatabaseOperationsWidget: React.FC<DatabaseOperationsWidgetProps> =
|
|
|
360
361
|
</Typography>
|
|
361
362
|
</Box>
|
|
362
363
|
|
|
364
|
+
{status.managed && (
|
|
365
|
+
<Alert severity="info" sx={{ mt: 2 }}>
|
|
366
|
+
Managed database (Neon / Supabase). Delete and recreate is disabled — manage your database through the provider dashboard.
|
|
367
|
+
</Alert>
|
|
368
|
+
)}
|
|
369
|
+
|
|
363
370
|
{!status.connected && !operating && (
|
|
364
371
|
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
|
|
365
372
|
<Button
|
|
@@ -370,14 +377,16 @@ export const DatabaseOperationsWidget: React.FC<DatabaseOperationsWidgetProps> =
|
|
|
370
377
|
>
|
|
371
378
|
Initialize Database
|
|
372
379
|
</Button>
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
380
|
+
{!status.managed && (
|
|
381
|
+
<Button
|
|
382
|
+
variant="contained"
|
|
383
|
+
color="error"
|
|
384
|
+
onClick={() => startOperation('recreate')}
|
|
385
|
+
size="small"
|
|
386
|
+
>
|
|
387
|
+
Recreate Database
|
|
388
|
+
</Button>
|
|
389
|
+
)}
|
|
381
390
|
</Box>
|
|
382
391
|
)}
|
|
383
392
|
</CardContent>
|