glashjs 0.14.2 → 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 +17 -5
- package/package.json +1 -1
- package/src/auth.mjs +74 -10
- package/src/config.mjs +2 -1
- package/src/create.mjs +16 -47
- 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 +47 -0
- package/src/server/nav-client.mjs +12 -2
- package/src/server/server.mjs +139 -28
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,6 +133,8 @@ 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),
|
|
136
|
+
translate: flag('--translate') ? true : optionBool('--no-translate', undefined),
|
|
137
|
+
animation: flag('--animation') ? true : undefined,
|
|
127
138
|
});
|
|
128
139
|
break;
|
|
129
140
|
}
|
|
@@ -183,8 +194,9 @@ async function main() {
|
|
|
183
194
|
|
|
184
195
|
Usage: (run as "glashjs <cmd>"; "glash <cmd>" also works unless the glashdb deploy CLI owns that name)
|
|
185
196
|
glashjs run dev Alias for glashjs dev (also: glash run dev)
|
|
186
|
-
glashjs create [name] Create a new Glash project (interactive)
|
|
187
|
-
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
|
|
188
200
|
glashjs serve [--port 3000] Run the production server over routes/ + built assets
|
|
189
201
|
glashjs build [--root <dir>] Optimize assets, precompile routes, generate offline SW + PWA + security
|
|
190
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.
|
|
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');
|
|
@@ -111,7 +111,7 @@ function projectFiles(answers) {
|
|
|
111
111
|
'.env.local': envLocal(answers),
|
|
112
112
|
'glash.config.mjs': configFile(answers, hasCss),
|
|
113
113
|
'README.md': projectReadme(answers),
|
|
114
|
-
'routes/_layout.jsx': layoutRoute(
|
|
114
|
+
'routes/_layout.jsx': layoutRoute(),
|
|
115
115
|
'routes/index.jsx': indexRoute(answers),
|
|
116
116
|
'routes/api/health.mjs': healthRoute(),
|
|
117
117
|
'db/schema.sql': schemaSql(answers),
|
|
@@ -119,7 +119,6 @@ function projectFiles(answers) {
|
|
|
119
119
|
...(answers.css === 'tailwind' ? { 'styles/input.css': tailwindInput() } : {}),
|
|
120
120
|
...(answers.auth ? authRoutes() : {}),
|
|
121
121
|
...(answers.sqlRunner ? sqlRunnerRoutes() : {}),
|
|
122
|
-
...(answers.translate ? { 'routes/api/_glash/geo.mjs': geoRoute() } : {}),
|
|
123
122
|
...(answers.animation ? { 'routes/demo/motion.jsx': motionDemoRoute() } : {}),
|
|
124
123
|
...(answers.aiPrompts ? { '.glash/prompts/deploy.md': aiDeployPrompt(answers) } : {}),
|
|
125
124
|
'public/favicon.svg': faviconSvg(),
|
|
@@ -136,6 +135,9 @@ export default defineConfig({
|
|
|
136
135
|
publicDir: 'public',
|
|
137
136
|
outDir: '.glash/out',
|
|
138
137
|
offline: true,
|
|
138
|
+
// Built-in IP auto-translation is opt-in because it calls third-party
|
|
139
|
+
// translation/geolocation services and relaxes CSP for that widget.
|
|
140
|
+
i18n: ${answers.translate ? 'true' : 'false'},
|
|
139
141
|
favicon: 'node_modules/glashjs/templates/glash_favicon.svg',
|
|
140
142
|
animatedFavicon: false,
|
|
141
143
|
stylesheets: ${hasCss ? "['/app.css']" : '[]'},
|
|
@@ -149,19 +151,10 @@ export default defineConfig({
|
|
|
149
151
|
`;
|
|
150
152
|
}
|
|
151
153
|
|
|
152
|
-
function layoutRoute(
|
|
153
|
-
|
|
154
|
-
const imports = [`import { Link } from 'glashjs/link';`];
|
|
155
|
-
if (t) {
|
|
156
|
-
imports.push(`import { useEffect } from 'preact/hooks';`);
|
|
157
|
-
imports.push(`import { glashAutoTranslate } from 'glashjs/i18n';`);
|
|
158
|
-
}
|
|
159
|
-
return `${imports.join('\n')}
|
|
154
|
+
function layoutRoute() {
|
|
155
|
+
return `import { Link } from 'glashjs/link';
|
|
160
156
|
|
|
161
|
-
export default function RootLayout({ children }) {
|
|
162
|
-
// Built-in IP auto-translation: detect the visitor's country and offer to
|
|
163
|
-
// translate the page into their native language. Reads /api/_glash/geo.
|
|
164
|
-
useEffect(() => { glashAutoTranslate(); }, []);` : ''}
|
|
157
|
+
export default function RootLayout({ children }) {
|
|
165
158
|
return (
|
|
166
159
|
<div className="shell">
|
|
167
160
|
<header className="nav">
|
|
@@ -178,16 +171,6 @@ export default function RootLayout({ children }) {${t ? `
|
|
|
178
171
|
`;
|
|
179
172
|
}
|
|
180
173
|
|
|
181
|
-
function geoRoute() {
|
|
182
|
-
return `// IP -> country -> native language, read from the edge geo header
|
|
183
|
-
// (CF-IPCountry / X-Glash-Country on glashDB hosting). The client i18n runtime
|
|
184
|
-
// calls this to decide whether to offer auto-translation.
|
|
185
|
-
import { geoRouteHandler } from 'glashjs/i18n';
|
|
186
|
-
|
|
187
|
-
export const GET = geoRouteHandler;
|
|
188
|
-
`;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
174
|
function motionDemoRoute() {
|
|
192
175
|
return `import { useEffect, useRef } from 'preact/hooks';
|
|
193
176
|
import { animate } from 'motion';
|
|
@@ -315,21 +298,21 @@ export const GET = () => json({
|
|
|
315
298
|
function authRoutes() {
|
|
316
299
|
return {
|
|
317
300
|
'routes/api/auth/signup.mjs': `import { json } from 'glashjs';
|
|
318
|
-
import { glashAuth, sessionCookie } from 'glashjs/auth';
|
|
301
|
+
import { glashAuth, publicSession, sessionCookie } from 'glashjs/auth';
|
|
319
302
|
|
|
320
303
|
export const POST = async (ctx) => {
|
|
321
304
|
const auth = glashAuth();
|
|
322
305
|
const session = await auth.signup(ctx.body);
|
|
323
|
-
return json(session, { headers: { 'set-cookie': sessionCookie(session) } });
|
|
306
|
+
return json({ ok: true, session: publicSession(session) }, { headers: { 'set-cookie': sessionCookie(session) } });
|
|
324
307
|
};
|
|
325
308
|
`,
|
|
326
309
|
'routes/api/auth/signin.mjs': `import { json } from 'glashjs';
|
|
327
|
-
import { glashAuth, sessionCookie } from 'glashjs/auth';
|
|
310
|
+
import { glashAuth, publicSession, sessionCookie } from 'glashjs/auth';
|
|
328
311
|
|
|
329
312
|
export const POST = async (ctx) => {
|
|
330
313
|
const auth = glashAuth();
|
|
331
314
|
const session = await auth.signin(ctx.body);
|
|
332
|
-
return json(session, { headers: { 'set-cookie': sessionCookie(session) } });
|
|
315
|
+
return json({ ok: true, session: publicSession(session) }, { headers: { 'set-cookie': sessionCookie(session) } });
|
|
333
316
|
};
|
|
334
317
|
`,
|
|
335
318
|
'routes/api/auth/me.mjs': `import { json } from 'glashjs';
|
|
@@ -360,20 +343,6 @@ export const POST = serverFunction(async ({ email }) => {
|
|
|
360
343
|
\`;
|
|
361
344
|
return { message: \`Saved \${value} in Postgres.\` };
|
|
362
345
|
});
|
|
363
|
-
`,
|
|
364
|
-
'routes/api/sql/run.mjs': `import { json } from 'glashjs';
|
|
365
|
-
import { query } from 'glashjs/postgres';
|
|
366
|
-
|
|
367
|
-
export const POST = async (ctx) => {
|
|
368
|
-
const expected = process.env.GLASH_SQL_RUNNER_TOKEN;
|
|
369
|
-
const token = String(ctx.headers.authorization || '').replace(/^Bearer\\s+/i, '');
|
|
370
|
-
if (!expected || token !== expected) return json({ error: 'SQL runner token required' }, { status: 401 });
|
|
371
|
-
const statement = String(ctx.body?.statement || '');
|
|
372
|
-
const params = Array.isArray(ctx.body?.params) ? ctx.body.params : [];
|
|
373
|
-
if (!statement.trim()) return json({ error: 'statement is required' }, { status: 400 });
|
|
374
|
-
const result = await query(statement, params);
|
|
375
|
-
return json({ rowCount: result.rowCount, rows: result.rows });
|
|
376
|
-
};
|
|
377
346
|
`,
|
|
378
347
|
};
|
|
379
348
|
}
|
|
@@ -395,7 +364,7 @@ GLASHDB_PROJECT_ID=""
|
|
|
395
364
|
DATABASE_URL="postgresql://user:password@localhost:5432/${answers.name}?sslmode=disable"
|
|
396
365
|
DIRECT_URL=""
|
|
397
366
|
GLASHDB_ANON_KEY=""
|
|
398
|
-
|
|
367
|
+
GLASH_SESSION_SECRET="replace-with-at-least-32-random-characters"
|
|
399
368
|
`;
|
|
400
369
|
}
|
|
401
370
|
|
|
@@ -406,7 +375,7 @@ GLASHDB_PROJECT_ID=""
|
|
|
406
375
|
DATABASE_URL="postgresql://user:password@localhost:5432/${answers.name}?sslmode=disable"
|
|
407
376
|
DIRECT_URL=""
|
|
408
377
|
GLASHDB_ANON_KEY=""
|
|
409
|
-
|
|
378
|
+
GLASH_SESSION_SECRET="${randomToken()}.${randomToken()}"
|
|
410
379
|
`;
|
|
411
380
|
}
|
|
412
381
|
|
|
@@ -548,9 +517,9 @@ function tailwindInput() {
|
|
|
548
517
|
function faviconSvg() {
|
|
549
518
|
// Use the bundled glash mark (the official metallic glash favicon) so a new
|
|
550
519
|
// app ships the real brand favicon, not a placeholder. Single source of truth:
|
|
551
|
-
// glashjs/templates/
|
|
520
|
+
// glashjs/templates/glash_favicon.svg.
|
|
552
521
|
try {
|
|
553
|
-
return readFileSync(fileURLToPath(new URL('../templates/
|
|
522
|
+
return readFileSync(fileURLToPath(new URL('../templates/glash_favicon.svg', import.meta.url)), 'utf8');
|
|
554
523
|
} catch {
|
|
555
524
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
556
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
|
@@ -11,14 +11,60 @@ import { createHash } from 'node:crypto';
|
|
|
11
11
|
* builds hash/nonce-based, so XSS via injected <script> is blocked by default.
|
|
12
12
|
* Pass `connectSrc` to allow your API/realtime origins.
|
|
13
13
|
*/
|
|
14
|
+
// Google Translate widget origins used by the built-in i18n auto-translation.
|
|
15
|
+
// Scripts stay nonce-based (no 'unsafe-inline'): GT's element.js loads as an
|
|
16
|
+
// EXTERNAL script from these origins and fetches translations over XHR, so the
|
|
17
|
+
// framework's strict, nonce-based script CSP is preserved. Only style-src gains
|
|
18
|
+
// 'unsafe-inline' (low-risk) for the inline styles GT applies to translated text.
|
|
19
|
+
const I18N_CSP = {
|
|
20
|
+
script: ['https://translate.google.com', 'https://translate.googleapis.com', 'https://www.gstatic.com'],
|
|
21
|
+
style: ["'unsafe-inline'", 'https://www.gstatic.com'],
|
|
22
|
+
img: ['https://translate.google.com', 'https://translate.googleapis.com', 'https://www.gstatic.com'],
|
|
23
|
+
connect: ['https://translate.googleapis.com', 'https://ipapi.co'],
|
|
24
|
+
frame: ['https://translate.google.com'],
|
|
25
|
+
};
|
|
26
|
+
const merge = (base, extra) => [...new Set([...base, ...extra])];
|
|
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
|
+
|
|
14
51
|
export function buildCsp({
|
|
15
52
|
connectSrc = ["'self'"],
|
|
16
53
|
imgSrc = ["'self'", 'data:', 'blob:'],
|
|
17
54
|
mediaSrc = ["'self'", 'blob:'],
|
|
18
55
|
styleSrc = ["'self'"],
|
|
19
56
|
scriptSrc = ["'self'"],
|
|
57
|
+
frameSrc = ["'self'"],
|
|
20
58
|
nonce,
|
|
59
|
+
i18n = false,
|
|
21
60
|
} = {}) {
|
|
61
|
+
if (i18n) {
|
|
62
|
+
scriptSrc = merge(scriptSrc, I18N_CSP.script);
|
|
63
|
+
styleSrc = merge(styleSrc, I18N_CSP.style);
|
|
64
|
+
imgSrc = merge(imgSrc, I18N_CSP.img);
|
|
65
|
+
connectSrc = merge(connectSrc, I18N_CSP.connect);
|
|
66
|
+
frameSrc = merge(frameSrc, I18N_CSP.frame);
|
|
67
|
+
}
|
|
22
68
|
const script = nonce ? [...scriptSrc, `'nonce-${nonce}'`] : scriptSrc;
|
|
23
69
|
const directives = {
|
|
24
70
|
'default-src': ["'self'"],
|
|
@@ -31,6 +77,7 @@ export function buildCsp({
|
|
|
31
77
|
'img-src': imgSrc,
|
|
32
78
|
'media-src': mediaSrc,
|
|
33
79
|
'connect-src': connectSrc,
|
|
80
|
+
'frame-src': frameSrc,
|
|
34
81
|
'worker-src': ["'self'"],
|
|
35
82
|
'manifest-src': ["'self'"],
|
|
36
83
|
'upgrade-insecure-requests': [],
|
|
@@ -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
|
|
@@ -76,6 +78,17 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
|
|
|
76
78
|
if (pathname === '/_glash/nav.js') {
|
|
77
79
|
return send(res, 200, 'text/javascript; charset=utf-8', NAV_CLIENT, { ...secHeaders, 'cache-control': dev ? 'no-store' : 'public, max-age=31536000, immutable' });
|
|
78
80
|
}
|
|
81
|
+
// Built-in IP auto-translation runtime + geo endpoint (config: i18n).
|
|
82
|
+
if (cfg.i18n) {
|
|
83
|
+
if (pathname === '/_glash/i18n.js') {
|
|
84
|
+
return send(res, 200, 'text/javascript; charset=utf-8', I18N_CLIENT, { ...secHeaders, 'cache-control': dev ? 'no-store' : 'public, max-age=31536000, immutable' });
|
|
85
|
+
}
|
|
86
|
+
// Geo endpoint: an app-defined routes/api/_glash/geo.mjs wins; otherwise
|
|
87
|
+
// the framework answers from the edge country header.
|
|
88
|
+
if (pathname === '/api/_glash/geo' && !matchRoute(routes, pathname)) {
|
|
89
|
+
return send(res, 200, 'application/json', JSON.stringify(geoRouteHandler({ headers: req.headers })), { ...secHeaders, 'cache-control': 'no-store' });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
79
92
|
// Static first: in production this serves prebuilt /_glash/<id>.js bundles
|
|
80
93
|
// (written by `glash build`) — no runtime esbuild needed.
|
|
81
94
|
if (await serveStatic(res, outDir, pathname, req, secHeaders)) return;
|
|
@@ -88,6 +101,9 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
|
|
|
88
101
|
const js = await clientBundle(comp.file, findLayouts(routesDir, comp.file), dev);
|
|
89
102
|
return send(res, 200, 'text/javascript; charset=utf-8', js, { ...secHeaders, 'cache-control': dev ? 'no-store' : 'public, max-age=31536000, immutable' });
|
|
90
103
|
}
|
|
104
|
+
if (isCrossSiteUnsafeRequest(req, url)) {
|
|
105
|
+
return send(res, 403, 'application/json', JSON.stringify({ error: 'cross-site request blocked' }), secHeaders);
|
|
106
|
+
}
|
|
91
107
|
const match = matchRoute(routes, pathname);
|
|
92
108
|
if (!match) return await handleNotFound(res, routes, req, url, cfg, secHeaders, root, routesDir, dev);
|
|
93
109
|
const ctx = makeCtx(req, res, url, match.params);
|
|
@@ -127,7 +143,7 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
|
|
|
127
143
|
}
|
|
128
144
|
});
|
|
129
145
|
|
|
130
|
-
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') =>
|
|
131
147
|
new Promise((resolve, reject) => {
|
|
132
148
|
const onError = (error) => {
|
|
133
149
|
server.off('listening', onListening);
|
|
@@ -147,26 +163,37 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
|
|
|
147
163
|
|
|
148
164
|
async function handleApi(res, mod, req, ctx, secHeaders) {
|
|
149
165
|
const method = req.method.toUpperCase();
|
|
166
|
+
const apiHeaders = { ...secHeaders, 'cache-control': 'no-store' };
|
|
150
167
|
const handler = mod[method] || (method === 'GET' && mod.default) || mod.handler;
|
|
151
168
|
if (typeof handler !== 'function') {
|
|
152
|
-
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
|
+
}
|
|
153
180
|
}
|
|
154
|
-
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) ctx.body = await readJson(req);
|
|
155
181
|
const result = await handler(ctx);
|
|
156
182
|
// Next-style route handlers return a Web `Response` (e.g. Response.json(...)).
|
|
157
183
|
// Pass it through so migrated API routes work unchanged.
|
|
158
184
|
if (typeof Response !== 'undefined' && result instanceof Response) {
|
|
159
|
-
|
|
160
|
-
result.headers.forEach((v, k) => {
|
|
185
|
+
let responseHeaders = {};
|
|
186
|
+
result.headers.forEach((v, k) => { responseHeaders[k] = v; });
|
|
187
|
+
const headers = mergeSafeHeaders(apiHeaders, responseHeaders);
|
|
161
188
|
const buf = Buffer.from(await result.arrayBuffer());
|
|
162
189
|
res.writeHead(result.status || 200, headers);
|
|
163
190
|
return res.end(buf);
|
|
164
191
|
}
|
|
165
192
|
if (result && result.__response) {
|
|
166
193
|
return send(res, result.status || 200, result.contentType || 'application/json',
|
|
167
|
-
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));
|
|
168
195
|
}
|
|
169
|
-
send(res, 200, 'application/json', JSON.stringify(result ?? null),
|
|
196
|
+
send(res, 200, 'application/json', JSON.stringify(result ?? null), apiHeaders);
|
|
170
197
|
}
|
|
171
198
|
|
|
172
199
|
async function handlePage(res, mod, ctx, cfg, secHeaders, dev) {
|
|
@@ -182,9 +209,11 @@ async function handlePage(res, mod, ctx, cfg, secHeaders, dev) {
|
|
|
182
209
|
body: page.body ?? '',
|
|
183
210
|
offline: cfg.offline,
|
|
184
211
|
animatedFavicon: !!cfg.animatedFavicon,
|
|
212
|
+
i18n: cfg.i18n,
|
|
185
213
|
nonce, dev,
|
|
186
214
|
});
|
|
187
|
-
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));
|
|
188
217
|
}
|
|
189
218
|
|
|
190
219
|
async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, routesDir, dev) {
|
|
@@ -199,7 +228,8 @@ async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, route
|
|
|
199
228
|
// so <Link> can swap #glash-root and re-hydrate without a full reload.
|
|
200
229
|
if (String(ctx.headers['x-glash-nav'] || '') === '1') {
|
|
201
230
|
const rendered = await renderComponent(mod, props);
|
|
202
|
-
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' });
|
|
203
233
|
}
|
|
204
234
|
|
|
205
235
|
const nonce = randomBytes(16).toString('base64');
|
|
@@ -207,10 +237,10 @@ async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, route
|
|
|
207
237
|
// external 'self' module — both pass the strict CSP without 'unsafe-inline'.
|
|
208
238
|
const head = renderStylesheets(cfg) + renderMeta(meta) + `<script type="application/json" id="glash-props">${safeJson(props)}</script>`;
|
|
209
239
|
const { open, tail } = documentParts({
|
|
210
|
-
title, head, offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, nonce, dev,
|
|
240
|
+
title, head, offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, i18n: cfg.i18n, nonce, dev,
|
|
211
241
|
});
|
|
212
242
|
const bundleTag = `</div><script type="module" src="/_glash/${id}.js"></script>`;
|
|
213
|
-
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' });
|
|
214
244
|
res.write(open + '<div id="glash-root">'); // flush the shell before rendering
|
|
215
245
|
|
|
216
246
|
// True Suspense streaming: render the boundary's fallback into the shell now,
|
|
@@ -256,7 +286,7 @@ async function resolveMeta(metadata, ctx) {
|
|
|
256
286
|
// Per-request page headers: a fresh CSP carrying this request's script nonce, so
|
|
257
287
|
// the framework's own inline <script>s run while injected scripts stay blocked.
|
|
258
288
|
function pageHeaders(cfg, secHeaders, nonce) {
|
|
259
|
-
const csp = securityHeaders({ ...(cfg.security || {}), csp: { ...((cfg.security || {}).csp || {}), nonce } })['Content-Security-Policy'];
|
|
289
|
+
const csp = securityHeaders({ ...(cfg.security || {}), csp: { ...((cfg.security || {}).csp || {}), nonce, i18n: !!cfg.i18n } })['Content-Security-Policy'];
|
|
260
290
|
return { ...secHeaders, 'Content-Security-Policy': csp };
|
|
261
291
|
}
|
|
262
292
|
|
|
@@ -268,22 +298,26 @@ function safeJson(value) {
|
|
|
268
298
|
async function serveStatic(res, outDir, pathname, req, secHeaders) {
|
|
269
299
|
if (pathname === '/') return false; // let the index page route render
|
|
270
300
|
const rel = pathname.replace(/^\/+/, '');
|
|
271
|
-
const
|
|
272
|
-
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
|
|
273
305
|
const head = req.method === 'HEAD';
|
|
274
306
|
const range = req.headers.range;
|
|
275
307
|
const ae = String(req.headers['accept-encoding'] || '');
|
|
276
308
|
|
|
277
309
|
// Brotli precompressed sibling — only when the client isn't asking for a byte range.
|
|
278
|
-
|
|
279
|
-
|
|
310
|
+
const br = !range && ae.includes('br') ? await staticFile(file + '.br', root) : null;
|
|
311
|
+
if (br) {
|
|
312
|
+
const buf = await fs.readFile(br.file);
|
|
280
313
|
res.writeHead(200, { ...secHeaders, 'content-type': mime(file), 'content-encoding': 'br', vary: 'Accept-Encoding', 'cache-control': 'public, max-age=31536000, immutable' });
|
|
281
314
|
res.end(head ? undefined : buf);
|
|
282
315
|
return true;
|
|
283
316
|
}
|
|
284
|
-
|
|
317
|
+
const safe = await staticFile(file, root);
|
|
318
|
+
if (!safe) return false;
|
|
285
319
|
|
|
286
|
-
const stat =
|
|
320
|
+
const { stat } = safe;
|
|
287
321
|
const ct = mime(file);
|
|
288
322
|
// Range requests (video/audio seeking) -> 206 Partial Content.
|
|
289
323
|
if (range) {
|
|
@@ -297,13 +331,13 @@ async function serveStatic(res, outDir, pathname, req, secHeaders) {
|
|
|
297
331
|
}
|
|
298
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' });
|
|
299
333
|
if (head) { res.end(); return true; }
|
|
300
|
-
createReadStream(file, { start, end }).pipe(res);
|
|
334
|
+
createReadStream(safe.file, { start, end }).pipe(res);
|
|
301
335
|
return true;
|
|
302
336
|
}
|
|
303
337
|
// Full file — streamed (handles large assets without buffering them in memory).
|
|
304
338
|
res.writeHead(200, { ...secHeaders, 'content-type': ct, 'accept-ranges': 'bytes', 'content-length': stat.size, 'cache-control': 'public, max-age=3600' });
|
|
305
339
|
if (head) { res.end(); return true; }
|
|
306
|
-
createReadStream(file).pipe(res);
|
|
340
|
+
createReadStream(safe.file).pipe(res);
|
|
307
341
|
return true;
|
|
308
342
|
}
|
|
309
343
|
|
|
@@ -380,11 +414,41 @@ function makeCtx(req, res, url, params) {
|
|
|
380
414
|
}
|
|
381
415
|
|
|
382
416
|
function readJson(req) {
|
|
383
|
-
return new Promise((resolve) => {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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')));
|
|
388
452
|
});
|
|
389
453
|
}
|
|
390
454
|
|
|
@@ -414,9 +478,56 @@ function sendMiddlewareResult(res, result, secHeaders) {
|
|
|
414
478
|
}
|
|
415
479
|
if (result.__response) {
|
|
416
480
|
return send(res, result.status || 200, result.contentType || 'application/json',
|
|
417
|
-
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));
|
|
418
482
|
}
|
|
419
483
|
// A bare object/string from middleware is treated as a JSON/text body.
|
|
420
484
|
if (typeof result === 'string') return send(res, 200, 'text/plain; charset=utf-8', result, secHeaders);
|
|
421
485
|
return send(res, 200, 'application/json', JSON.stringify(result), secHeaders);
|
|
422
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
|
+
}
|