jskelet 0.5.5 → 0.6.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/AGENTS.md +19 -15
- package/CHANGELOG.md +165 -15
- package/README.md +16 -21
- package/bin/jskelet.mjs +23 -9
- package/docs/01-baslangic.md +4 -3
- package/docs/02-mimari.md +10 -4
- package/docs/03-routing.md +14 -7
- package/docs/04-render-ve-sablonlar.md +60 -43
- package/docs/05-islands.md +12 -8
- package/docs/06-cache.md +18 -7
- package/docs/07-yapilandirma.md +69 -27
- package/docs/08-build.md +15 -9
- package/docs/09-dev-araclari.md +22 -8
- package/docs/10-dagitim.md +14 -13
- package/docs/11-tasima.md +51 -17
- package/docs/12-panel-ve-oturum.md +10 -4
- package/docs/README.md +10 -33
- package/docs/en/01-getting-started.md +4 -3
- package/docs/en/02-architecture.md +12 -6
- package/docs/en/03-routing.md +15 -8
- package/docs/en/04-rendering.md +71 -59
- package/docs/en/05-islands.md +13 -8
- package/docs/en/06-caching.md +21 -7
- package/docs/en/07-configuration.md +69 -29
- package/docs/en/08-build.md +16 -10
- package/docs/en/09-dev-tools.md +24 -8
- package/docs/en/10-deployment.md +14 -14
- package/docs/en/11-migration.md +51 -16
- package/docs/en/12-dashboards-and-sessions.md +9 -4
- package/docs/en/README.md +10 -35
- package/package.json +48 -13
- package/src/build/tasks/client.mjs +91 -10
- package/src/build/tasks/icons.mjs +11 -1
- package/src/client/index.js +2 -2
- package/src/compile/codegen.js +4 -0
- package/src/compile/compile-all.js +12 -21
- package/src/compile/expr.js +5 -0
- package/src/compile/parse.js +64 -8
- package/src/compile/resolve.js +3 -0
- package/src/config/defaults.js +48 -5
- package/src/config/index.js +138 -27
- package/src/dev-server.mjs +26 -3
- package/src/http/cookies-entry.js +1 -0
- package/src/http/cookies.js +18 -0
- package/src/logo.png +0 -0
- package/src/migrate/apply.mjs +262 -0
- package/src/migrate/babel.mjs +79 -0
- package/src/migrate/classify.mjs +155 -0
- package/src/migrate/config.mjs +126 -0
- package/src/migrate/fs-walk.mjs +191 -0
- package/src/migrate/parse.mjs +26 -0
- package/src/migrate/scan.mjs +177 -0
- package/src/migrate/transform/expr-source.mjs +168 -0
- package/src/migrate/transform/island.mjs +67 -0
- package/src/migrate/transform/jsx-to-component.mjs +302 -0
- package/src/migrate/transform/jsx-to-jsk.mjs +330 -0
- package/src/migrate/transform/page-split.mjs +435 -0
- package/src/migrate/write.mjs +81 -0
- package/src/migrate.mjs +171 -0
- package/src/server/auth/handoff.js +94 -11
- package/src/server/create-app.js +37 -10
- package/src/server/ejs-adapter.js +59 -0
- package/src/server/html-cache.js +178 -32
- package/src/server/image-optimizer.js +94 -26
- package/src/server/middleware/dev-gate.js +21 -8
- package/src/server/middleware/robots-txt.js +341 -0
- package/src/server/port-guard.js +255 -0
- package/src/server/prewarm.js +137 -51
- package/src/server/render.js +30 -10
- package/src/server/status-page.js +105 -4
- package/src/start.mjs +18 -3
- package/src/templates/layout.ejs +8 -28
- package/src/templates/layout.jsk +30 -0
- package/src/templates/layout.render.js +41 -0
- package/src/views/helpers/tags.js +86 -3
- package/types/build/resolve-peer.d.mts +13 -0
- package/types/client/dom.d.ts +55 -0
- package/types/client/form.d.ts +19 -0
- package/types/client/index.d.ts +20 -0
- package/types/client/registry.d.ts +53 -0
- package/types/client/safe-image.d.ts +19 -0
- package/types/client/shared-cookie.d.ts +82 -0
- package/types/client/store.d.ts +18 -0
- package/types/client/swap.d.ts +46 -0
- package/types/compile/codegen.d.ts +32 -0
- package/types/compile/compile-all.d.ts +42 -0
- package/types/compile/errors.d.ts +30 -0
- package/types/compile/expr.d.ts +67 -0
- package/types/compile/index.d.ts +10 -0
- package/types/compile/parse.d.ts +82 -0
- package/types/compile/resolve.d.ts +46 -0
- package/types/compile/scan-exports.d.ts +9 -0
- package/types/config/defaults.d.ts +477 -0
- package/types/config/index.d.ts +304 -0
- package/types/config/pattern.d.ts +38 -0
- package/types/http/control-flow.d.ts +45 -0
- package/types/http/cookies-entry.d.ts +5 -0
- package/types/http/cookies.d.ts +113 -0
- package/types/http/request-cache.d.ts +13 -0
- package/types/http/request-context.d.ts +67 -0
- package/types/http/shared-cookie.d.ts +73 -0
- package/types/index.d.ts +30 -0
- package/types/log.d.mts +153 -0
- package/types/server/admin/actions.d.ts +16 -0
- package/types/server/admin/auth.d.ts +52 -0
- package/types/server/admin/event-log.d.ts +38 -0
- package/types/server/admin/gate.d.ts +43 -0
- package/types/server/admin/inventory.d.ts +40 -0
- package/types/server/admin/mount.d.ts +6 -0
- package/types/server/admin/router.d.ts +6 -0
- package/types/server/admin/snapshot.d.ts +6 -0
- package/types/server/assets.d.ts +47 -0
- package/types/server/auth/handoff.d.ts +12 -0
- package/types/server/cache-deps.d.ts +16 -0
- package/types/server/cache-vary.d.ts +30 -0
- package/types/server/cloudflare.d.ts +163 -0
- package/types/server/create-app.d.ts +25 -0
- package/types/server/data-cache.d.ts +116 -0
- package/types/server/dev/devtools.d.ts +44 -0
- package/types/server/dev/report.d.ts +229 -0
- package/types/server/dev/socket.d.ts +17 -0
- package/types/server/dev/version-check.d.mts +15 -0
- package/types/server/ejs-adapter.d.ts +11 -0
- package/types/server/head-hints.d.ts +40 -0
- package/types/server/html-cache.d.ts +207 -0
- package/types/server/image-optimizer.d.ts +68 -0
- package/types/server/logs/access-middleware.d.ts +7 -0
- package/types/server/logs/file-sink.d.ts +17 -0
- package/types/server/logs/pipeline.d.ts +37 -0
- package/types/server/logs/s3-put.d.ts +85 -0
- package/types/server/logs/s3-sink.d.ts +26 -0
- package/types/server/metadata.d.ts +38 -0
- package/types/server/middleware/compression.d.ts +17 -0
- package/types/server/middleware/csrf.d.ts +4 -0
- package/types/server/middleware/dev-gate.d.ts +2 -0
- package/types/server/middleware/headers.d.ts +2 -0
- package/types/server/middleware/redirects.d.ts +2 -0
- package/types/server/middleware/robots-txt.d.ts +33 -0
- package/types/server/middleware/static-precompressed.d.ts +5 -0
- package/types/server/middleware/trailing-slash.d.ts +11 -0
- package/types/server/middleware/upstream-proxy.d.ts +21 -0
- package/types/server/og-image.d.ts +149 -0
- package/types/server/port-guard.d.ts +50 -0
- package/types/server/prewarm.d.ts +131 -0
- package/types/server/redis.d.ts +163 -0
- package/types/server/render.d.ts +101 -0
- package/types/server/router.d.ts +5 -0
- package/types/server/status-page.d.ts +24 -0
- package/types/server/upstream-limiter.d.ts +123 -0
- package/types/server/upstream-tracking.d.ts +42 -0
- package/types/shared/cookie-domain.d.ts +29 -0
- package/types/templates/layout.render.d.ts +7 -0
- package/types/version.d.mts +10 -0
- package/types/views/components/loader.d.ts +5 -0
- package/types/views/helpers/html.d.ts +39 -0
- package/types/views/helpers/tags.d.ts +127 -0
|
@@ -8,8 +8,13 @@
|
|
|
8
8
|
*
|
|
9
9
|
* sharp yoksa 302 ile orijinale yönlendirilir — sayfa bozulmaz, tasarruf
|
|
10
10
|
* olmaz. Deployment notu: remote açıksa sharp runtime bağımlılığıdır.
|
|
11
|
+
*
|
|
12
|
+
* Fetch redirect'leri elle takip edilir: her hop allowlist + blocked-address
|
|
13
|
+
* (ve mümkünse DNS çözümü) ile yeniden doğrulanır — açık redirect SSRF'sini
|
|
14
|
+
* kapatmak için `redirect: "follow"` kullanılmaz.
|
|
11
15
|
*/
|
|
12
16
|
import crypto from "node:crypto";
|
|
17
|
+
import dns from "node:dns/promises";
|
|
13
18
|
import fs from "node:fs";
|
|
14
19
|
import path from "node:path";
|
|
15
20
|
import process from "node:process";
|
|
@@ -22,6 +27,8 @@ let sharpModule;
|
|
|
22
27
|
/** @type {string | null} */
|
|
23
28
|
let cacheDir = null;
|
|
24
29
|
|
|
30
|
+
const MAX_REDIRECTS = 5;
|
|
31
|
+
|
|
25
32
|
/**
|
|
26
33
|
* @returns {import('../config/index.js').ImagesRemoteConfig | null}
|
|
27
34
|
*/
|
|
@@ -49,13 +56,45 @@ export function parseAllowedRemoteUrl(src) {
|
|
|
49
56
|
return null;
|
|
50
57
|
}
|
|
51
58
|
|
|
52
|
-
if (url
|
|
53
|
-
if (!isHostAllowed(url.hostname, remote.allowHosts)) return null;
|
|
54
|
-
if (isBlockedAddress(url.hostname)) return null;
|
|
55
|
-
|
|
59
|
+
if (!isRemoteUrlShapeAllowed(url, remote.allowHosts)) return null;
|
|
56
60
|
return url;
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Host allowlist + literal private IP; redirect hop'larında da kullanılır.
|
|
65
|
+
*
|
|
66
|
+
* @param {URL} url
|
|
67
|
+
* @param {string[]} allowHosts
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
export function isRemoteUrlShapeAllowed(url, allowHosts) {
|
|
71
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
72
|
+
if (!isHostAllowed(url.hostname, allowHosts)) return false;
|
|
73
|
+
if (isBlockedAddress(url.hostname)) return false;
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Hostname'i çözümleyip private IP'ye düşüyorsa reddet (DNS rebinding
|
|
79
|
+
* savunması; TOCTOU kalır ama check-time private resolve yakalanır).
|
|
80
|
+
*
|
|
81
|
+
* @param {string} hostname
|
|
82
|
+
* @returns {Promise<boolean>} true = güvenli
|
|
83
|
+
*/
|
|
84
|
+
export async function assertResolvedHostSafe(hostname) {
|
|
85
|
+
if (isBlockedAddress(hostname)) return false;
|
|
86
|
+
try {
|
|
87
|
+
const results = await dns.lookup(hostname, { all: true, verbatim: true });
|
|
88
|
+
if (!results.length) return false;
|
|
89
|
+
for (const { address } of results) {
|
|
90
|
+
if (isBlockedAddress(address)) return false;
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
59
98
|
/**
|
|
60
99
|
* @param {string} hostname
|
|
61
100
|
* @param {string[]} allowHosts
|
|
@@ -257,6 +296,8 @@ async function handleOptimize(req, res, remote) {
|
|
|
257
296
|
}
|
|
258
297
|
|
|
259
298
|
/**
|
|
299
|
+
* Redirect'leri elle takip eder; her hop allowlist + DNS private kontrolünden geçer.
|
|
300
|
+
*
|
|
260
301
|
* @param {string} href
|
|
261
302
|
* @param {import('../config/index.js').ImagesRemoteConfig} remote
|
|
262
303
|
* @returns {Promise<{ ok: true, buffer: Buffer } | { ok: false }>}
|
|
@@ -266,30 +307,57 @@ async function fetchUpstream(href, remote) {
|
|
|
266
307
|
const timer = setTimeout(() => controller.abort(), remote.fetchTimeoutMs);
|
|
267
308
|
|
|
268
309
|
try {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
310
|
+
let current = href;
|
|
311
|
+
|
|
312
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
313
|
+
let url;
|
|
314
|
+
try {
|
|
315
|
+
url = new URL(current);
|
|
316
|
+
} catch {
|
|
317
|
+
return { ok: false };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (!isRemoteUrlShapeAllowed(url, remote.allowHosts)) return { ok: false };
|
|
321
|
+
if (!(await assertResolvedHostSafe(url.hostname))) return { ok: false };
|
|
322
|
+
|
|
323
|
+
const response = await fetch(url.href, {
|
|
324
|
+
signal: controller.signal,
|
|
325
|
+
redirect: "manual",
|
|
326
|
+
headers: {
|
|
327
|
+
// Bazı CDN'ler bot UA reddeder; tarayıcıya yakın tut.
|
|
328
|
+
Accept: "image/avif,image/webp,image/*,*/*;q=0.8",
|
|
329
|
+
"User-Agent": "jskelet-image-optimizer/1",
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
if (response.status >= 300 && response.status < 400) {
|
|
334
|
+
const location = response.headers.get("location");
|
|
335
|
+
if (!location) return { ok: false };
|
|
336
|
+
try {
|
|
337
|
+
current = new URL(location, url).href;
|
|
338
|
+
} catch {
|
|
339
|
+
return { ok: false };
|
|
340
|
+
}
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!response.ok) return { ok: false };
|
|
345
|
+
|
|
346
|
+
const type = response.headers.get("content-type") ?? "";
|
|
347
|
+
if (type && !type.startsWith("image/") && !type.includes("octet-stream")) {
|
|
348
|
+
return { ok: false };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const length = Number(response.headers.get("content-length") ?? 0);
|
|
352
|
+
if (length > remote.maxBytes) return { ok: false };
|
|
353
|
+
|
|
354
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
355
|
+
if (buffer.byteLength > remote.maxBytes) return { ok: false };
|
|
356
|
+
|
|
357
|
+
return { ok: true, buffer };
|
|
284
358
|
}
|
|
285
359
|
|
|
286
|
-
|
|
287
|
-
if (length > remote.maxBytes) return { ok: false };
|
|
288
|
-
|
|
289
|
-
const buffer = Buffer.from(await response.arrayBuffer());
|
|
290
|
-
if (buffer.byteLength > remote.maxBytes) return { ok: false };
|
|
291
|
-
|
|
292
|
-
return { ok: true, buffer };
|
|
360
|
+
return { ok: false };
|
|
293
361
|
} catch {
|
|
294
362
|
return { ok: false };
|
|
295
363
|
} finally {
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Yayına açılmamış bir ortamı gizler
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Yayına açılmamış bir ortamı gizler. Gate kapalıyken (varsayılan) `DEV_TOKEN`
|
|
3
|
+
* olsa bile istekler geçer: değişkenin task ortamında unutulması production'u
|
|
4
|
+
* kilitlemesin. Açmak için `devGate: true` ya da `DEV_GATE=1` gerekir; o zaman
|
|
5
|
+
* token taşımayan her isteğe 404 döner. 403 değil 404 — 403 ortamın var
|
|
6
|
+
* olduğunu doğrular, 404 hiç yokmuş gibi davranır.
|
|
5
7
|
*
|
|
6
8
|
* Token bir kez `?dev_token=…` ile gelirse çereze yazılır, böylece link
|
|
7
|
-
* paylaşımı yeterli olur.
|
|
8
|
-
* ve üretimde hiçbir maliyeti olmaz.
|
|
9
|
+
* paylaşımı yeterli olur. Token boşsa gate açık olsa da kimse kilitlenmez.
|
|
9
10
|
*/
|
|
10
11
|
import process from "node:process";
|
|
11
12
|
import { getConfig } from "../../config/index.js";
|
|
@@ -32,13 +33,25 @@ function readCookie(req, name) {
|
|
|
32
33
|
|
|
33
34
|
/** @returns {import('express').RequestHandler} */
|
|
34
35
|
export function devGate() {
|
|
35
|
-
const { devGateBypass, brand } = getConfig();
|
|
36
|
+
const { devGate: enabled, devGateBypass, brand } = getConfig();
|
|
36
37
|
const bypass = new Set(devGateBypass);
|
|
37
38
|
const cookieName = brand.devTokenCookie;
|
|
39
|
+
const devToken = process.env.DEV_TOKEN;
|
|
40
|
+
|
|
41
|
+
// Unutulmuş bir değişken sessizce herkese 404 kesmesin; operatör görsün.
|
|
42
|
+
if (!enabled && devToken) {
|
|
43
|
+
console.warn(
|
|
44
|
+
"[dev-gate] DEV_TOKEN is set but the gate is off, so the site stays public. " +
|
|
45
|
+
"Set devGate: true or DEV_GATE=1 to require the token.",
|
|
46
|
+
);
|
|
47
|
+
} else if (enabled && !devToken) {
|
|
48
|
+
console.warn(
|
|
49
|
+
"[dev-gate] the gate is on but DEV_TOKEN is empty; requests are not blocked.",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
38
52
|
|
|
39
53
|
return (req, res, next) => {
|
|
40
|
-
|
|
41
|
-
if (!devToken) return next();
|
|
54
|
+
if (!enabled || !devToken) return next();
|
|
42
55
|
if (bypass.has(req.path)) return next();
|
|
43
56
|
|
|
44
57
|
const fromQuery = req.query?.[cookieName];
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kullanıcının `robots.txt` gövdesinin altına framework uçlarını ekler.
|
|
3
|
+
*
|
|
4
|
+
* Panel, görsel proxy, auth handoff, dev araçları ve fragment parçaları
|
|
5
|
+
* sayfa değildir; bir crawler onları dizinine alırsa indeks parçalı HTML
|
|
6
|
+
* ve yönetim yollarıyla kirlenir. Uygulama dosyayı kendisi yazar (`public/`
|
|
7
|
+
* ya da bir route). Framework yalnızca başarılı metin yanıtının sonuna bir
|
|
8
|
+
* not ve `Disallow` grubu düşer — dosya yoksa uydurulmaz.
|
|
9
|
+
*
|
|
10
|
+
* Google, belirli bir user-agent grubunu `User-agent: *` ile birleştirmez;
|
|
11
|
+
* aynı ajanın ikinci grubunu birleştirir. Bu yüzden alttaki grup, dosyada
|
|
12
|
+
* adı geçen her ajanı tekrarlar. Aksi hâlde yalnızca `Googlebot` grubu olan
|
|
13
|
+
* bir dosyada ek hiç uygulanmazdı.
|
|
14
|
+
*
|
|
15
|
+
* Sıkıştırılmış kopyanın (`Content-Encoding` dolu) üzerine yazılmaz: ek,
|
|
16
|
+
* düz metnin sonuna konur, sıkıştırma dıştaki middleware'de olur.
|
|
17
|
+
*/
|
|
18
|
+
import crypto from "node:crypto";
|
|
19
|
+
import { getConfig } from "../../config/index.js";
|
|
20
|
+
|
|
21
|
+
/** İdempotency anahtarı. Marka adı değişse de ikinci kez eklenmez. */
|
|
22
|
+
const NOTE_MARK = "framework endpoints, not for indexing";
|
|
23
|
+
|
|
24
|
+
/** Framework'ün ayırdığı önekler. Özel bir yola taşınsalar da bunlar kalır. */
|
|
25
|
+
const RESERVED_PREFIXES = ["/_jskelet/", "/__jskelet/", "/_fragment/"];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} [brandName]
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
export function frameworkRobotsNote(brandName = "JSkelet") {
|
|
32
|
+
const name = String(brandName || "JSkelet")
|
|
33
|
+
.replace(/[\r\n]+/g, " ")
|
|
34
|
+
.trim();
|
|
35
|
+
return `# ${name || "JSkelet"} — ${NOTE_MARK}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `robots.txt`'e yazılacak `Disallow` yolları.
|
|
40
|
+
*
|
|
41
|
+
* Üç önek her zaman vardır. Panel, uzak görsel ve handoff yalnızca
|
|
42
|
+
* gerçekten mount edildiklerinde ve öneklerin dışına taşındıklarında eklenir.
|
|
43
|
+
* Dev araçlarının özel yolu yalnızca development'ta yazılır: production'da
|
|
44
|
+
* o yol uygulamanın kendi sayfası olabilir.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} [config]
|
|
47
|
+
* @param {{ dev?: boolean }} [options]
|
|
48
|
+
* @returns {string[]}
|
|
49
|
+
*/
|
|
50
|
+
export function frameworkDisallowPaths(config = {}, options = {}) {
|
|
51
|
+
/** @type {string[]} */
|
|
52
|
+
const paths = [...RESERVED_PREFIXES];
|
|
53
|
+
const dev = options.dev ?? process.env.NODE_ENV === "development";
|
|
54
|
+
|
|
55
|
+
/** @type {string[]} */
|
|
56
|
+
const extras = [];
|
|
57
|
+
|
|
58
|
+
const admin = /** @type {{ enabled?: boolean, basePath?: string }} */ (
|
|
59
|
+
config.admin
|
|
60
|
+
);
|
|
61
|
+
if (admin?.enabled && typeof admin.basePath === "string") {
|
|
62
|
+
extras.push(admin.basePath);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const images = /** @type {{ remote?: { enabled?: boolean, path?: string } | false } | false} */ (
|
|
66
|
+
config.images
|
|
67
|
+
);
|
|
68
|
+
const remote = images && images !== false ? images.remote : null;
|
|
69
|
+
if (remote && remote !== false && remote.enabled && typeof remote.path === "string") {
|
|
70
|
+
extras.push(remote.path);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const auth = /** @type {{ crossSubdomainHandoff?: boolean | { enabled?: boolean, path?: string } }} */ (
|
|
74
|
+
config.auth
|
|
75
|
+
);
|
|
76
|
+
const handoff = auth?.crossSubdomainHandoff;
|
|
77
|
+
const handoffOn =
|
|
78
|
+
handoff === true ||
|
|
79
|
+
(Boolean(handoff) &&
|
|
80
|
+
typeof handoff === "object" &&
|
|
81
|
+
handoff.enabled !== false);
|
|
82
|
+
if (handoffOn && handoff && typeof handoff === "object" && typeof handoff.path === "string") {
|
|
83
|
+
extras.push(handoff.path);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const brand = /** @type {{ devBasePath?: string }} */ (config.brand);
|
|
87
|
+
if (dev && typeof brand?.devBasePath === "string") {
|
|
88
|
+
extras.push(brand.devBasePath);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const raw of extras) {
|
|
92
|
+
const path = cleanDisallowPath(raw);
|
|
93
|
+
if (!path) continue;
|
|
94
|
+
if (paths.some((existing) => coveredBy(existing, path))) continue;
|
|
95
|
+
paths.push(path);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return paths;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {string} body
|
|
103
|
+
* @param {{ brandName?: string, paths: string[] }} options
|
|
104
|
+
* @returns {string}
|
|
105
|
+
*/
|
|
106
|
+
export function appendFrameworkRobots(body, options) {
|
|
107
|
+
const text = String(body ?? "").replace(/^\uFEFF/, "");
|
|
108
|
+
const paths = options.paths.filter((path) => cleanDisallowPath(path) || isReserved(path));
|
|
109
|
+
if (paths.length === 0 || text.includes(NOTE_MARK)) return text;
|
|
110
|
+
|
|
111
|
+
const agents = collectUserAgents(text);
|
|
112
|
+
const block = [
|
|
113
|
+
frameworkRobotsNote(options.brandName),
|
|
114
|
+
...agents.map((agent) => `User-agent: ${agent}`),
|
|
115
|
+
...paths.map((path) => `Disallow: ${path}`),
|
|
116
|
+
"",
|
|
117
|
+
].join("\n");
|
|
118
|
+
|
|
119
|
+
const base = text.replace(/\s*$/, "");
|
|
120
|
+
return base ? `${base}\n\n${block}` : block;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @returns {import('express').RequestHandler}
|
|
125
|
+
*/
|
|
126
|
+
export function robotsTxtMiddleware() {
|
|
127
|
+
return function robotsTxt(req, res, next) {
|
|
128
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
129
|
+
next();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const pathname = req.path || "";
|
|
134
|
+
if (pathname !== "/robots.txt") {
|
|
135
|
+
next();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Handler'ın kendi ETag'i eklenmemiş gövdeye aittir. 304, crawler'ın
|
|
140
|
+
// framework bloğu olmayan kopyayı tutmasına yol açardı.
|
|
141
|
+
const previousEtag = req.headers["if-none-match"];
|
|
142
|
+
delete req.headers["if-none-match"];
|
|
143
|
+
delete req.headers["if-modified-since"];
|
|
144
|
+
|
|
145
|
+
/** @type {Buffer[]} */
|
|
146
|
+
const chunks = [];
|
|
147
|
+
const originalEnd = res.end;
|
|
148
|
+
let finished = false;
|
|
149
|
+
|
|
150
|
+
res.write = function write(chunk, encoding, callback) {
|
|
151
|
+
const enc = typeof encoding === "function" ? undefined : encoding;
|
|
152
|
+
const done = typeof encoding === "function" ? encoding : callback;
|
|
153
|
+
const buf = takeChunk(chunk, enc);
|
|
154
|
+
if (buf && buf.length) chunks.push(buf);
|
|
155
|
+
if (typeof done === "function") done();
|
|
156
|
+
return true;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
res.end = function end(chunk, encoding, callback) {
|
|
160
|
+
if (finished) return originalEnd.call(this);
|
|
161
|
+
finished = true;
|
|
162
|
+
|
|
163
|
+
const enc = typeof encoding === "function" ? undefined : encoding;
|
|
164
|
+
const done =
|
|
165
|
+
typeof chunk === "function"
|
|
166
|
+
? chunk
|
|
167
|
+
: typeof encoding === "function"
|
|
168
|
+
? encoding
|
|
169
|
+
: callback;
|
|
170
|
+
const buf = takeChunk(chunk, enc);
|
|
171
|
+
if (buf && buf.length) chunks.push(buf);
|
|
172
|
+
|
|
173
|
+
const body = chunks.length === 0 ? Buffer.alloc(0) : Buffer.concat(chunks);
|
|
174
|
+
|
|
175
|
+
if (!shouldRewrite(req, res, body)) {
|
|
176
|
+
return typeof done === "function"
|
|
177
|
+
? originalEnd.call(this, body, done)
|
|
178
|
+
: originalEnd.call(this, body);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const context = readRobotsContext();
|
|
182
|
+
const nextBody = Buffer.from(
|
|
183
|
+
appendFrameworkRobots(body.toString("utf8"), context),
|
|
184
|
+
"utf8",
|
|
185
|
+
);
|
|
186
|
+
const tag = `"${crypto.createHash("sha1").update(nextBody).digest("base64url")}"`;
|
|
187
|
+
|
|
188
|
+
if (previousEtag && etagMatches(previousEtag, tag)) {
|
|
189
|
+
res.statusCode = 304;
|
|
190
|
+
res.setHeader("ETag", tag);
|
|
191
|
+
res.setHeader("Content-Length", "0");
|
|
192
|
+
res.removeHeader("Last-Modified");
|
|
193
|
+
return originalEnd.call(this);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const type = String(res.getHeader("content-type") || "");
|
|
197
|
+
if (!/^text\/plain\b/i.test(type)) {
|
|
198
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
199
|
+
}
|
|
200
|
+
res.setHeader("ETag", tag);
|
|
201
|
+
res.setHeader("Content-Length", String(nextBody.length));
|
|
202
|
+
res.removeHeader("Last-Modified");
|
|
203
|
+
|
|
204
|
+
return typeof done === "function"
|
|
205
|
+
? originalEnd.call(this, nextBody, done)
|
|
206
|
+
: originalEnd.call(this, nextBody);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
next();
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @returns {{ brandName: string, paths: string[] }}
|
|
215
|
+
*/
|
|
216
|
+
function readRobotsContext() {
|
|
217
|
+
try {
|
|
218
|
+
const config = getConfig();
|
|
219
|
+
const brand = /** @type {{ name?: string }} */ (config.brand);
|
|
220
|
+
return {
|
|
221
|
+
brandName: typeof brand?.name === "string" && brand.name ? brand.name : "JSkelet",
|
|
222
|
+
paths: frameworkDisallowPaths(config),
|
|
223
|
+
};
|
|
224
|
+
} catch {
|
|
225
|
+
return { brandName: "JSkelet", paths: frameworkDisallowPaths({}) };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @param {import('express').Request} req
|
|
231
|
+
* @param {import('express').Response} res
|
|
232
|
+
* @param {Buffer} body
|
|
233
|
+
* @returns {boolean}
|
|
234
|
+
*/
|
|
235
|
+
function shouldRewrite(req, res, body) {
|
|
236
|
+
if (req.method !== "GET") return false;
|
|
237
|
+
if (res.headersSent) return false;
|
|
238
|
+
|
|
239
|
+
const status = res.statusCode || 200;
|
|
240
|
+
if (status < 200 || status >= 300) return false;
|
|
241
|
+
if (res.getHeader("content-encoding")) return false;
|
|
242
|
+
|
|
243
|
+
const type = String(res.getHeader("content-type") || "");
|
|
244
|
+
if (type && !/^text\/(?:plain|html)\b/i.test(type)) return false;
|
|
245
|
+
|
|
246
|
+
const sample = body.subarray(0, 64).toString("utf8");
|
|
247
|
+
if (/^\s*<(!doctype|html)\b/i.test(sample)) return false;
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* @param {string | string[] | undefined} header
|
|
253
|
+
* @param {string} tag
|
|
254
|
+
* @returns {boolean}
|
|
255
|
+
*/
|
|
256
|
+
function etagMatches(header, tag) {
|
|
257
|
+
const value = Array.isArray(header) ? header.join(",") : String(header);
|
|
258
|
+
return value.split(",").some((part) => {
|
|
259
|
+
const token = part.trim().replace(/^W\//i, "");
|
|
260
|
+
return token === "*" || token === tag;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* @param {unknown} chunk
|
|
266
|
+
* @param {unknown} encoding
|
|
267
|
+
* @returns {Buffer | null}
|
|
268
|
+
*/
|
|
269
|
+
function takeChunk(chunk, encoding) {
|
|
270
|
+
if (chunk == null || typeof chunk === "function") return null;
|
|
271
|
+
if (Buffer.isBuffer(chunk)) return chunk;
|
|
272
|
+
if (chunk instanceof Uint8Array) return Buffer.from(chunk);
|
|
273
|
+
if (typeof chunk === "string") {
|
|
274
|
+
const enc = typeof encoding === "string" ? encoding : "utf8";
|
|
275
|
+
return Buffer.from(chunk, /** @type {BufferEncoding} */ (enc));
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* @param {string} text
|
|
282
|
+
* @returns {string[]}
|
|
283
|
+
*/
|
|
284
|
+
function collectUserAgents(text) {
|
|
285
|
+
/** @type {string[]} */
|
|
286
|
+
const agents = [];
|
|
287
|
+
const seen = new Set();
|
|
288
|
+
|
|
289
|
+
for (const line of text.split(/\r?\n/)) {
|
|
290
|
+
const match = /^[ \t]*user-agent[ \t]*:[ \t]*(.*?)[ \t]*$/i.exec(line);
|
|
291
|
+
if (!match) continue;
|
|
292
|
+
const agent = match[1].trim();
|
|
293
|
+
if (!agent || /[\r\n]/.test(agent)) continue;
|
|
294
|
+
const key = agent.toLowerCase();
|
|
295
|
+
if (seen.has(key)) continue;
|
|
296
|
+
seen.add(key);
|
|
297
|
+
agents.push(agent);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const star = agents.findIndex((agent) => agent === "*");
|
|
301
|
+
if (star === -1) agents.unshift("*");
|
|
302
|
+
else if (star > 0) {
|
|
303
|
+
agents.splice(star, 1);
|
|
304
|
+
agents.unshift("*");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return agents;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* @param {string} raw
|
|
312
|
+
* @returns {string | null}
|
|
313
|
+
*/
|
|
314
|
+
function cleanDisallowPath(raw) {
|
|
315
|
+
if (typeof raw !== "string") return null;
|
|
316
|
+
const pathOnly = raw.split("?")[0].trim();
|
|
317
|
+
if (!pathOnly.startsWith("/") || /[\s#]/.test(pathOnly)) return null;
|
|
318
|
+
const trimmed = pathOnly.replace(/\/+$/, "");
|
|
319
|
+
return trimmed || null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* @param {string} path
|
|
324
|
+
* @returns {boolean}
|
|
325
|
+
*/
|
|
326
|
+
function isReserved(path) {
|
|
327
|
+
return RESERVED_PREFIXES.includes(path);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* @param {string} rule
|
|
332
|
+
* @param {string} candidate
|
|
333
|
+
* @returns {boolean}
|
|
334
|
+
*/
|
|
335
|
+
function coveredBy(rule, candidate) {
|
|
336
|
+
const ruleExact = rule.replace(/\/+$/, "");
|
|
337
|
+
const candExact = candidate.replace(/\/+$/, "");
|
|
338
|
+
if (candExact === ruleExact) return true;
|
|
339
|
+
const prefix = rule.endsWith("/") ? rule : `${ruleExact}/`;
|
|
340
|
+
return candidate.startsWith(prefix) || `${candExact}/`.startsWith(prefix);
|
|
341
|
+
}
|