jskelet 0.1.1 → 0.1.3
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/AGENTS.md +5 -0
- package/CHANGELOG.md +129 -2
- package/README.md +21 -7
- package/bin/jskelet.mjs +6 -6
- package/docs/03-routing.md +48 -9
- package/docs/04-render-ve-sablonlar.md +2 -2
- package/docs/05-islands.md +59 -6
- package/docs/06-cache.md +240 -26
- package/docs/07-yapilandirma.md +108 -7
- package/docs/08-build.md +4 -4
- package/docs/09-dev-araclari.md +5 -0
- package/docs/12-panel-ve-oturum.md +384 -0
- package/docs/README.md +25 -2
- package/docs/en/01-getting-started.md +292 -0
- package/docs/en/02-architecture.md +305 -0
- package/docs/en/03-routing.md +493 -0
- package/docs/en/04-rendering.md +504 -0
- package/docs/en/05-islands.md +492 -0
- package/docs/en/06-caching.md +640 -0
- package/docs/en/07-configuration.md +789 -0
- package/docs/en/08-build.md +383 -0
- package/docs/en/09-dev-tools.md +314 -0
- package/docs/en/10-deployment.md +332 -0
- package/docs/en/11-migration.md +360 -0
- package/docs/en/12-dashboards-and-sessions.md +392 -0
- package/docs/en/README.md +112 -0
- package/package.json +4 -2
- package/src/build/build.mjs +1 -1
- package/src/build/tasks/client.mjs +2 -2
- package/src/build/tasks/fonts.mjs +3 -3
- package/src/build/tasks/icons.mjs +1 -1
- package/src/build/tasks/images.mjs +2 -2
- package/src/client/devtools/overlay.js +196 -164
- package/src/client/devtools/report.js +96 -96
- package/src/client/form.js +192 -0
- package/src/client/index.js +10 -1
- package/src/client/registry.js +78 -4
- package/src/client/swap.js +188 -0
- package/src/config/defaults.js +83 -0
- package/src/config/index.js +129 -18
- package/src/config/pattern.js +1 -1
- package/src/dev-server.mjs +1 -1
- package/src/http/control-flow.js +16 -1
- package/src/http/cookies.js +257 -0
- package/src/http/request-context.js +162 -0
- package/src/index.js +26 -2
- package/src/init.mjs +32 -31
- package/src/log.mjs +8 -2
- package/src/logo.png +0 -0
- package/src/runtime/alias-hooks.mjs +1 -1
- package/src/server/assets.js +1 -1
- package/src/server/create-app.js +12 -4
- package/src/server/data-cache.js +244 -0
- package/src/server/dev/devtools.js +6 -2
- package/src/server/dev/report.js +8 -1
- package/src/server/dev/version-check.mjs +139 -0
- package/src/server/head-hints.js +1 -1
- package/src/server/html-cache.js +32 -6
- package/src/server/middleware/csrf.js +134 -0
- package/src/server/prewarm.js +164 -19
- package/src/server/render.js +256 -20
- package/src/server/router.js +14 -7
- package/src/server/status-page.js +1 -1
- package/src/version.mjs +9 -4
- package/src/views/components/loader.js +1 -1
- package/src/views/helpers/tags.js +53 -1
package/src/config/index.js
CHANGED
|
@@ -15,21 +15,28 @@
|
|
|
15
15
|
* headers() → [{ source, headers: [{ key, value }] }]
|
|
16
16
|
* redirects() → [{ source, destination, permanent?, statusCode? }]
|
|
17
17
|
* rewrites() → [{ source, destination }] | { beforeFiles?, afterFiles? }
|
|
18
|
-
* cache() → { html?: { [source]: saniye },
|
|
18
|
+
* cache() → { html?: { [source]: saniye }, maxEntries?: number,
|
|
19
|
+
* data?: {...}, prewarm?: {...} }
|
|
20
|
+
*
|
|
21
|
+
* Fonksiyon olmayan bölümler (`brand`, `security`, `static`, `navigation`…)
|
|
22
|
+
* düz nesne olarak okunur.
|
|
19
23
|
*/
|
|
20
24
|
import fs from "node:fs";
|
|
21
25
|
import path from "node:path";
|
|
22
26
|
import process from "node:process";
|
|
23
27
|
import { pathToFileURL } from "node:url";
|
|
24
|
-
import { compilePattern } from "./pattern.js";
|
|
28
|
+
import { compilePattern, matchPattern } from "./pattern.js";
|
|
25
29
|
import {
|
|
26
30
|
DEFAULT_BRAND,
|
|
31
|
+
DEFAULT_DATA_CACHE,
|
|
27
32
|
DEFAULT_DEV_GATE_BYPASS,
|
|
28
33
|
DEFAULT_DIRS,
|
|
34
|
+
DEFAULT_HTML_CACHE_MAX_ENTRIES,
|
|
29
35
|
DEFAULT_NAVIGATION,
|
|
30
36
|
DEFAULT_NAVIGATION_EXCLUDE,
|
|
31
37
|
DEFAULT_PREWARM,
|
|
32
38
|
DEFAULT_PREWARM_SKIP,
|
|
39
|
+
DEFAULT_SECURITY,
|
|
33
40
|
DEFAULT_STATIC,
|
|
34
41
|
} from "./defaults.js";
|
|
35
42
|
|
|
@@ -59,7 +66,10 @@ const CONFIG_FILE = "jskelet.config.mjs";
|
|
|
59
66
|
* @property {{ pattern: CompiledPattern, destination: string, statusCode: number }[]} redirects
|
|
60
67
|
* @property {{ phase: "beforeFiles" | "afterFiles", pattern: CompiledPattern, destination: string }[]} rewrites
|
|
61
68
|
* @property {{ pattern: CompiledPattern, seconds: number }[]} html
|
|
69
|
+
* @property {number} htmlMaxEntries HTML önbelleğinin girdi sınırı.
|
|
70
|
+
* @property {Record<string, unknown>} data Upstream veri önbelleği ayarları.
|
|
62
71
|
* @property {Record<string, unknown>} prewarm
|
|
72
|
+
* @property {{ source: string, test: (pathname: string) => boolean }[]} prewarmPriority
|
|
63
73
|
* @property {Record<string, unknown>} brand
|
|
64
74
|
* @property {Record<string, Function>} hooks
|
|
65
75
|
* @property {string} layout Layout `.ejs` dosyasının mutlak yolu.
|
|
@@ -68,6 +78,7 @@ const CONFIG_FILE = "jskelet.config.mjs";
|
|
|
68
78
|
* @property {string[]} devGateBypass
|
|
69
79
|
* @property {string[]} preconnect
|
|
70
80
|
* @property {NavigationConfig} navigation
|
|
81
|
+
* @property {SecurityConfig} security
|
|
71
82
|
* @property {string[]} prewarmSkip
|
|
72
83
|
* @property {string[]} watch Dev sunucusunun izlediği ek dizinler.
|
|
73
84
|
* @property {{ family: string, slug?: string, weights: number[] }[]} fonts
|
|
@@ -87,7 +98,7 @@ let config = null;
|
|
|
87
98
|
function asArray(value, label) {
|
|
88
99
|
if (value == null) return [];
|
|
89
100
|
if (Array.isArray(value)) return value;
|
|
90
|
-
console.warn(`[config] ${label}
|
|
101
|
+
console.warn(`[config] ${label} must return an array, ignoring it`);
|
|
91
102
|
return [];
|
|
92
103
|
}
|
|
93
104
|
|
|
@@ -162,9 +173,41 @@ function normalizeRewrites(raw) {
|
|
|
162
173
|
return out;
|
|
163
174
|
}
|
|
164
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Isıtma sırası desenleri. İki biçim kabul edilir: config'in her yerinde
|
|
178
|
+
* geçerli olan `/haber/:slug` sözdizimi ve doğrudan `RegExp` — ikincisi
|
|
179
|
+
* "sonu `-yorumlar` ile bitenler" gibi desen sözdiziminin karşılamadığı
|
|
180
|
+
* kuralları yazabilmek için.
|
|
181
|
+
*
|
|
182
|
+
* @param {unknown} raw
|
|
183
|
+
* @returns {ResolvedConfig["prewarmPriority"]}
|
|
184
|
+
*/
|
|
185
|
+
function normalizePriority(raw) {
|
|
186
|
+
/** @type {ResolvedConfig["prewarmPriority"]} */
|
|
187
|
+
const out = [];
|
|
188
|
+
|
|
189
|
+
for (const entry of asArray(raw, "cache().prewarm.priority")) {
|
|
190
|
+
if (entry instanceof RegExp) {
|
|
191
|
+
out.push({ source: String(entry), test: (pathname) => entry.test(pathname) });
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const pattern = compilePattern(entry);
|
|
196
|
+
if (!pattern) continue;
|
|
197
|
+
out.push({
|
|
198
|
+
source: pattern.source,
|
|
199
|
+
test: (pathname) => matchPattern(pattern, pathname) !== null,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
165
206
|
/**
|
|
166
207
|
* @param {unknown} raw
|
|
167
|
-
* @returns {{ html: ResolvedConfig["html"],
|
|
208
|
+
* @returns {{ html: ResolvedConfig["html"], htmlMaxEntries: number,
|
|
209
|
+
* data: Record<string, unknown>, prewarm: Record<string, unknown>,
|
|
210
|
+
* prewarmPriority: ResolvedConfig["prewarmPriority"] }}
|
|
168
211
|
*/
|
|
169
212
|
function normalizeCache(raw) {
|
|
170
213
|
/** @type {ResolvedConfig["html"]} */
|
|
@@ -177,7 +220,21 @@ function normalizeCache(raw) {
|
|
|
177
220
|
html.push({ pattern, seconds: value });
|
|
178
221
|
}
|
|
179
222
|
|
|
180
|
-
|
|
223
|
+
const prewarm = { ...DEFAULT_PREWARM, ...(raw?.prewarm ?? {}) };
|
|
224
|
+
const maxEntries = Number(raw?.maxEntries);
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
html,
|
|
228
|
+
htmlMaxEntries:
|
|
229
|
+
Number.isFinite(maxEntries) && maxEntries > 0
|
|
230
|
+
? Math.floor(maxEntries)
|
|
231
|
+
: DEFAULT_HTML_CACHE_MAX_ENTRIES,
|
|
232
|
+
data: { ...DEFAULT_DATA_CACHE, ...(raw?.data ?? {}) },
|
|
233
|
+
// Desenler derlenmiş hâlde ayrı alanda tutulur: `prewarm` sayısal
|
|
234
|
+
// ayarların düz torbası olarak kalsın, her turda yeniden derlenmesin.
|
|
235
|
+
prewarm,
|
|
236
|
+
prewarmPriority: normalizePriority(prewarm.priority),
|
|
237
|
+
};
|
|
181
238
|
}
|
|
182
239
|
|
|
183
240
|
/** Speculation Rules'un tanıdığı eagerness değerleri. */
|
|
@@ -201,7 +258,7 @@ function normalizeEagerness(value, fallback, label) {
|
|
|
201
258
|
}
|
|
202
259
|
|
|
203
260
|
console.warn(
|
|
204
|
-
`[config] navigation.${label}
|
|
261
|
+
`[config] navigation.${label} is invalid (${String(value)}), falling back to the default`,
|
|
205
262
|
);
|
|
206
263
|
return fallback;
|
|
207
264
|
}
|
|
@@ -240,6 +297,50 @@ function normalizeNavigation(raw, brand) {
|
|
|
240
297
|
};
|
|
241
298
|
}
|
|
242
299
|
|
|
300
|
+
/**
|
|
301
|
+
* @typedef {object} SecurityConfig
|
|
302
|
+
* @property {boolean} trustProxy
|
|
303
|
+
* @property {string | null} cookieSecret
|
|
304
|
+
* @property {{ enabled: boolean, token: boolean, allowedOrigins: string[],
|
|
305
|
+
* exclude: CompiledPattern[], cookieName: string, fieldName: string,
|
|
306
|
+
* headerName: string }} csrf
|
|
307
|
+
*/
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Güvenlik bölümü. `csrf.exclude` desenleri burada derlenir: her istekte
|
|
311
|
+
* yeniden derlemek gereksiz, ve bozuk bir desen sunucuyu düşürmemeli.
|
|
312
|
+
*
|
|
313
|
+
* @param {unknown} raw
|
|
314
|
+
* @returns {SecurityConfig}
|
|
315
|
+
*/
|
|
316
|
+
function normalizeSecurity(raw) {
|
|
317
|
+
const source = /** @type {Record<string, any>} */ (raw ?? {});
|
|
318
|
+
const csrf = { ...DEFAULT_SECURITY.csrf, ...(source.csrf ?? {}) };
|
|
319
|
+
|
|
320
|
+
const exclude = asArray(csrf.exclude, "security.csrf.exclude")
|
|
321
|
+
.map((entry) => compilePattern(entry))
|
|
322
|
+
.filter((pattern) => pattern !== null);
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
trustProxy: source.trustProxy !== false,
|
|
326
|
+
cookieSecret:
|
|
327
|
+
typeof source.cookieSecret === "string" && source.cookieSecret
|
|
328
|
+
? source.cookieSecret
|
|
329
|
+
: null,
|
|
330
|
+
csrf: {
|
|
331
|
+
enabled: csrf.enabled !== false,
|
|
332
|
+
token: csrf.token === true,
|
|
333
|
+
allowedOrigins: asArray(csrf.allowedOrigins, "security.csrf.allowedOrigins")
|
|
334
|
+
.filter((entry) => typeof entry === "string")
|
|
335
|
+
.map(String),
|
|
336
|
+
exclude: /** @type {CompiledPattern[]} */ (exclude),
|
|
337
|
+
cookieName: String(csrf.cookieName ?? DEFAULT_SECURITY.csrf.cookieName),
|
|
338
|
+
fieldName: String(csrf.fieldName ?? DEFAULT_SECURITY.csrf.fieldName),
|
|
339
|
+
headerName: String(csrf.headerName ?? DEFAULT_SECURITY.csrf.headerName).toLowerCase(),
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
243
344
|
/**
|
|
244
345
|
* Dizin adlarını mutlak yola çevirir. `styles` bir dosya yolu olduğu için
|
|
245
346
|
* de aynı çözümlemeden geçer; ayrı bir alan tutmaya değmez.
|
|
@@ -307,7 +408,7 @@ export async function loadConfig(options = {}) {
|
|
|
307
408
|
|
|
308
409
|
if (!fs.existsSync(configPath)) {
|
|
309
410
|
console.warn(
|
|
310
|
-
`[config] ${configFile}
|
|
411
|
+
`[config] ${configFile} not found — continuing with built-in defaults.`,
|
|
311
412
|
);
|
|
312
413
|
} else {
|
|
313
414
|
try {
|
|
@@ -316,7 +417,7 @@ export async function loadConfig(options = {}) {
|
|
|
316
417
|
source = module.default ?? module;
|
|
317
418
|
loaded = true;
|
|
318
419
|
} catch (error) {
|
|
319
|
-
console.warn(`[config] ${configFile}
|
|
420
|
+
console.warn(`[config] ${configFile} failed to load, ignoring it`, error);
|
|
320
421
|
}
|
|
321
422
|
}
|
|
322
423
|
|
|
@@ -327,7 +428,7 @@ export async function loadConfig(options = {}) {
|
|
|
327
428
|
try {
|
|
328
429
|
return typeof value === "function" ? await value.call(source) : value;
|
|
329
430
|
} catch (error) {
|
|
330
|
-
console.warn(`[config] ${name}()
|
|
431
|
+
console.warn(`[config] ${name}() threw, ignoring it`, error);
|
|
331
432
|
return null;
|
|
332
433
|
}
|
|
333
434
|
};
|
|
@@ -339,7 +440,8 @@ export async function loadConfig(options = {}) {
|
|
|
339
440
|
section("cache"),
|
|
340
441
|
]);
|
|
341
442
|
|
|
342
|
-
const { html, prewarm } =
|
|
443
|
+
const { html, htmlMaxEntries, data, prewarm, prewarmPriority } =
|
|
444
|
+
normalizeCache(cache);
|
|
343
445
|
const dirs = resolveDirs(root, source.paths);
|
|
344
446
|
const brand = { ...DEFAULT_BRAND, ...(source.brand ?? {}) };
|
|
345
447
|
|
|
@@ -351,7 +453,10 @@ export async function loadConfig(options = {}) {
|
|
|
351
453
|
redirects: normalizeRedirects(redirects),
|
|
352
454
|
rewrites: normalizeRewrites(rewrites),
|
|
353
455
|
html,
|
|
456
|
+
htmlMaxEntries,
|
|
457
|
+
data,
|
|
354
458
|
prewarm,
|
|
459
|
+
prewarmPriority,
|
|
355
460
|
brand,
|
|
356
461
|
hooks: source.hooks ?? {},
|
|
357
462
|
layout: resolveLayout(dirs, source.layout),
|
|
@@ -363,6 +468,7 @@ export async function loadConfig(options = {}) {
|
|
|
363
468
|
devGateBypass: source.devGateBypass ?? DEFAULT_DEV_GATE_BYPASS,
|
|
364
469
|
preconnect: source.preconnect ?? [],
|
|
365
470
|
navigation: normalizeNavigation(source.navigation, brand),
|
|
471
|
+
security: normalizeSecurity(source.security),
|
|
366
472
|
prewarmSkip: source.prewarmSkip ?? DEFAULT_PREWARM_SKIP,
|
|
367
473
|
// `routes`, `views` ve `lib` zaten izlenir; buraya yalnızca ek dizinler.
|
|
368
474
|
watch: source.watch ?? [],
|
|
@@ -377,15 +483,20 @@ export async function loadConfig(options = {}) {
|
|
|
377
483
|
// Dev'de build ve sunucu ayrı alt süreçler; üçü de aynı özeti basınca satır
|
|
378
484
|
// banner'ın ve build bloğunun arasına üç kez giriyor. Özeti dış süreç basar.
|
|
379
485
|
if (loaded && !process.env.JSKELET_CHILD) {
|
|
486
|
+
/** @param {number} count @param {string} singular @param {string} plural */
|
|
487
|
+
const label = (count, singular, plural) =>
|
|
488
|
+
`${count} ${count === 1 ? singular : plural}`;
|
|
489
|
+
|
|
380
490
|
const counts = [
|
|
381
|
-
config.headers.length &&
|
|
382
|
-
config.redirects.length &&
|
|
383
|
-
|
|
384
|
-
config.
|
|
491
|
+
config.headers.length && label(config.headers.length, "header", "headers"),
|
|
492
|
+
config.redirects.length &&
|
|
493
|
+
label(config.redirects.length, "redirect", "redirects"),
|
|
494
|
+
config.rewrites.length && label(config.rewrites.length, "rewrite", "rewrites"),
|
|
495
|
+
config.html.length && label(config.html.length, "cache rule", "cache rules"),
|
|
385
496
|
].filter(Boolean);
|
|
386
497
|
|
|
387
498
|
if (counts.length) {
|
|
388
|
-
console.log(`[config] ${configFile}
|
|
499
|
+
console.log(`[config] ${configFile} loaded — ${counts.join(", ")}`);
|
|
389
500
|
}
|
|
390
501
|
}
|
|
391
502
|
|
|
@@ -402,8 +513,8 @@ export async function loadConfig(options = {}) {
|
|
|
402
513
|
export function getConfig() {
|
|
403
514
|
if (!config) {
|
|
404
515
|
throw new Error(
|
|
405
|
-
"[config]
|
|
406
|
-
"
|
|
516
|
+
"[config] getConfig() was used before loadConfig(). " +
|
|
517
|
+
"Start the server with the `jskelet` CLI or through createApp().",
|
|
407
518
|
);
|
|
408
519
|
}
|
|
409
520
|
return config;
|
|
@@ -427,7 +538,7 @@ export async function hook(name, fallback, ...args) {
|
|
|
427
538
|
try {
|
|
428
539
|
return await fn(...args);
|
|
429
540
|
} catch (error) {
|
|
430
|
-
console.warn(`[config] hooks.${name}()
|
|
541
|
+
console.warn(`[config] hooks.${name}() threw, using the default`, error);
|
|
431
542
|
return fallback;
|
|
432
543
|
}
|
|
433
544
|
}
|
package/src/config/pattern.js
CHANGED
|
@@ -28,7 +28,7 @@ function escapeLiteral(text) {
|
|
|
28
28
|
*/
|
|
29
29
|
export function compilePattern(source) {
|
|
30
30
|
if (typeof source !== "string" || !source.startsWith("/")) {
|
|
31
|
-
console.warn(`[config]
|
|
31
|
+
console.warn(`[config] invalid source (must start with \`/\`): ${source}`);
|
|
32
32
|
return null;
|
|
33
33
|
}
|
|
34
34
|
|
package/src/dev-server.mjs
CHANGED
|
@@ -350,7 +350,7 @@ function watchSources() {
|
|
|
350
350
|
});
|
|
351
351
|
} catch {
|
|
352
352
|
log.warn(
|
|
353
|
-
|
|
353
|
+
`could not watch ${path.relative(ROOT, target)}; no auto restart for this directory.`,
|
|
354
354
|
);
|
|
355
355
|
}
|
|
356
356
|
}
|
package/src/http/control-flow.js
CHANGED
|
@@ -14,7 +14,7 @@ export class NotFoundError extends Error {
|
|
|
14
14
|
export class RedirectError extends Error {
|
|
15
15
|
/**
|
|
16
16
|
* @param {string} location
|
|
17
|
-
* @param {301 | 302 | 307 | 308} [statusCode]
|
|
17
|
+
* @param {301 | 302 | 303 | 307 | 308} [statusCode]
|
|
18
18
|
*/
|
|
19
19
|
constructor(location, statusCode = 307) {
|
|
20
20
|
super(`Redirect to ${location}`);
|
|
@@ -45,6 +45,21 @@ export function redirect(location) {
|
|
|
45
45
|
throw new RedirectError(location, 307);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* POST sonrası yönlendirme (303 See Other).
|
|
50
|
+
*
|
|
51
|
+
* `redirect()` 307 kullanır ve 307 **metodu korur**: bir POST handler'ından
|
|
52
|
+
* çağrıldığında tarayıcı hedefe yeniden POST eder. Form gönderiminden sonra
|
|
53
|
+
* sayfayı GET olarak açmak — yani geri tuşunun formu yeniden göndermediği
|
|
54
|
+
* klasik "post/redirect/get" akışı — 303 gerektiriyor.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} location
|
|
57
|
+
* @returns {never}
|
|
58
|
+
*/
|
|
59
|
+
export function seeOther(location) {
|
|
60
|
+
throw new RedirectError(location, 303);
|
|
61
|
+
}
|
|
62
|
+
|
|
48
63
|
/** @param {unknown} error */
|
|
49
64
|
export function isNotFoundError(error) {
|
|
50
65
|
return error instanceof NotFoundError;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie okuma/yazma ve HMAC ile imzalama.
|
|
3
|
+
*
|
|
4
|
+
* Neden framework'te: kişiye özel her sayfa bir oturum cookie'sine dayanıyor
|
|
5
|
+
* ve bunu elle yazan her proje aynı üç hatayı tekrar ediyor — `HttpOnly`
|
|
6
|
+
* unutmak, imzasız değere güvenmek, karşılaştırmayı `===` ile yapmak.
|
|
7
|
+
* Burada varsayılanlar güvenli tarafta ve imza doğrulaması sabit zamanlı.
|
|
8
|
+
*
|
|
9
|
+
* Framework **kimlik sağlamaz**: oturumun içinde ne olduğu, ne kadar
|
|
10
|
+
* yaşadığı ve kimin verdiği uygulamanın kararı. Buradaki yüzey yalnızca
|
|
11
|
+
* "bu değeri ben yazdım, kurcalanmamış" garantisini veriyor.
|
|
12
|
+
*
|
|
13
|
+
* Bağımlılık eklenmez; `node:crypto` yeterli.
|
|
14
|
+
*/
|
|
15
|
+
import crypto from "node:crypto";
|
|
16
|
+
import process from "node:process";
|
|
17
|
+
import { getConfig } from "../config/index.js";
|
|
18
|
+
import { markTainted } from "./request-context.js";
|
|
19
|
+
|
|
20
|
+
/** Ayrıştırılmış cookie'ler istek başına bir kez hesaplanır. */
|
|
21
|
+
const PARSED = Symbol("jskelet.cookies");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} value
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
function base64url(value) {
|
|
28
|
+
return Buffer.from(value, "utf8").toString("base64url");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} value
|
|
33
|
+
* @returns {string | null}
|
|
34
|
+
*/
|
|
35
|
+
function fromBase64url(value) {
|
|
36
|
+
try {
|
|
37
|
+
return Buffer.from(value, "base64url").toString("utf8");
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* İmza sırrı. `security.cookieSecret` ya da `JSKELET_SECRET`.
|
|
45
|
+
*
|
|
46
|
+
* Yokluğunda imzasız cookie yazmak en kötü sonuç olurdu: uygulama kendini
|
|
47
|
+
* güvende sanar, değer kurcalanabilir. Bu yüzden imzalı API sır olmadan
|
|
48
|
+
* hata verir — config hatasının siteyi düşürmemesi kuralı burada geçmez,
|
|
49
|
+
* çünkü sessiz alternatif bir güvenlik açığı.
|
|
50
|
+
*
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
function getSecret() {
|
|
54
|
+
/** @type {string | null} */
|
|
55
|
+
let configured = null;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
configured = getConfig().security.cookieSecret;
|
|
59
|
+
} catch {
|
|
60
|
+
// Config yüklenmemiş olabilir (script, test); env yine de geçerli.
|
|
61
|
+
configured = null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const secret = configured ?? process.env.JSKELET_SECRET ?? null;
|
|
65
|
+
|
|
66
|
+
if (!secret) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
"[cookies] a secret is required for signed cookies. Set " +
|
|
69
|
+
"`security.cookieSecret` in `jskelet.config.mjs` or the JSKELET_SECRET environment variable.",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return secret;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {string} value
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
function sign(value) {
|
|
81
|
+
return crypto.createHmac("sha256", getSecret()).update(value).digest("base64url");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Sabit zamanlı karşılaştırma: imza doğrulamasında erken çıkış, saldırganın
|
|
86
|
+
* baytları tek tek tahmin etmesine kapı aralar.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} a
|
|
89
|
+
* @param {string} b
|
|
90
|
+
* @returns {boolean}
|
|
91
|
+
*/
|
|
92
|
+
export function safeEqual(a, b) {
|
|
93
|
+
const left = Buffer.from(String(a));
|
|
94
|
+
const right = Buffer.from(String(b));
|
|
95
|
+
if (left.length !== right.length) return false;
|
|
96
|
+
return crypto.timingSafeEqual(left, right);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `Cookie` başlığını ayrıştırır.
|
|
101
|
+
*
|
|
102
|
+
* Okuma çıktının kullanıcıya bağlı olduğunu bildirir: bu sayfa artık public
|
|
103
|
+
* HTML cache'ine yazılamaz.
|
|
104
|
+
*
|
|
105
|
+
* @param {import('http').IncomingMessage} req
|
|
106
|
+
* @returns {Record<string, string>}
|
|
107
|
+
*/
|
|
108
|
+
export function parseCookies(req) {
|
|
109
|
+
markTainted("parseCookies(req)");
|
|
110
|
+
|
|
111
|
+
const cached = /** @type {any} */ (req)[PARSED];
|
|
112
|
+
if (cached) return cached;
|
|
113
|
+
|
|
114
|
+
/** @type {Record<string, string>} */
|
|
115
|
+
const out = {};
|
|
116
|
+
const header = req.headers?.cookie;
|
|
117
|
+
|
|
118
|
+
if (header) {
|
|
119
|
+
for (const part of header.split(";")) {
|
|
120
|
+
const index = part.indexOf("=");
|
|
121
|
+
if (index === -1) continue;
|
|
122
|
+
|
|
123
|
+
const name = part.slice(0, index).trim();
|
|
124
|
+
if (!name) continue;
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
out[name] = decodeURIComponent(part.slice(index + 1).trim());
|
|
128
|
+
} catch {
|
|
129
|
+
// Bozuk yüzde kodlaması tüm başlığı çöpe atmamalı.
|
|
130
|
+
out[name] = part.slice(index + 1).trim();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @type {any} */ (req)[PARSED] = out;
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @typedef {object} CookieOptions
|
|
141
|
+
* @property {string} [path] Varsayılan `/`.
|
|
142
|
+
* @property {string} [domain]
|
|
143
|
+
* @property {number} [maxAge] Saniye.
|
|
144
|
+
* @property {Date} [expires]
|
|
145
|
+
* @property {boolean} [httpOnly] Varsayılan `true`.
|
|
146
|
+
* @property {boolean} [secure] Varsayılan: development dışında `true`.
|
|
147
|
+
* @property {"Strict" | "Lax" | "None"} [sameSite] Varsayılan `Lax`.
|
|
148
|
+
*/
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Varsayılanlar bilinçli olarak kısıtlayıcı: `HttpOnly` ile JS okuyamaz,
|
|
152
|
+
* `SameSite=Lax` ile çapraz site POST'larında gönderilmez (CSRF'nin büyük
|
|
153
|
+
* kısmını kapatan tek satır), `Secure` üretimde açık.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} name
|
|
156
|
+
* @param {string} value
|
|
157
|
+
* @param {CookieOptions} [options]
|
|
158
|
+
* @returns {string}
|
|
159
|
+
*/
|
|
160
|
+
export function serializeCookie(name, value, options = {}) {
|
|
161
|
+
const parts = [`${name}=${encodeURIComponent(value)}`];
|
|
162
|
+
|
|
163
|
+
parts.push(`Path=${options.path ?? "/"}`);
|
|
164
|
+
if (options.domain) parts.push(`Domain=${options.domain}`);
|
|
165
|
+
if (options.maxAge !== undefined) parts.push(`Max-Age=${Math.floor(options.maxAge)}`);
|
|
166
|
+
if (options.expires) parts.push(`Expires=${options.expires.toUTCString()}`);
|
|
167
|
+
if (options.httpOnly !== false) parts.push("HttpOnly");
|
|
168
|
+
if (options.secure ?? process.env.NODE_ENV !== "development") parts.push("Secure");
|
|
169
|
+
parts.push(`SameSite=${options.sameSite ?? "Lax"}`);
|
|
170
|
+
|
|
171
|
+
return parts.join("; ");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {import('http').ServerResponse} res
|
|
176
|
+
* @param {string} name
|
|
177
|
+
* @param {string} value
|
|
178
|
+
* @param {CookieOptions} [options]
|
|
179
|
+
*/
|
|
180
|
+
export function setCookie(res, name, value, options = {}) {
|
|
181
|
+
const existing = res.getHeader("Set-Cookie");
|
|
182
|
+
const serialized = serializeCookie(name, value, options);
|
|
183
|
+
|
|
184
|
+
/** @type {string[]} */
|
|
185
|
+
const all = existing
|
|
186
|
+
? Array.isArray(existing)
|
|
187
|
+
? [...existing.map(String)]
|
|
188
|
+
: [String(existing)]
|
|
189
|
+
: [];
|
|
190
|
+
|
|
191
|
+
all.push(serialized);
|
|
192
|
+
res.setHeader("Set-Cookie", all);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {import('http').ServerResponse} res
|
|
197
|
+
* @param {string} name
|
|
198
|
+
* @param {CookieOptions} [options]
|
|
199
|
+
*/
|
|
200
|
+
export function clearCookie(res, name, options = {}) {
|
|
201
|
+
setCookie(res, name, "", { ...options, maxAge: 0, expires: new Date(0) });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* İmzalı cookie yazar. Değer okunabilir kalır (şifreleme değil, imza);
|
|
206
|
+
* gizli kalması gereken veriyi cookie'ye koymayın, kimliğini koyun.
|
|
207
|
+
*
|
|
208
|
+
* @param {import('http').ServerResponse} res
|
|
209
|
+
* @param {string} name
|
|
210
|
+
* @param {string} value
|
|
211
|
+
* @param {CookieOptions} [options]
|
|
212
|
+
*/
|
|
213
|
+
export function setSignedCookie(res, name, value, options = {}) {
|
|
214
|
+
const encoded = base64url(value);
|
|
215
|
+
setCookie(res, name, `${encoded}.${sign(encoded)}`, options);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* İmzalı cookie okur. İmza uymuyorsa `null` — bozuk imza, yok sayılmalı,
|
|
220
|
+
* "belki geçerlidir" diye kullanılmamalı.
|
|
221
|
+
*
|
|
222
|
+
* @param {import('http').IncomingMessage} req
|
|
223
|
+
* @param {string} name
|
|
224
|
+
* @returns {string | null}
|
|
225
|
+
*/
|
|
226
|
+
export function getSignedCookie(req, name) {
|
|
227
|
+
const raw = parseCookies(req)[name];
|
|
228
|
+
if (!raw) return null;
|
|
229
|
+
|
|
230
|
+
const index = raw.lastIndexOf(".");
|
|
231
|
+
if (index <= 0) return null;
|
|
232
|
+
|
|
233
|
+
const encoded = raw.slice(0, index);
|
|
234
|
+
const signature = raw.slice(index + 1);
|
|
235
|
+
|
|
236
|
+
let expected;
|
|
237
|
+
try {
|
|
238
|
+
expected = sign(encoded);
|
|
239
|
+
} catch {
|
|
240
|
+
// Sır yoksa imzalı okuma sessizce başarısız olur: sunucu ayakta kalır
|
|
241
|
+
// ama hiçbir oturum geçerli sayılmaz.
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!safeEqual(signature, expected)) return null;
|
|
246
|
+
return fromBase64url(encoded);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Kriptografik rastgele token. CSRF token'ı ve oturum kimliği için.
|
|
251
|
+
*
|
|
252
|
+
* @param {number} [bytes]
|
|
253
|
+
* @returns {string}
|
|
254
|
+
*/
|
|
255
|
+
export function randomToken(bytes = 32) {
|
|
256
|
+
return crypto.randomBytes(bytes).toString("base64url");
|
|
257
|
+
}
|