glashjs 0.15.0 → 0.15.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/README.md +11 -5
- package/bin/glash.mjs +16 -6
- package/package.json +1 -1
- package/src/auth.mjs +74 -10
- package/src/config.mjs +2 -1
- package/src/create.mjs +11 -27
- package/src/deploy.mjs +11 -3
- package/src/index.mjs +1 -1
- package/src/offline/generate-sw.mjs +27 -7
- package/src/postgres.mjs +7 -1
- package/src/security/headers.mjs +23 -0
- package/src/server/nav-client.mjs +12 -2
- package/src/server/server.mjs +125 -26
package/README.md
CHANGED
|
@@ -18,8 +18,9 @@ The create CLI asks for:
|
|
|
18
18
|
- CSS setup: Tailwind, plain CSS, or none.
|
|
19
19
|
- Postgres by default.
|
|
20
20
|
- glashAuth route helpers.
|
|
21
|
-
- SQL runner support.
|
|
21
|
+
- SQL runner support through the local CLI.
|
|
22
22
|
- AI deployment prompts.
|
|
23
|
+
- Optional IP auto-translation.
|
|
23
24
|
- Package manager and dependency install.
|
|
24
25
|
|
|
25
26
|
For non-interactive use:
|
|
@@ -40,7 +41,6 @@ routes/
|
|
|
40
41
|
api/auth/signup.mjs
|
|
41
42
|
api/auth/me.mjs
|
|
42
43
|
api/functions/contact.mjs
|
|
43
|
-
api/sql/run.mjs
|
|
44
44
|
db/schema.sql
|
|
45
45
|
.glash/prompts/deploy.md
|
|
46
46
|
glash.config.mjs
|
|
@@ -56,7 +56,7 @@ glash.config.mjs
|
|
|
56
56
|
- **API routes**: method exports such as `GET`, `POST`, `PUT`, and `DELETE`.
|
|
57
57
|
- **Postgres by default**: `glashjs/postgres` reads `DATABASE_URL` and exposes `sql`, `query`, and transactions.
|
|
58
58
|
- **glashAuth built in**: `glashjs/auth` calls the real GlashDB auth API under `/api/auth/v1/:projectId`.
|
|
59
|
-
- **SQL runner support**: `glashjs sql db/schema.sql` runs checked-in SQL against `DATABASE_URL
|
|
59
|
+
- **SQL runner support**: `glashjs sql db/schema.sql` runs checked-in SQL against `DATABASE_URL`; starters do not expose arbitrary SQL over HTTP.
|
|
60
60
|
- **AI deployment prompts**: starters include `.glash/prompts/deploy.md` for agents and deployment review.
|
|
61
61
|
- **Zero-config hosting**: `glashjs deploy` builds and hands off to the GlashDB deploy CLI.
|
|
62
62
|
- **Automatic env management**: `.env`, `.env.local`, and mode-specific env files load automatically; hosted deploys use GlashDB project env vars.
|
|
@@ -66,7 +66,8 @@ glash.config.mjs
|
|
|
66
66
|
|
|
67
67
|
```bash
|
|
68
68
|
glashjs create [name] # scaffold a new Glash project
|
|
69
|
-
glashjs dev [--port 3000] # dev server with live reload
|
|
69
|
+
glashjs dev [--port 3000] # dev server with live reload, bound to localhost
|
|
70
|
+
glashjs dev --host 0.0.0.0 # opt into LAN preview
|
|
70
71
|
glashjs build # optimize assets, precompile routes, emit PWA/security manifests
|
|
71
72
|
glashjs serve # serve the production build
|
|
72
73
|
glashjs typegen # generate typed route declarations
|
|
@@ -144,6 +145,7 @@ Required env:
|
|
|
144
145
|
GLASHDB_API_URL="https://api.glashdb.com/api"
|
|
145
146
|
GLASHDB_PROJECT_ID="..."
|
|
146
147
|
GLASHDB_ANON_KEY="..."
|
|
148
|
+
GLASH_SESSION_SECRET="at-least-32-random-characters"
|
|
147
149
|
DATABASE_URL="postgresql://..."
|
|
148
150
|
```
|
|
149
151
|
|
|
@@ -181,10 +183,14 @@ export default defineConfig({
|
|
|
181
183
|
offline: true,
|
|
182
184
|
favicon: 'node_modules/glashjs/templates/glash_favicon.svg',
|
|
183
185
|
animatedFavicon: false,
|
|
186
|
+
i18n: false,
|
|
184
187
|
dataPrefixes: ['/api/', '/auth/', '/rest/', '/live', '/stream'],
|
|
185
188
|
});
|
|
186
189
|
```
|
|
187
190
|
|
|
191
|
+
`i18n` is opt-in because it calls third-party geolocation/translation services
|
|
192
|
+
and relaxes CSP for the translation widget.
|
|
193
|
+
|
|
188
194
|
## Deploy
|
|
189
195
|
|
|
190
196
|
```bash
|
|
@@ -199,7 +205,7 @@ npm run deploy
|
|
|
199
205
|
3. Optimizes public assets.
|
|
200
206
|
4. Precompiles JSX routes and client bundles.
|
|
201
207
|
5. Emits PWA, offline, security, and asset manifests.
|
|
202
|
-
6. Hands off to
|
|
208
|
+
6. Hands off to a local pinned GlashDB CLI when installed, otherwise a pinned fallback CLI package, for upload, env sync, build logs, live URL, and hosting.
|
|
203
209
|
|
|
204
210
|
## Asset Optimization
|
|
205
211
|
|
package/bin/glash.mjs
CHANGED
|
@@ -26,7 +26,7 @@ function flag(name) {
|
|
|
26
26
|
return rest.includes(name);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
function optionBool(disableFlag, fallback
|
|
29
|
+
function optionBool(disableFlag, fallback) {
|
|
30
30
|
return rest.includes(disableFlag) ? false : fallback;
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -78,7 +78,8 @@ async function serve(dev) {
|
|
|
78
78
|
const root = arg('--root', process.cwd());
|
|
79
79
|
const { listen, cfg, routes } = await createGlashServer({ root, dev });
|
|
80
80
|
const preferredPort = Number(arg('--port', cfg.port || 3000));
|
|
81
|
-
const
|
|
81
|
+
const host = arg('--host', dev ? '127.0.0.1' : '0.0.0.0');
|
|
82
|
+
const port = await listenOnAvailablePort((p) => listen(p, host), preferredPort);
|
|
82
83
|
const pages = routes.filter((r) => !r.isApi).length;
|
|
83
84
|
const apis = routes.filter((r) => r.isApi).length;
|
|
84
85
|
console.log('');
|
|
@@ -90,11 +91,19 @@ async function serve(dev) {
|
|
|
90
91
|
routes.forEach((r) => console.log(` ${r.isApi ? 'api ' : 'page'} ${r.pattern}`));
|
|
91
92
|
console.log('');
|
|
92
93
|
console.log(` ➜ Local: http://localhost:${port}`);
|
|
93
|
-
|
|
94
|
+
if (isPublicHost(host)) {
|
|
95
|
+
for (const ip of lanAddresses()) console.log(` ➜ Network: http://${ip}:${port} (preview on other devices)`);
|
|
96
|
+
} else {
|
|
97
|
+
console.log(' ➜ Network: disabled (use --host 0.0.0.0 to expose on your LAN)');
|
|
98
|
+
}
|
|
94
99
|
if (dev) console.log('\n live reload on save · ctrl-c to stop');
|
|
95
100
|
console.log('');
|
|
96
101
|
}
|
|
97
102
|
|
|
103
|
+
function isPublicHost(host) {
|
|
104
|
+
return host === '0.0.0.0' || host === '::' || host === '';
|
|
105
|
+
}
|
|
106
|
+
|
|
98
107
|
async function listenOnAvailablePort(listen, preferredPort) {
|
|
99
108
|
for (let port = preferredPort; port < preferredPort + 20; port += 1) {
|
|
100
109
|
try {
|
|
@@ -124,7 +133,7 @@ async function main() {
|
|
|
124
133
|
auth: optionBool('--no-auth', undefined),
|
|
125
134
|
sqlRunner: optionBool('--no-sql-runner', undefined),
|
|
126
135
|
aiPrompts: optionBool('--no-ai-prompts', undefined),
|
|
127
|
-
translate: optionBool('--no-translate', undefined),
|
|
136
|
+
translate: flag('--translate') ? true : optionBool('--no-translate', undefined),
|
|
128
137
|
animation: flag('--animation') ? true : undefined,
|
|
129
138
|
});
|
|
130
139
|
break;
|
|
@@ -185,8 +194,9 @@ async function main() {
|
|
|
185
194
|
|
|
186
195
|
Usage: (run as "glashjs <cmd>"; "glash <cmd>" also works unless the glashdb deploy CLI owns that name)
|
|
187
196
|
glashjs run dev Alias for glashjs dev (also: glash run dev)
|
|
188
|
-
glashjs create [name] Create a new Glash project (interactive)
|
|
189
|
-
glashjs dev [--port 3000] Run the dev server (routing, SSR, API, live reload)
|
|
197
|
+
glashjs create [name] Create a new Glash project (interactive; --translate opts into i18n)
|
|
198
|
+
glashjs dev [--port 3000] Run the dev server on localhost (routing, SSR, API, live reload)
|
|
199
|
+
glashjs dev --host 0.0.0.0 Opt into LAN preview from other devices
|
|
190
200
|
glashjs serve [--port 3000] Run the production server over routes/ + built assets
|
|
191
201
|
glashjs build [--root <dir>] Optimize assets, precompile routes, generate offline SW + PWA + security
|
|
192
202
|
glashjs typegen Generate typed route declarations from routes/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glashjs",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.1",
|
|
4
4
|
"description": "glashjs — The Postgres-native full-stack framework for builders who want to ship without DevOps. Framework, hosting, database, auth, and deploy in one GlashDB-native runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/auth.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// /api/auth/v1/:projectId/signup
|
|
5
5
|
// /api/auth/v1/:projectId/signin
|
|
6
6
|
// /api/auth/v1/:projectId/me
|
|
7
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
|
7
8
|
|
|
8
9
|
export function glashAuth(options = {}) {
|
|
9
10
|
const apiUrl = trimSlash(options.apiUrl ?? process.env.GLASHDB_API_URL ?? 'https://api.glashdb.com/api');
|
|
@@ -55,34 +56,47 @@ export function glashAuth(options = {}) {
|
|
|
55
56
|
};
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
export function readSessionCookie(ctx, name = 'glash_session') {
|
|
59
|
+
export function readSessionCookie(ctx, name = 'glash_session', options = {}) {
|
|
59
60
|
const cookie = ctx?.headers?.cookie || ctx?.req?.headers?.cookie || '';
|
|
60
61
|
const found = cookie.split(';').map((part) => part.trim()).find((part) => part.startsWith(`${name}=`));
|
|
61
62
|
if (!found) return null;
|
|
62
|
-
|
|
63
|
-
return JSON.parse(Buffer.from(decodeURIComponent(found.slice(name.length + 1)), 'base64url').toString('utf8'));
|
|
64
|
-
} catch {
|
|
65
|
-
return null;
|
|
66
|
-
}
|
|
63
|
+
return openSession(decodeURIComponent(found.slice(name.length + 1)), name, options);
|
|
67
64
|
}
|
|
68
65
|
|
|
69
66
|
export function sessionCookie(session, options = {}) {
|
|
70
67
|
const name = options.name ?? 'glash_session';
|
|
71
68
|
const maxAge = options.maxAge ?? 60 * 60 * 24 * 30;
|
|
72
|
-
const encoded =
|
|
69
|
+
const encoded = sealSession(session, name, options);
|
|
73
70
|
const parts = [
|
|
74
71
|
`${name}=${encodeURIComponent(encoded)}`,
|
|
75
72
|
'Path=/',
|
|
76
73
|
`Max-Age=${maxAge}`,
|
|
77
74
|
'HttpOnly',
|
|
78
|
-
'
|
|
75
|
+
`SameSite=${options.sameSite ?? 'Lax'}`,
|
|
79
76
|
];
|
|
80
77
|
if (options.secure ?? process.env.NODE_ENV === 'production') parts.push('Secure');
|
|
81
78
|
return parts.join('; ');
|
|
82
79
|
}
|
|
83
80
|
|
|
84
|
-
export function clearSessionCookie(name = 'glash_session') {
|
|
85
|
-
|
|
81
|
+
export function clearSessionCookie(name = 'glash_session', options = {}) {
|
|
82
|
+
const parts = [`${name}=`, 'Path=/', 'Max-Age=0', 'HttpOnly', `SameSite=${options.sameSite ?? 'Lax'}`];
|
|
83
|
+
if (options.secure ?? process.env.NODE_ENV === 'production') parts.push('Secure');
|
|
84
|
+
return parts.join('; ');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function publicSession(session = {}) {
|
|
88
|
+
if (!session || typeof session !== 'object') return {};
|
|
89
|
+
const {
|
|
90
|
+
accessToken,
|
|
91
|
+
access_token,
|
|
92
|
+
refreshToken,
|
|
93
|
+
refresh_token,
|
|
94
|
+
token,
|
|
95
|
+
idToken,
|
|
96
|
+
id_token,
|
|
97
|
+
...safe
|
|
98
|
+
} = session;
|
|
99
|
+
return safe;
|
|
86
100
|
}
|
|
87
101
|
|
|
88
102
|
async function readJson(res) {
|
|
@@ -94,3 +108,53 @@ async function readJson(res) {
|
|
|
94
108
|
function trimSlash(value) {
|
|
95
109
|
return String(value || '').replace(/\/+$/, '');
|
|
96
110
|
}
|
|
111
|
+
|
|
112
|
+
function sealSession(session, name, options) {
|
|
113
|
+
const secret = sessionSecret(options);
|
|
114
|
+
const iv = randomBytes(12);
|
|
115
|
+
const cipher = createCipheriv('aes-256-gcm', sessionKey(secret, name), iv);
|
|
116
|
+
const plaintext = Buffer.from(JSON.stringify(session ?? {}), 'utf8');
|
|
117
|
+
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
118
|
+
const tag = cipher.getAuthTag();
|
|
119
|
+
return ['v1', b64(iv), b64(encrypted), b64(tag)].join('.');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function openSession(value, name, options) {
|
|
123
|
+
try {
|
|
124
|
+
if (!String(value || '').startsWith('v1.')) {
|
|
125
|
+
if (!options.allowUnsigned) return null;
|
|
126
|
+
return JSON.parse(Buffer.from(value, 'base64url').toString('utf8'));
|
|
127
|
+
}
|
|
128
|
+
const [, ivRaw, encryptedRaw, tagRaw] = String(value).split('.');
|
|
129
|
+
if (!ivRaw || !encryptedRaw || !tagRaw) return null;
|
|
130
|
+
const secret = sessionSecret(options);
|
|
131
|
+
const decipher = createDecipheriv('aes-256-gcm', sessionKey(secret, name), Buffer.from(ivRaw, 'base64url'));
|
|
132
|
+
decipher.setAuthTag(Buffer.from(tagRaw, 'base64url'));
|
|
133
|
+
const plain = Buffer.concat([
|
|
134
|
+
decipher.update(Buffer.from(encryptedRaw, 'base64url')),
|
|
135
|
+
decipher.final(),
|
|
136
|
+
]);
|
|
137
|
+
return JSON.parse(plain.toString('utf8'));
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function sessionSecret(options = {}) {
|
|
144
|
+
const secret = options.secret
|
|
145
|
+
?? process.env.GLASH_SESSION_SECRET
|
|
146
|
+
?? process.env.GLASHDB_SESSION_SECRET
|
|
147
|
+
?? process.env.AUTH_SECRET;
|
|
148
|
+
if (!secret || String(secret).length < 32) {
|
|
149
|
+
throw new Error('GLASH_SESSION_SECRET must be set to at least 32 characters for glashAuth session cookies');
|
|
150
|
+
}
|
|
151
|
+
return String(secret);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function sessionKey(secret, name) {
|
|
155
|
+
return createHash('sha256').update(`glashjs:${name}:${secret}`).digest();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function b64(value) {
|
|
159
|
+
return Buffer.from(value).toString('base64url');
|
|
160
|
+
}
|
package/src/config.mjs
CHANGED
|
@@ -32,7 +32,8 @@ export const DEFAULT_CONFIG = {
|
|
|
32
32
|
// (/api/_glash/geo) automatically — no route or layout wiring per app.
|
|
33
33
|
// true | false | { auto?: boolean, geoEndpoint?: string }
|
|
34
34
|
// auto: translate immediately instead of prompting.
|
|
35
|
-
|
|
35
|
+
// Disabled by default because it calls third-party translation/geolocation services.
|
|
36
|
+
i18n: false,
|
|
36
37
|
// Requests under these prefixes are treated as live/updated data: network-first
|
|
37
38
|
// in the Service Worker, so offline mode degrades exactly there (no stale live data).
|
|
38
39
|
dataPrefixes: ['/api/', '/rest/', '/auth/', '/realtime', '/live', '/stream'],
|
package/src/create.mjs
CHANGED
|
@@ -42,7 +42,7 @@ async function collectAnswers(options, rl) {
|
|
|
42
42
|
const auth = boolAnswer(options.auth, await ask(rl, 'Add glashAuth routes?', 'yes'));
|
|
43
43
|
const sqlRunner = boolAnswer(options.sqlRunner, await ask(rl, 'Add SQL runner support?', 'yes'));
|
|
44
44
|
const aiPrompts = boolAnswer(options.aiPrompts, await ask(rl, 'Add AI deployment prompts?', 'yes'));
|
|
45
|
-
const translate = boolAnswer(options.translate, await ask(rl, 'Add built-in IP auto-translation?', '
|
|
45
|
+
const translate = boolAnswer(options.translate, await ask(rl, 'Add built-in IP auto-translation?', 'no'));
|
|
46
46
|
const animation = boolAnswer(options.animation, await ask(rl, 'Add motion + 3D (motion, three.js)?', 'no'));
|
|
47
47
|
const install = boolAnswer(options.install, await ask(rl, 'Install dependencies now?', 'yes'));
|
|
48
48
|
const packageManager = normalizeChoice(options.packageManager || await ask(rl, 'Package manager (npm/pnpm/yarn/bun)', detectPackageManager()), PM_CHOICES, 'npm');
|
|
@@ -135,10 +135,8 @@ export default defineConfig({
|
|
|
135
135
|
publicDir: 'public',
|
|
136
136
|
outDir: '.glash/out',
|
|
137
137
|
offline: true,
|
|
138
|
-
// Built-in IP auto-translation
|
|
139
|
-
//
|
|
140
|
-
// serves the runtime + geo endpoint and relaxes the CSP for the translate
|
|
141
|
-
// widget automatically — no route or layout wiring needed.
|
|
138
|
+
// Built-in IP auto-translation is opt-in because it calls third-party
|
|
139
|
+
// translation/geolocation services and relaxes CSP for that widget.
|
|
142
140
|
i18n: ${answers.translate ? 'true' : 'false'},
|
|
143
141
|
favicon: 'node_modules/glashjs/templates/glash_favicon.svg',
|
|
144
142
|
animatedFavicon: false,
|
|
@@ -300,21 +298,21 @@ export const GET = () => json({
|
|
|
300
298
|
function authRoutes() {
|
|
301
299
|
return {
|
|
302
300
|
'routes/api/auth/signup.mjs': `import { json } from 'glashjs';
|
|
303
|
-
import { glashAuth, sessionCookie } from 'glashjs/auth';
|
|
301
|
+
import { glashAuth, publicSession, sessionCookie } from 'glashjs/auth';
|
|
304
302
|
|
|
305
303
|
export const POST = async (ctx) => {
|
|
306
304
|
const auth = glashAuth();
|
|
307
305
|
const session = await auth.signup(ctx.body);
|
|
308
|
-
return json(session, { headers: { 'set-cookie': sessionCookie(session) } });
|
|
306
|
+
return json({ ok: true, session: publicSession(session) }, { headers: { 'set-cookie': sessionCookie(session) } });
|
|
309
307
|
};
|
|
310
308
|
`,
|
|
311
309
|
'routes/api/auth/signin.mjs': `import { json } from 'glashjs';
|
|
312
|
-
import { glashAuth, sessionCookie } from 'glashjs/auth';
|
|
310
|
+
import { glashAuth, publicSession, sessionCookie } from 'glashjs/auth';
|
|
313
311
|
|
|
314
312
|
export const POST = async (ctx) => {
|
|
315
313
|
const auth = glashAuth();
|
|
316
314
|
const session = await auth.signin(ctx.body);
|
|
317
|
-
return json(session, { headers: { 'set-cookie': sessionCookie(session) } });
|
|
315
|
+
return json({ ok: true, session: publicSession(session) }, { headers: { 'set-cookie': sessionCookie(session) } });
|
|
318
316
|
};
|
|
319
317
|
`,
|
|
320
318
|
'routes/api/auth/me.mjs': `import { json } from 'glashjs';
|
|
@@ -345,20 +343,6 @@ export const POST = serverFunction(async ({ email }) => {
|
|
|
345
343
|
\`;
|
|
346
344
|
return { message: \`Saved \${value} in Postgres.\` };
|
|
347
345
|
});
|
|
348
|
-
`,
|
|
349
|
-
'routes/api/sql/run.mjs': `import { json } from 'glashjs';
|
|
350
|
-
import { query } from 'glashjs/postgres';
|
|
351
|
-
|
|
352
|
-
export const POST = async (ctx) => {
|
|
353
|
-
const expected = process.env.GLASH_SQL_RUNNER_TOKEN;
|
|
354
|
-
const token = String(ctx.headers.authorization || '').replace(/^Bearer\\s+/i, '');
|
|
355
|
-
if (!expected || token !== expected) return json({ error: 'SQL runner token required' }, { status: 401 });
|
|
356
|
-
const statement = String(ctx.body?.statement || '');
|
|
357
|
-
const params = Array.isArray(ctx.body?.params) ? ctx.body.params : [];
|
|
358
|
-
if (!statement.trim()) return json({ error: 'statement is required' }, { status: 400 });
|
|
359
|
-
const result = await query(statement, params);
|
|
360
|
-
return json({ rowCount: result.rowCount, rows: result.rows });
|
|
361
|
-
};
|
|
362
346
|
`,
|
|
363
347
|
};
|
|
364
348
|
}
|
|
@@ -380,7 +364,7 @@ GLASHDB_PROJECT_ID=""
|
|
|
380
364
|
DATABASE_URL="postgresql://user:password@localhost:5432/${answers.name}?sslmode=disable"
|
|
381
365
|
DIRECT_URL=""
|
|
382
366
|
GLASHDB_ANON_KEY=""
|
|
383
|
-
|
|
367
|
+
GLASH_SESSION_SECRET="replace-with-at-least-32-random-characters"
|
|
384
368
|
`;
|
|
385
369
|
}
|
|
386
370
|
|
|
@@ -391,7 +375,7 @@ GLASHDB_PROJECT_ID=""
|
|
|
391
375
|
DATABASE_URL="postgresql://user:password@localhost:5432/${answers.name}?sslmode=disable"
|
|
392
376
|
DIRECT_URL=""
|
|
393
377
|
GLASHDB_ANON_KEY=""
|
|
394
|
-
|
|
378
|
+
GLASH_SESSION_SECRET="${randomToken()}.${randomToken()}"
|
|
395
379
|
`;
|
|
396
380
|
}
|
|
397
381
|
|
|
@@ -533,9 +517,9 @@ function tailwindInput() {
|
|
|
533
517
|
function faviconSvg() {
|
|
534
518
|
// Use the bundled glash mark (the official metallic glash favicon) so a new
|
|
535
519
|
// app ships the real brand favicon, not a placeholder. Single source of truth:
|
|
536
|
-
// glashjs/templates/
|
|
520
|
+
// glashjs/templates/glash_favicon.svg.
|
|
537
521
|
try {
|
|
538
|
-
return readFileSync(fileURLToPath(new URL('../templates/
|
|
522
|
+
return readFileSync(fileURLToPath(new URL('../templates/glash_favicon.svg', import.meta.url)), 'utf8');
|
|
539
523
|
} catch {
|
|
540
524
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
541
525
|
<rect width="64" height="64" rx="14" fill="#0a0a0a"/>
|
package/src/deploy.mjs
CHANGED
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
// to the existing glashdb CLI (npm: `glashdb`) for upload/auth — no duplicated
|
|
6
6
|
// deploy logic. The platform then runs `glash build` + `glash serve`.
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
|
-
import { promises as fs } from 'node:fs';
|
|
8
|
+
import { promises as fs, existsSync } from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { build } from './build.mjs';
|
|
11
11
|
|
|
12
|
+
const FALLBACK_GLASHDB_CLI = 'glashdb@0.2.35';
|
|
13
|
+
|
|
12
14
|
/** Ensure the app declares the scripts the glashdb platform runs to build/serve it. */
|
|
13
15
|
async function ensureDeployScripts(root, log) {
|
|
14
16
|
const pkgPath = path.join(root, 'package.json');
|
|
@@ -35,8 +37,7 @@ export async function deploy({ root = process.cwd(), dryRun = false, args = [],
|
|
|
35
37
|
await ensureDeployScripts(root, log);
|
|
36
38
|
|
|
37
39
|
// 3. Hand off to the glashdb CLI (zips + uploads, handles login).
|
|
38
|
-
const cmd =
|
|
39
|
-
const cmdArgs = ['-y', 'glashdb', 'deploy', ...args];
|
|
40
|
+
const { cmd, cmdArgs } = resolveGlashdbCli(root, args);
|
|
40
41
|
log(`\nHanding off to the glashdb CLI:\n $ ${cmd} ${cmdArgs.join(' ')}\n`);
|
|
41
42
|
if (dryRun) {
|
|
42
43
|
log('(dry run — build complete, not uploading)');
|
|
@@ -48,3 +49,10 @@ export async function deploy({ root = process.cwd(), dryRun = false, args = [],
|
|
|
48
49
|
child.on('exit', (code) => (code === 0 ? resolve({ code }) : reject(new Error(`glashdb deploy exited with code ${code}`))));
|
|
49
50
|
});
|
|
50
51
|
}
|
|
52
|
+
|
|
53
|
+
function resolveGlashdbCli(root, args) {
|
|
54
|
+
const bin = process.platform === 'win32' ? 'glashdb.cmd' : 'glashdb';
|
|
55
|
+
const local = path.join(root, 'node_modules', '.bin', bin);
|
|
56
|
+
if (existsSync(local)) return { cmd: local, cmdArgs: ['deploy', ...args] };
|
|
57
|
+
return { cmd: 'npx', cmdArgs: ['-y', FALLBACK_GLASHDB_CLI, 'deploy', ...args] };
|
|
58
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -8,7 +8,7 @@ export { createProject } from './create.mjs';
|
|
|
8
8
|
export { optimizeAssets } from './assets/optimize.mjs';
|
|
9
9
|
export { generateAnimatedFavicon } from './assets/animated-favicon.mjs';
|
|
10
10
|
export { generateServiceWorker } from './offline/generate-sw.mjs';
|
|
11
|
-
export { securityHeaders, buildCsp, sri, glashSecurity } from './security/headers.mjs';
|
|
11
|
+
export { securityHeaders, buildCsp, sri, glashSecurity, mergeSafeHeaders } from './security/headers.mjs';
|
|
12
12
|
export { createGlashServer, json, redirect } from './server/server.mjs';
|
|
13
13
|
export { serverFunction, callServerFunction } from './server-functions.mjs';
|
|
14
14
|
export { discoverRoutes, matchRoute, findMiddleware } from './server/router.mjs';
|
|
@@ -43,6 +43,19 @@ function isData(url) {
|
|
|
43
43
|
return DATA_PREFIXES.some((p) => url.pathname.startsWith(p));
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
function cacheableRequest(request) {
|
|
47
|
+
if (request.headers.has('authorization')) return false;
|
|
48
|
+
if (request.cache === 'no-store') return false;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function cacheableResponse(response) {
|
|
53
|
+
if (!response || !response.ok) return false;
|
|
54
|
+
const cc = response.headers.get('cache-control') || '';
|
|
55
|
+
if (/\\b(no-store|private)\\b/i.test(cc)) return false;
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
46
59
|
self.addEventListener('fetch', (event) => {
|
|
47
60
|
const { request } = event;
|
|
48
61
|
if (request.method !== 'GET') return;
|
|
@@ -56,8 +69,6 @@ self.addEventListener('fetch', (event) => {
|
|
|
56
69
|
const fresh = await fetch(request);
|
|
57
70
|
return fresh;
|
|
58
71
|
} catch {
|
|
59
|
-
const cached = await caches.match(request);
|
|
60
|
-
if (cached) return cached;
|
|
61
72
|
return new Response(JSON.stringify({ offline: true, error: 'offline: live data unavailable' }), {
|
|
62
73
|
status: 503, headers: { 'content-type': 'application/json', 'x-glash-offline': '1' },
|
|
63
74
|
});
|
|
@@ -70,21 +81,30 @@ self.addEventListener('fetch', (event) => {
|
|
|
70
81
|
if (request.mode === 'navigate') {
|
|
71
82
|
event.respondWith((async () => {
|
|
72
83
|
const cache = await caches.open(CACHE);
|
|
84
|
+
if (!cacheableRequest(request)) {
|
|
85
|
+
return fetch(request).catch(() => cache.match('/offline.html') || new Response('offline', { status: 503 }));
|
|
86
|
+
}
|
|
73
87
|
const cached = await cache.match(request);
|
|
74
|
-
const network = fetch(request).then((res) => {
|
|
75
|
-
|
|
88
|
+
const network = fetch(request).then((res) => {
|
|
89
|
+
if (cacheableResponse(res)) cache.put(request, res.clone());
|
|
90
|
+
return res;
|
|
91
|
+
}).catch(() => null);
|
|
92
|
+
return (await network) || cached || cache.match('/offline.html') || new Response('offline', { status: 503 });
|
|
76
93
|
})());
|
|
77
94
|
return;
|
|
78
95
|
}
|
|
79
96
|
|
|
80
97
|
// Hashed static assets: cache-first.
|
|
81
98
|
event.respondWith((async () => {
|
|
99
|
+
if (!cacheableRequest(request)) return fetch(request);
|
|
82
100
|
const cached = await caches.match(request);
|
|
83
101
|
if (cached) return cached;
|
|
84
102
|
try {
|
|
85
103
|
const res = await fetch(request);
|
|
86
|
-
|
|
87
|
-
|
|
104
|
+
if (cacheableResponse(res)) {
|
|
105
|
+
const cache = await caches.open(CACHE);
|
|
106
|
+
cache.put(request, res.clone());
|
|
107
|
+
}
|
|
88
108
|
return res;
|
|
89
109
|
} catch {
|
|
90
110
|
return cached || new Response('offline', { status: 503 });
|
|
@@ -101,7 +121,7 @@ self.addEventListener('fetch', (event) => {
|
|
|
101
121
|
*/
|
|
102
122
|
export async function generateServiceWorker(outDir, manifest, {
|
|
103
123
|
cacheName,
|
|
104
|
-
appShell = ['/
|
|
124
|
+
appShell = ['/offline.html', '/favicon.svg'],
|
|
105
125
|
dataPrefixes = DATA_PREFIXES,
|
|
106
126
|
} = {}) {
|
|
107
127
|
const version = manifest?.version || 'dev';
|
package/src/postgres.mjs
CHANGED
|
@@ -72,6 +72,12 @@ function templateToSql(strings, values) {
|
|
|
72
72
|
|
|
73
73
|
function sslFor(connectionString) {
|
|
74
74
|
if (/sslmode=disable/i.test(connectionString)) return false;
|
|
75
|
-
|
|
75
|
+
let hostname = '';
|
|
76
|
+
try {
|
|
77
|
+
hostname = new URL(connectionString).hostname.toLowerCase();
|
|
78
|
+
} catch {
|
|
79
|
+
return { rejectUnauthorized: true };
|
|
80
|
+
}
|
|
81
|
+
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]') return false;
|
|
76
82
|
return { rejectUnauthorized: true };
|
|
77
83
|
}
|
package/src/security/headers.mjs
CHANGED
|
@@ -25,6 +25,29 @@ const I18N_CSP = {
|
|
|
25
25
|
};
|
|
26
26
|
const merge = (base, extra) => [...new Set([...base, ...extra])];
|
|
27
27
|
|
|
28
|
+
export const PROTECTED_SECURITY_HEADERS = new Set([
|
|
29
|
+
'content-security-policy',
|
|
30
|
+
'strict-transport-security',
|
|
31
|
+
'x-content-type-options',
|
|
32
|
+
'x-frame-options',
|
|
33
|
+
'referrer-policy',
|
|
34
|
+
'permissions-policy',
|
|
35
|
+
'cross-origin-opener-policy',
|
|
36
|
+
'cross-origin-resource-policy',
|
|
37
|
+
'cross-origin-embedder-policy',
|
|
38
|
+
'origin-agent-cluster',
|
|
39
|
+
'x-dns-prefetch-control',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
export function mergeSafeHeaders(base = {}, extra = {}) {
|
|
43
|
+
const out = { ...base };
|
|
44
|
+
for (const [key, value] of Object.entries(extra || {})) {
|
|
45
|
+
if (PROTECTED_SECURITY_HEADERS.has(String(key).toLowerCase())) continue;
|
|
46
|
+
out[key] = value;
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
28
51
|
export function buildCsp({
|
|
29
52
|
connectSrc = ["'self'"],
|
|
30
53
|
imgSrc = ["'self'", 'data:', 'blob:'],
|
|
@@ -27,7 +27,17 @@ async function navigate(href, push, keepScroll) {
|
|
|
27
27
|
root.innerHTML = data.html;
|
|
28
28
|
if (push) history.pushState({ glash: 1 }, '', href);
|
|
29
29
|
if (!keepScroll) window.scrollTo(0, 0);
|
|
30
|
-
|
|
30
|
+
var bundle = safeBundle(data.bundle);
|
|
31
|
+
if (bundle) { try { await import(bundle + '?v=' + Date.now()); } catch (e) {} }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function safeBundle(value) {
|
|
35
|
+
return /^\\/_glash\\/[a-f0-9]{10}\\.js$/i.test(String(value || '')) ? value : '';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function escapeCssIdent(value) {
|
|
39
|
+
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value);
|
|
40
|
+
return String(value).replace(/["\\\\\\]\\[]/g, '\\\\$&');
|
|
31
41
|
}
|
|
32
42
|
|
|
33
43
|
document.addEventListener('click', function (e) {
|
|
@@ -65,7 +75,7 @@ window.__glashSoftRefresh = async function () {
|
|
|
65
75
|
});
|
|
66
76
|
window.scrollTo(sx, sy);
|
|
67
77
|
if (focusName) {
|
|
68
|
-
var el = root.querySelector('[name="' + focusName + '"]');
|
|
78
|
+
var el = root.querySelector('[name="' + escapeCssIdent(focusName) + '"]');
|
|
69
79
|
if (el) { el.focus(); try { el.selectionStart = selStart; el.selectionEnd = selEnd; } catch (e) {} }
|
|
70
80
|
}
|
|
71
81
|
};
|
package/src/server/server.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// service worker, favicons) with Brotli negotiation.
|
|
7
7
|
// Every response carries the secure-by-default headers.
|
|
8
8
|
import http from 'node:http';
|
|
9
|
-
import { promises as fs, existsSync,
|
|
9
|
+
import { promises as fs, existsSync, watch, createReadStream, readFileSync } from 'node:fs';
|
|
10
10
|
import { Transform } from 'node:stream';
|
|
11
11
|
import { randomBytes } from 'node:crypto';
|
|
12
12
|
import path from 'node:path';
|
|
@@ -16,7 +16,7 @@ import { discoverRoutes, matchRoute, findMiddleware } from './router.mjs';
|
|
|
16
16
|
import { renderDocument, documentParts, renderMeta, escapeHtml } from './html.mjs';
|
|
17
17
|
import { NAV_CLIENT } from './nav-client.mjs';
|
|
18
18
|
import { isComponentRoute, loadComponentRoute, clientBundle, renderComponent, composeVNode, getPipeableRenderer, routeId, findLayouts, clearJsxCaches, compileModule } from './jsx.mjs';
|
|
19
|
-
import { securityHeaders } from '../security/headers.mjs';
|
|
19
|
+
import { mergeSafeHeaders, securityHeaders } from '../security/headers.mjs';
|
|
20
20
|
import { loadConfig } from '../config.mjs';
|
|
21
21
|
import { loadEnvFiles } from '../env.mjs';
|
|
22
22
|
|
|
@@ -28,6 +28,8 @@ const MIME = {
|
|
|
28
28
|
'.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8',
|
|
29
29
|
};
|
|
30
30
|
const mime = (file) => MIME[path.extname(file).toLowerCase()] || 'application/octet-stream';
|
|
31
|
+
const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
32
|
+
const JSON_BODY_LIMIT = 2_000_000;
|
|
31
33
|
|
|
32
34
|
// The built-in i18n runtime served at /_glash/i18n.js. i18n.mjs is dependency-free
|
|
33
35
|
// and valid browser ESM (its server-only exports are harmless in the browser), so
|
|
@@ -99,6 +101,9 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
|
|
|
99
101
|
const js = await clientBundle(comp.file, findLayouts(routesDir, comp.file), dev);
|
|
100
102
|
return send(res, 200, 'text/javascript; charset=utf-8', js, { ...secHeaders, 'cache-control': dev ? 'no-store' : 'public, max-age=31536000, immutable' });
|
|
101
103
|
}
|
|
104
|
+
if (isCrossSiteUnsafeRequest(req, url)) {
|
|
105
|
+
return send(res, 403, 'application/json', JSON.stringify({ error: 'cross-site request blocked' }), secHeaders);
|
|
106
|
+
}
|
|
102
107
|
const match = matchRoute(routes, pathname);
|
|
103
108
|
if (!match) return await handleNotFound(res, routes, req, url, cfg, secHeaders, root, routesDir, dev);
|
|
104
109
|
const ctx = makeCtx(req, res, url, match.params);
|
|
@@ -138,7 +143,7 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
|
|
|
138
143
|
}
|
|
139
144
|
});
|
|
140
145
|
|
|
141
|
-
const listen = (port = cfg.port || 3000, host = '0.0.0.0') =>
|
|
146
|
+
const listen = (port = cfg.port || 3000, host = dev ? '127.0.0.1' : '0.0.0.0') =>
|
|
142
147
|
new Promise((resolve, reject) => {
|
|
143
148
|
const onError = (error) => {
|
|
144
149
|
server.off('listening', onListening);
|
|
@@ -158,26 +163,37 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
|
|
|
158
163
|
|
|
159
164
|
async function handleApi(res, mod, req, ctx, secHeaders) {
|
|
160
165
|
const method = req.method.toUpperCase();
|
|
166
|
+
const apiHeaders = { ...secHeaders, 'cache-control': 'no-store' };
|
|
161
167
|
const handler = mod[method] || (method === 'GET' && mod.default) || mod.handler;
|
|
162
168
|
if (typeof handler !== 'function') {
|
|
163
|
-
return send(res, 405, 'application/json', JSON.stringify({ error: 'method not allowed' }),
|
|
169
|
+
return send(res, 405, 'application/json', JSON.stringify({ error: 'method not allowed' }), apiHeaders);
|
|
170
|
+
}
|
|
171
|
+
if (UNSAFE_METHODS.has(method)) {
|
|
172
|
+
try {
|
|
173
|
+
ctx.body = await readJson(req);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error instanceof HttpError) {
|
|
176
|
+
return send(res, error.status, 'application/json', JSON.stringify({ error: error.message }), apiHeaders);
|
|
177
|
+
}
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
164
180
|
}
|
|
165
|
-
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) ctx.body = await readJson(req);
|
|
166
181
|
const result = await handler(ctx);
|
|
167
182
|
// Next-style route handlers return a Web `Response` (e.g. Response.json(...)).
|
|
168
183
|
// Pass it through so migrated API routes work unchanged.
|
|
169
184
|
if (typeof Response !== 'undefined' && result instanceof Response) {
|
|
170
|
-
|
|
171
|
-
result.headers.forEach((v, k) => {
|
|
185
|
+
let responseHeaders = {};
|
|
186
|
+
result.headers.forEach((v, k) => { responseHeaders[k] = v; });
|
|
187
|
+
const headers = mergeSafeHeaders(apiHeaders, responseHeaders);
|
|
172
188
|
const buf = Buffer.from(await result.arrayBuffer());
|
|
173
189
|
res.writeHead(result.status || 200, headers);
|
|
174
190
|
return res.end(buf);
|
|
175
191
|
}
|
|
176
192
|
if (result && result.__response) {
|
|
177
193
|
return send(res, result.status || 200, result.contentType || 'application/json',
|
|
178
|
-
typeof result.body === 'string' ? result.body : JSON.stringify(result.body),
|
|
194
|
+
typeof result.body === 'string' ? result.body : JSON.stringify(result.body), mergeSafeHeaders(apiHeaders, result.headers));
|
|
179
195
|
}
|
|
180
|
-
send(res, 200, 'application/json', JSON.stringify(result ?? null),
|
|
196
|
+
send(res, 200, 'application/json', JSON.stringify(result ?? null), apiHeaders);
|
|
181
197
|
}
|
|
182
198
|
|
|
183
199
|
async function handlePage(res, mod, ctx, cfg, secHeaders, dev) {
|
|
@@ -196,7 +212,8 @@ async function handlePage(res, mod, ctx, cfg, secHeaders, dev) {
|
|
|
196
212
|
i18n: cfg.i18n,
|
|
197
213
|
nonce, dev,
|
|
198
214
|
});
|
|
199
|
-
send(res, page.status || 200, 'text/html; charset=utf-8', docHtml,
|
|
215
|
+
send(res, page.status || 200, 'text/html; charset=utf-8', docHtml,
|
|
216
|
+
mergeSafeHeaders({ ...pageHeaders(cfg, secHeaders, nonce), ...dynamicCacheHeaders(ctx) }, page.headers));
|
|
200
217
|
}
|
|
201
218
|
|
|
202
219
|
async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, routesDir, dev) {
|
|
@@ -211,7 +228,8 @@ async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, route
|
|
|
211
228
|
// so <Link> can swap #glash-root and re-hydrate without a full reload.
|
|
212
229
|
if (String(ctx.headers['x-glash-nav'] || '') === '1') {
|
|
213
230
|
const rendered = await renderComponent(mod, props);
|
|
214
|
-
return send(res, 200, 'application/json', JSON.stringify({ title, html: rendered, props, bundle: `/_glash/${id}.js` }),
|
|
231
|
+
return send(res, 200, 'application/json', JSON.stringify({ title, html: rendered, props, bundle: `/_glash/${id}.js` }),
|
|
232
|
+
{ ...secHeaders, ...dynamicCacheHeaders(ctx), 'cache-control': 'no-store' });
|
|
215
233
|
}
|
|
216
234
|
|
|
217
235
|
const nonce = randomBytes(16).toString('base64');
|
|
@@ -222,7 +240,7 @@ async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, route
|
|
|
222
240
|
title, head, offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, i18n: cfg.i18n, nonce, dev,
|
|
223
241
|
});
|
|
224
242
|
const bundleTag = `</div><script type="module" src="/_glash/${id}.js"></script>`;
|
|
225
|
-
res.writeHead(200, { ...pageHeaders(cfg, secHeaders, nonce), 'content-type': 'text/html; charset=utf-8' });
|
|
243
|
+
res.writeHead(200, { ...pageHeaders(cfg, secHeaders, nonce), ...dynamicCacheHeaders(ctx), 'content-type': 'text/html; charset=utf-8' });
|
|
226
244
|
res.write(open + '<div id="glash-root">'); // flush the shell before rendering
|
|
227
245
|
|
|
228
246
|
// True Suspense streaming: render the boundary's fallback into the shell now,
|
|
@@ -280,22 +298,26 @@ function safeJson(value) {
|
|
|
280
298
|
async function serveStatic(res, outDir, pathname, req, secHeaders) {
|
|
281
299
|
if (pathname === '/') return false; // let the index page route render
|
|
282
300
|
const rel = pathname.replace(/^\/+/, '');
|
|
283
|
-
const
|
|
284
|
-
if (!
|
|
301
|
+
const root = await fs.realpath(outDir).catch(() => null);
|
|
302
|
+
if (!root) return false;
|
|
303
|
+
const file = path.resolve(root, rel);
|
|
304
|
+
if (!isInsideRoot(file, root)) return false; // path traversal guard
|
|
285
305
|
const head = req.method === 'HEAD';
|
|
286
306
|
const range = req.headers.range;
|
|
287
307
|
const ae = String(req.headers['accept-encoding'] || '');
|
|
288
308
|
|
|
289
309
|
// Brotli precompressed sibling — only when the client isn't asking for a byte range.
|
|
290
|
-
|
|
291
|
-
|
|
310
|
+
const br = !range && ae.includes('br') ? await staticFile(file + '.br', root) : null;
|
|
311
|
+
if (br) {
|
|
312
|
+
const buf = await fs.readFile(br.file);
|
|
292
313
|
res.writeHead(200, { ...secHeaders, 'content-type': mime(file), 'content-encoding': 'br', vary: 'Accept-Encoding', 'cache-control': 'public, max-age=31536000, immutable' });
|
|
293
314
|
res.end(head ? undefined : buf);
|
|
294
315
|
return true;
|
|
295
316
|
}
|
|
296
|
-
|
|
317
|
+
const safe = await staticFile(file, root);
|
|
318
|
+
if (!safe) return false;
|
|
297
319
|
|
|
298
|
-
const stat =
|
|
320
|
+
const { stat } = safe;
|
|
299
321
|
const ct = mime(file);
|
|
300
322
|
// Range requests (video/audio seeking) -> 206 Partial Content.
|
|
301
323
|
if (range) {
|
|
@@ -309,13 +331,13 @@ async function serveStatic(res, outDir, pathname, req, secHeaders) {
|
|
|
309
331
|
}
|
|
310
332
|
res.writeHead(206, { ...secHeaders, 'content-type': ct, 'accept-ranges': 'bytes', 'content-range': `bytes ${start}-${end}/${stat.size}`, 'content-length': end - start + 1, 'cache-control': 'public, max-age=3600' });
|
|
311
333
|
if (head) { res.end(); return true; }
|
|
312
|
-
createReadStream(file, { start, end }).pipe(res);
|
|
334
|
+
createReadStream(safe.file, { start, end }).pipe(res);
|
|
313
335
|
return true;
|
|
314
336
|
}
|
|
315
337
|
// Full file — streamed (handles large assets without buffering them in memory).
|
|
316
338
|
res.writeHead(200, { ...secHeaders, 'content-type': ct, 'accept-ranges': 'bytes', 'content-length': stat.size, 'cache-control': 'public, max-age=3600' });
|
|
317
339
|
if (head) { res.end(); return true; }
|
|
318
|
-
createReadStream(file).pipe(res);
|
|
340
|
+
createReadStream(safe.file).pipe(res);
|
|
319
341
|
return true;
|
|
320
342
|
}
|
|
321
343
|
|
|
@@ -392,11 +414,41 @@ function makeCtx(req, res, url, params) {
|
|
|
392
414
|
}
|
|
393
415
|
|
|
394
416
|
function readJson(req) {
|
|
395
|
-
return new Promise((resolve) => {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
417
|
+
return new Promise((resolve, reject) => {
|
|
418
|
+
const chunks = [];
|
|
419
|
+
let size = 0;
|
|
420
|
+
let done = false;
|
|
421
|
+
const fail = (error) => {
|
|
422
|
+
if (done) return;
|
|
423
|
+
done = true;
|
|
424
|
+
reject(error);
|
|
425
|
+
};
|
|
426
|
+
req.on('data', (chunk) => {
|
|
427
|
+
if (done) return;
|
|
428
|
+
size += chunk.length;
|
|
429
|
+
if (size > JSON_BODY_LIMIT) {
|
|
430
|
+
fail(new HttpError(413, `request body exceeds ${JSON_BODY_LIMIT} bytes`));
|
|
431
|
+
req.destroy();
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
chunks.push(chunk);
|
|
435
|
+
});
|
|
436
|
+
req.on('end', () => {
|
|
437
|
+
if (done) return;
|
|
438
|
+
done = true;
|
|
439
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
440
|
+
if (!raw) return resolve({});
|
|
441
|
+
if (!isJsonContentType(req.headers['content-type'])) {
|
|
442
|
+
reject(new HttpError(415, 'content-type must be application/json'));
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
resolve(JSON.parse(raw));
|
|
447
|
+
} catch {
|
|
448
|
+
reject(new HttpError(400, 'malformed JSON body'));
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
req.on('error', () => fail(new HttpError(400, 'request body could not be read')));
|
|
400
452
|
});
|
|
401
453
|
}
|
|
402
454
|
|
|
@@ -426,9 +478,56 @@ function sendMiddlewareResult(res, result, secHeaders) {
|
|
|
426
478
|
}
|
|
427
479
|
if (result.__response) {
|
|
428
480
|
return send(res, result.status || 200, result.contentType || 'application/json',
|
|
429
|
-
typeof result.body === 'string' ? result.body : JSON.stringify(result.body),
|
|
481
|
+
typeof result.body === 'string' ? result.body : JSON.stringify(result.body), mergeSafeHeaders(secHeaders, result.headers));
|
|
430
482
|
}
|
|
431
483
|
// A bare object/string from middleware is treated as a JSON/text body.
|
|
432
484
|
if (typeof result === 'string') return send(res, 200, 'text/plain; charset=utf-8', result, secHeaders);
|
|
433
485
|
return send(res, 200, 'application/json', JSON.stringify(result), secHeaders);
|
|
434
486
|
}
|
|
487
|
+
|
|
488
|
+
class HttpError extends Error {
|
|
489
|
+
constructor(status, message) {
|
|
490
|
+
super(message);
|
|
491
|
+
this.status = status;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function staticFile(candidate, root) {
|
|
496
|
+
const lst = await fs.lstat(candidate).catch(() => null);
|
|
497
|
+
if (!lst || !lst.isFile()) return null;
|
|
498
|
+
const real = await fs.realpath(candidate).catch(() => null);
|
|
499
|
+
if (!real || !isInsideRoot(real, root)) return null;
|
|
500
|
+
const stat = await fs.stat(real).catch(() => null);
|
|
501
|
+
if (!stat || !stat.isFile()) return null;
|
|
502
|
+
return { file: real, stat };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function isInsideRoot(file, root) {
|
|
506
|
+
const relative = path.relative(root, file);
|
|
507
|
+
return relative && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function isJsonContentType(value = '') {
|
|
511
|
+
return /(^|;|\s)(application\/json|[^;\s]+\/[^;\s+]+\+json)(;|\s|$)/i.test(String(value));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function isCrossSiteUnsafeRequest(req, url) {
|
|
515
|
+
if (!UNSAFE_METHODS.has(String(req.method || '').toUpperCase())) return false;
|
|
516
|
+
if (!req.headers.cookie) return false;
|
|
517
|
+
const site = String(req.headers['sec-fetch-site'] || '').toLowerCase();
|
|
518
|
+
if (site === 'cross-site') return true;
|
|
519
|
+
if (site === 'same-origin' || site === 'same-site' || site === 'none') return false;
|
|
520
|
+
const source = req.headers.origin || req.headers.referer;
|
|
521
|
+
if (!source) return false;
|
|
522
|
+
try {
|
|
523
|
+
return new URL(source).host !== url.host;
|
|
524
|
+
} catch {
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function dynamicCacheHeaders(ctx) {
|
|
530
|
+
return ctx?.headers?.cookie || ctx?.headers?.authorization
|
|
531
|
+
? { 'cache-control': 'private, no-store' }
|
|
532
|
+
: {};
|
|
533
|
+
}
|