jskelet 0.6.1 → 0.6.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.
Files changed (155) hide show
  1. package/AGENTS.md +136 -136
  2. package/CHANGELOG.md +620 -596
  3. package/LICENSE +21 -21
  4. package/bin/jskelet.mjs +130 -130
  5. package/docs/01-baslangic.md +291 -291
  6. package/docs/02-mimari.md +310 -309
  7. package/docs/03-routing.md +515 -515
  8. package/docs/04-render-ve-sablonlar.md +661 -661
  9. package/docs/05-islands.md +486 -486
  10. package/docs/06-cache.md +1443 -1423
  11. package/docs/07-yapilandirma.md +12 -6
  12. package/docs/08-build.md +429 -428
  13. package/docs/09-dev-araclari.md +364 -364
  14. package/docs/10-dagitim.md +338 -338
  15. package/docs/12-panel-ve-oturum.md +478 -478
  16. package/docs/README.md +83 -83
  17. package/docs/en/01-getting-started.md +298 -298
  18. package/docs/en/02-architecture.md +329 -328
  19. package/docs/en/03-routing.md +531 -531
  20. package/docs/en/04-rendering.md +669 -669
  21. package/docs/en/05-islands.md +497 -497
  22. package/docs/en/06-caching.md +1453 -1431
  23. package/docs/en/07-configuration.md +1219 -1214
  24. package/docs/en/08-build.md +447 -446
  25. package/docs/en/09-dev-tools.md +373 -373
  26. package/docs/en/10-deployment.md +340 -340
  27. package/docs/en/11-migration.md +398 -398
  28. package/docs/en/12-dashboards-and-sessions.md +488 -488
  29. package/docs/en/README.md +87 -87
  30. package/package.json +137 -137
  31. package/src/build/ensure-build.mjs +19 -19
  32. package/src/build/paths.mjs +153 -153
  33. package/src/build/resolve-peer.mjs +36 -36
  34. package/src/build/tasks/client.mjs +349 -349
  35. package/src/build/tasks/css.mjs +235 -235
  36. package/src/build/tasks/fonts.mjs +146 -146
  37. package/src/build/tasks/icons.mjs +357 -357
  38. package/src/build/tasks/images.mjs +244 -244
  39. package/src/build/tasks/precompress.mjs +78 -78
  40. package/src/build/tasks/templates.mjs +20 -20
  41. package/src/client/admin/i18n.js +764 -764
  42. package/src/client/admin/login.html +74 -74
  43. package/src/client/admin/panel.css +809 -809
  44. package/src/client/admin/panel.html +495 -495
  45. package/src/client/admin/panel.js +1251 -1251
  46. package/src/client/devtools/report.html +185 -185
  47. package/src/client/devtools/report.js +745 -745
  48. package/src/client/devtools/seo.js +628 -628
  49. package/src/client/dom.js +95 -95
  50. package/src/client/form.js +192 -192
  51. package/src/client/index.js +45 -45
  52. package/src/client/registry.js +305 -305
  53. package/src/client/safe-image.js +91 -91
  54. package/src/client/shared-cookie.js +225 -225
  55. package/src/client/store.js +36 -36
  56. package/src/client/swap.js +188 -188
  57. package/src/compile/codegen.js +336 -336
  58. package/src/compile/compile-all.js +149 -149
  59. package/src/compile/errors.js +66 -66
  60. package/src/compile/expr.js +409 -409
  61. package/src/compile/index.js +17 -17
  62. package/src/compile/parse.js +541 -541
  63. package/src/compile/resolve.js +211 -211
  64. package/src/compile/scan-exports.js +51 -51
  65. package/src/config/defaults.js +17 -1
  66. package/src/config/index.js +13 -0
  67. package/src/config/pattern.js +107 -107
  68. package/src/generate.mjs +163 -163
  69. package/src/http/control-flow.js +71 -71
  70. package/src/http/cookies-entry.js +21 -21
  71. package/src/http/cookies.js +277 -277
  72. package/src/http/request-cache.js +46 -46
  73. package/src/http/request-context.js +165 -165
  74. package/src/http/shared-cookie.js +178 -178
  75. package/src/index.js +101 -101
  76. package/src/init.mjs +230 -230
  77. package/src/migrate/apply.mjs +262 -262
  78. package/src/migrate/babel.mjs +79 -79
  79. package/src/migrate/classify.mjs +155 -155
  80. package/src/migrate/config.mjs +126 -126
  81. package/src/migrate/fs-walk.mjs +191 -191
  82. package/src/migrate/parse.mjs +26 -26
  83. package/src/migrate/scan.mjs +177 -177
  84. package/src/migrate/transform/expr-source.mjs +168 -168
  85. package/src/migrate/transform/island.mjs +67 -67
  86. package/src/migrate/transform/jsx-to-component.mjs +302 -302
  87. package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
  88. package/src/migrate/transform/page-split.mjs +435 -435
  89. package/src/migrate/write.mjs +81 -81
  90. package/src/migrate.mjs +171 -171
  91. package/src/runtime/alias-hooks.mjs +119 -119
  92. package/src/runtime/register.mjs +4 -4
  93. package/src/server/admin/actions.js +229 -229
  94. package/src/server/admin/auth.js +125 -125
  95. package/src/server/admin/event-log.js +151 -151
  96. package/src/server/admin/gate.js +209 -209
  97. package/src/server/admin/inventory.js +188 -188
  98. package/src/server/admin/mount.js +56 -56
  99. package/src/server/admin/router.js +216 -216
  100. package/src/server/admin/snapshot.js +241 -241
  101. package/src/server/assets.js +147 -147
  102. package/src/server/auth/handoff.js +309 -309
  103. package/src/server/cache-blob.js +70 -0
  104. package/src/server/cache-deps.js +42 -42
  105. package/src/server/cache-vary.js +113 -113
  106. package/src/server/cloudflare.js +607 -607
  107. package/src/server/create-app.js +366 -366
  108. package/src/server/data-cache.js +553 -462
  109. package/src/server/dev/report.js +485 -485
  110. package/src/server/dev/socket.js +170 -170
  111. package/src/server/dev/version-check.mjs +139 -139
  112. package/src/server/disk-cache.js +233 -0
  113. package/src/server/ejs-adapter.js +59 -59
  114. package/src/server/html-cache.js +1196 -1122
  115. package/src/server/image-optimizer.js +500 -407
  116. package/src/server/logs/access-middleware.js +66 -66
  117. package/src/server/logs/file-sink.js +193 -66
  118. package/src/server/logs/pipeline.js +165 -158
  119. package/src/server/logs/s3-put.js +214 -214
  120. package/src/server/logs/s3-sink.js +112 -112
  121. package/src/server/metadata.js +102 -102
  122. package/src/server/middleware/compression.js +205 -205
  123. package/src/server/middleware/csrf.js +134 -134
  124. package/src/server/middleware/dev-gate.js +75 -75
  125. package/src/server/middleware/headers.js +37 -37
  126. package/src/server/middleware/redirects.js +32 -32
  127. package/src/server/middleware/robots-txt.js +341 -341
  128. package/src/server/middleware/static-precompressed.js +121 -100
  129. package/src/server/middleware/trailing-slash.js +53 -53
  130. package/src/server/middleware/upstream-proxy.js +141 -141
  131. package/src/server/og-image.js +356 -356
  132. package/src/server/port-guard.js +255 -255
  133. package/src/server/prewarm.js +1082 -1058
  134. package/src/server/redis.js +588 -569
  135. package/src/server/render.js +4 -4
  136. package/src/server/router.js +157 -157
  137. package/src/server/status-page.js +265 -265
  138. package/src/server/upstream-limiter.js +376 -376
  139. package/src/server/upstream-tracking.js +166 -166
  140. package/src/shared/cookie-domain.js +66 -66
  141. package/src/start.mjs +22 -22
  142. package/src/templates/layout.ejs +30 -30
  143. package/src/templates/layout.jsk +30 -30
  144. package/src/version.mjs +31 -31
  145. package/src/views/components/loader.js +101 -101
  146. package/src/views/helpers/html.js +102 -102
  147. package/src/views/helpers/tags.js +375 -375
  148. package/types/config/defaults.d.ts +15 -1
  149. package/types/config/index.d.ts +8 -0
  150. package/types/server/cache-blob.d.ts +13 -0
  151. package/types/server/data-cache.d.ts +9 -0
  152. package/types/server/disk-cache.d.ts +36 -0
  153. package/types/server/html-cache.d.ts +26 -3
  154. package/types/server/logs/file-sink.d.ts +16 -5
  155. package/types/server/redis.d.ts +2 -1
@@ -1,407 +1,500 @@
1
- /**
2
- * Runtime uzak görsel proxy: allowlist'teki host'lardan çeker, sharp ile
3
- * webp'ye çevirir, diske yazar ve uzun Cache-Control ile servis eder.
4
- *
5
- * Next.js `/_next/image` karşılığı. Build zamanı `images.mjs` yalnızca
6
- * `public/` altındaki yerel dosyaları kapsar; CMS / CDN kapakları için bu uç
7
- * gerekir. Kapalıyken (allowHosts yok) router hiç mount edilmez.
8
- *
9
- * sharp yoksa 302 ile orijinale yönlendirilir — sayfa bozulmaz, tasarruf
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.
15
- */
16
- import crypto from "node:crypto";
17
- import dns from "node:dns/promises";
18
- import fs from "node:fs";
19
- import path from "node:path";
20
- import process from "node:process";
21
- import { tryImportFromApp } from "../build/resolve-peer.mjs";
22
- import { getConfig } from "../config/index.js";
23
-
24
- /** @type {typeof import('sharp') | null | undefined} */
25
- let sharpModule;
26
-
27
- /** @type {string | null} */
28
- let cacheDir = null;
29
-
30
- const MAX_REDIRECTS = 5;
31
-
32
- /**
33
- * @returns {import('../config/index.js').ImagesRemoteConfig | null}
34
- */
35
- export function getRemoteImages() {
36
- const images = getConfig().images;
37
- if (!images || images === false || !images.remote || !images.remote.enabled) {
38
- return null;
39
- }
40
- return images.remote;
41
- }
42
-
43
- /**
44
- * Mutlak http(s) URL mi ve allowHosts'ta mı.
45
- * @param {string} src
46
- * @returns {URL | null}
47
- */
48
- export function parseAllowedRemoteUrl(src) {
49
- const remote = getRemoteImages();
50
- if (!remote || typeof src !== "string") return null;
51
-
52
- let url;
53
- try {
54
- url = new URL(src);
55
- } catch {
56
- return null;
57
- }
58
-
59
- if (!isRemoteUrlShapeAllowed(url, remote.allowHosts)) return null;
60
- return url;
61
- }
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
-
98
- /**
99
- * @param {string} hostname
100
- * @param {string[]} allowHosts
101
- * @returns {boolean}
102
- */
103
- export function isHostAllowed(hostname, allowHosts) {
104
- const host = hostname.toLowerCase();
105
- return allowHosts.some((entry) => {
106
- const pattern = entry.toLowerCase();
107
- if (pattern.startsWith("*.")) {
108
- const suffix = pattern.slice(1); // ".example.com"
109
- return host.endsWith(suffix) || host === pattern.slice(2);
110
- }
111
- return host === pattern;
112
- });
113
- }
114
-
115
- /**
116
- * Literal private / link-local / loopback host'ları reddet (SSRF).
117
- * Allowlist asıl koruma; bu ek bir savunma katmanı.
118
- * @param {string} hostname
119
- * @returns {boolean}
120
- */
121
- export function isBlockedAddress(hostname) {
122
- const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
123
- if (host === "localhost" || host === "0.0.0.0" || host.endsWith(".localhost")) {
124
- return true;
125
- }
126
- if (host === "::1" || host === "0:0:0:0:0:0:0:1") return true;
127
-
128
- // IPv4
129
- const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
130
- if (ipv4) {
131
- const parts = ipv4.slice(1).map(Number);
132
- if (parts.some((n) => n > 255)) return true;
133
- const [a, b] = parts;
134
- if (a === 10 || a === 127 || a === 0) return true;
135
- if (a === 169 && b === 254) return true;
136
- if (a === 172 && b >= 16 && b <= 31) return true;
137
- if (a === 192 && b === 168) return true;
138
- if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
139
- return false;
140
- }
141
-
142
- // Ham IPv6 private / ULA — basit önek kontrolü
143
- if (host.includes(":")) {
144
- if (host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80")) {
145
- return true;
146
- }
147
- }
148
-
149
- return false;
150
- }
151
-
152
- /**
153
- * Optimizer URL'si üret. `image()` ve elle URL kuran uygulamalar için.
154
- * @param {string} src Uzak görsel URL'si
155
- * @param {{ width: number, quality?: number }} options
156
- * @returns {string | null} Allowlist dışıysa null
157
- */
158
- export function remoteImageUrl(src, options) {
159
- const remote = getRemoteImages();
160
- const allowed = parseAllowedRemoteUrl(src);
161
- if (!remote || !allowed) return null;
162
-
163
- const images = getConfig().images;
164
- const quality =
165
- options.quality ??
166
- (images && images !== false ? images.quality : 78) ??
167
- 78;
168
- const width = clampWidth(options.width, remote.maxWidth);
169
-
170
- const params = new URLSearchParams({
171
- url: allowed.href,
172
- w: String(width),
173
- q: String(quality),
174
- });
175
- return `${remote.path}?${params}`;
176
- }
177
-
178
- /**
179
- * @param {number} width
180
- * @param {number} maxWidth
181
- * @returns {number}
182
- */
183
- export function clampWidth(width, maxWidth) {
184
- const n = Math.round(Number(width));
185
- if (!Number.isFinite(n) || n < 1) return Math.min(640, maxWidth);
186
- return Math.min(n, maxWidth);
187
- }
188
-
189
- /**
190
- * Görüntülenen genişliğe göre srcset adayları (1x + 2x + config widths).
191
- * @param {number} displayWidth
192
- * @param {number[]} widths
193
- * @param {number} maxWidth
194
- * @returns {number[]}
195
- */
196
- export function srcsetWidths(displayWidth, widths, maxWidth) {
197
- const base = Math.max(1, Math.round(displayWidth));
198
- const candidates = new Set([
199
- base,
200
- Math.min(base * 2, maxWidth),
201
- ...widths.filter((w) => w >= base && w <= maxWidth),
202
- ]);
203
- return [...candidates].sort((a, b) => a - b);
204
- }
205
-
206
- /**
207
- * @param {import('express').Express} app
208
- * @returns {Promise<void>}
209
- */
210
- export async function mountImageOptimizer(app) {
211
- const remote = getRemoteImages();
212
- if (!remote) return;
213
-
214
- const config = getConfig();
215
- cacheDir = path.join(config.dirs.generated, "image-cache");
216
- fs.mkdirSync(cacheDir, { recursive: true });
217
-
218
- sharpModule = await tryImportFromApp(config.root, "sharp");
219
- if (!sharpModule) {
220
- console.warn(
221
- "[images.remote] sharp not installed; optimizer will redirect to the source URL",
222
- );
223
- }
224
-
225
- app.get(remote.path, (req, res) => {
226
- void handleOptimize(req, res, remote);
227
- });
228
- }
229
-
230
- /**
231
- * @param {import('express').Request} req
232
- * @param {import('express').Response} res
233
- * @param {import('../config/index.js').ImagesRemoteConfig} remote
234
- */
235
- async function handleOptimize(req, res, remote) {
236
- const rawUrl = typeof req.query.url === "string" ? req.query.url : "";
237
- const allowed = parseAllowedRemoteUrl(rawUrl);
238
- if (!allowed) {
239
- res.status(400).type("text").send("Invalid or disallowed image url");
240
- return;
241
- }
242
-
243
- const images = getConfig().images;
244
- const defaultQ = images && images !== false ? images.quality : 78;
245
- const quality = clampQuality(
246
- typeof req.query.q === "string" ? req.query.q : defaultQ,
247
- defaultQ,
248
- );
249
- const width = clampWidth(
250
- typeof req.query.w === "string" ? req.query.w : 640,
251
- remote.maxWidth,
252
- );
253
-
254
- if (!sharpModule) {
255
- res.redirect(302, allowed.href);
256
- return;
257
- }
258
-
259
- const key = cacheKey(allowed.href, width, quality);
260
- const filePath = path.join(/** @type {string} */ (cacheDir), `${key}.webp`);
261
-
262
- try {
263
- if (fs.existsSync(filePath)) {
264
- sendCached(res, filePath, remote.cacheMaxAge);
265
- return;
266
- }
267
-
268
- const upstream = await fetchUpstream(allowed.href, remote);
269
- if (!upstream.ok) {
270
- res.status(502).type("text").send("Upstream image fetch failed");
271
- return;
272
- }
273
-
274
- const sharp = sharpModule.default;
275
- const buffer = await sharp(upstream.buffer, { failOn: "none" })
276
- .rotate()
277
- .resize({
278
- width,
279
- withoutEnlargement: true,
280
- fit: "inside",
281
- })
282
- .webp({ quality, effort: 4 })
283
- .toBuffer();
284
-
285
- // Atomik yaz: yarım dosya immutable cache'e düşmesin.
286
- const tmp = `${filePath}.${process.pid}.tmp`;
287
- await fs.promises.writeFile(tmp, buffer);
288
- await fs.promises.rename(tmp, filePath);
289
-
290
- sendCached(res, filePath, remote.cacheMaxAge);
291
- } catch (error) {
292
- console.warn("[images.remote] optimize failed:", error);
293
- // Bozuk kaynakta sayfa boş kalmasın: orijinale düş.
294
- if (!res.headersSent) res.redirect(302, allowed.href);
295
- }
296
- }
297
-
298
- /**
299
- * Redirect'leri elle takip eder; her hop allowlist + DNS private kontrolünden geçer.
300
- *
301
- * @param {string} href
302
- * @param {import('../config/index.js').ImagesRemoteConfig} remote
303
- * @returns {Promise<{ ok: true, buffer: Buffer } | { ok: false }>}
304
- */
305
- async function fetchUpstream(href, remote) {
306
- const controller = new AbortController();
307
- const timer = setTimeout(() => controller.abort(), remote.fetchTimeoutMs);
308
-
309
- try {
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 };
358
- }
359
-
360
- return { ok: false };
361
- } catch {
362
- return { ok: false };
363
- } finally {
364
- clearTimeout(timer);
365
- }
366
- }
367
-
368
- /**
369
- * @param {import('express').Response} res
370
- * @param {string} filePath
371
- * @param {number} maxAge
372
- */
373
- function sendCached(res, filePath, maxAge) {
374
- res.setHeader("Content-Type", "image/webp");
375
- res.setHeader(
376
- "Cache-Control",
377
- `public, max-age=${maxAge}, stale-while-revalidate=${Math.min(maxAge, 86400)}`,
378
- );
379
- res.setHeader("Vary", "Accept");
380
- // Cache dir is under .jskelet/; Express send ignores dotfiles by default.
381
- res.sendFile(path.resolve(filePath), { dotfiles: "allow" });
382
- }
383
-
384
- /**
385
- * @param {string} href
386
- * @param {number} width
387
- * @param {number} quality
388
- * @returns {string}
389
- */
390
- function cacheKey(href, width, quality) {
391
- return crypto
392
- .createHash("sha256")
393
- .update(`webp-q${quality}-e4:${width}:${href}`)
394
- .digest("hex")
395
- .slice(0, 32);
396
- }
397
-
398
- /**
399
- * @param {unknown} value
400
- * @param {number} fallback
401
- * @returns {number}
402
- */
403
- function clampQuality(value, fallback) {
404
- const n = Math.round(Number(value));
405
- if (!Number.isFinite(n)) return fallback;
406
- return Math.min(100, Math.max(1, n));
407
- }
1
+ /**
2
+ * Runtime uzak görsel proxy: allowlist'teki host'lardan çeker, sharp ile
3
+ * webp'ye çevirir, diske yazar ve uzun Cache-Control ile servis eder.
4
+ *
5
+ * Next.js `/_next/image` karşılığı. Build zamanı `images.mjs` yalnızca
6
+ * `public/` altındaki yerel dosyaları kapsar; CMS / CDN kapakları için bu uç
7
+ * gerekir. Kapalıyken (allowHosts yok) router hiç mount edilmez.
8
+ *
9
+ * sharp yoksa 302 ile orijinale yönlendirilir — sayfa bozulmaz, tasarruf
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.
15
+ */
16
+ import crypto from "node:crypto";
17
+ import dns from "node:dns/promises";
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import process from "node:process";
21
+ import { tryImportFromApp } from "../build/resolve-peer.mjs";
22
+ import { IMAGE_CACHE_BYTE_BUDGET } from "../config/defaults.js";
23
+ import { getConfig } from "../config/index.js";
24
+
25
+ /** @type {typeof import('sharp') | null | undefined} */
26
+ let sharpModule;
27
+
28
+ /** @type {string | null} */
29
+ let cacheDir = null;
30
+
31
+ /**
32
+ * Bilinen dosyalar. HIT `existsSync` yerine bu kümeye bakar; açılışta dizin
33
+ * bir kez okunur. Değer yazım zamanı ve bayttır — tahliye en eskiden.
34
+ *
35
+ * @type {Map<string, { bytes: number, mtimeMs: number }>}
36
+ */
37
+ const imageCacheIndex = new Map();
38
+
39
+ /** @type {number} */
40
+ let imageCacheBytes = 0;
41
+
42
+ const MAX_REDIRECTS = 5;
43
+
44
+ /**
45
+ * @param {string} dir
46
+ * @returns {void}
47
+ */
48
+ function loadImageCacheIndex(dir) {
49
+ imageCacheIndex.clear();
50
+ imageCacheBytes = 0;
51
+
52
+ let names;
53
+ try {
54
+ names = fs.readdirSync(dir);
55
+ } catch {
56
+ return;
57
+ }
58
+
59
+ for (const name of names) {
60
+ if (!name.endsWith(".webp")) continue;
61
+ const filePath = path.join(dir, name);
62
+ try {
63
+ const stat = fs.statSync(filePath);
64
+ if (!stat.isFile()) continue;
65
+ imageCacheIndex.set(filePath, { bytes: stat.size, mtimeMs: stat.mtimeMs });
66
+ imageCacheBytes += stat.size;
67
+ } catch {
68
+ // Yarım tmp veya silinmiş dosya indeksi bozmasın.
69
+ }
70
+ }
71
+
72
+ evictImageCache("");
73
+ }
74
+
75
+ /**
76
+ * @param {string} filePath
77
+ * @param {number} bytes
78
+ * @returns {void}
79
+ */
80
+ function noteImageCacheFile(filePath, bytes) {
81
+ const prev = imageCacheIndex.get(filePath);
82
+ if (prev) imageCacheBytes -= prev.bytes;
83
+ imageCacheIndex.set(filePath, { bytes, mtimeMs: Date.now() });
84
+ imageCacheBytes += bytes;
85
+ evictImageCache(filePath);
86
+ }
87
+
88
+ /**
89
+ * Tavan aşılınca en eski dosyayı siler. `protect` az önce yazılan yoldur:
90
+ * bu yanıt onu hâlâ okuyacak, bu turda düşmez.
91
+ *
92
+ * @param {string} protect
93
+ * @returns {void}
94
+ */
95
+ function evictImageCache(protect) {
96
+ while (imageCacheBytes > IMAGE_CACHE_BYTE_BUDGET && imageCacheIndex.size > 1) {
97
+ let oldestPath = "";
98
+ let oldestMtime = Infinity;
99
+ let oldestBytes = 0;
100
+
101
+ for (const [filePath, meta] of imageCacheIndex) {
102
+ if (filePath === protect) continue;
103
+ if (meta.mtimeMs < oldestMtime) {
104
+ oldestMtime = meta.mtimeMs;
105
+ oldestPath = filePath;
106
+ oldestBytes = meta.bytes;
107
+ }
108
+ }
109
+
110
+ if (!oldestPath) break;
111
+ imageCacheIndex.delete(oldestPath);
112
+ imageCacheBytes = Math.max(0, imageCacheBytes - oldestBytes);
113
+ try {
114
+ fs.rmSync(oldestPath, { force: true });
115
+ } catch {
116
+ // Dosya yoksa indeks yine de düşmüş olsun.
117
+ }
118
+ }
119
+ }
120
+
121
+ /**
122
+ * @returns {import('../config/index.js').ImagesRemoteConfig | null}
123
+ */
124
+ export function getRemoteImages() {
125
+ const images = getConfig().images;
126
+ if (!images || images === false || !images.remote || !images.remote.enabled) {
127
+ return null;
128
+ }
129
+ return images.remote;
130
+ }
131
+
132
+ /**
133
+ * Mutlak http(s) URL mi ve allowHosts'ta mı.
134
+ * @param {string} src
135
+ * @returns {URL | null}
136
+ */
137
+ export function parseAllowedRemoteUrl(src) {
138
+ const remote = getRemoteImages();
139
+ if (!remote || typeof src !== "string") return null;
140
+
141
+ let url;
142
+ try {
143
+ url = new URL(src);
144
+ } catch {
145
+ return null;
146
+ }
147
+
148
+ if (!isRemoteUrlShapeAllowed(url, remote.allowHosts)) return null;
149
+ return url;
150
+ }
151
+
152
+ /**
153
+ * Host allowlist + literal private IP; redirect hop'larında da kullanılır.
154
+ *
155
+ * @param {URL} url
156
+ * @param {string[]} allowHosts
157
+ * @returns {boolean}
158
+ */
159
+ export function isRemoteUrlShapeAllowed(url, allowHosts) {
160
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
161
+ if (!isHostAllowed(url.hostname, allowHosts)) return false;
162
+ if (isBlockedAddress(url.hostname)) return false;
163
+ return true;
164
+ }
165
+
166
+ /**
167
+ * Hostname'i çözümleyip private IP'ye düşüyorsa reddet (DNS rebinding
168
+ * savunması; TOCTOU kalır ama check-time private resolve yakalanır).
169
+ *
170
+ * @param {string} hostname
171
+ * @returns {Promise<boolean>} true = güvenli
172
+ */
173
+ export async function assertResolvedHostSafe(hostname) {
174
+ if (isBlockedAddress(hostname)) return false;
175
+ try {
176
+ const results = await dns.lookup(hostname, { all: true, verbatim: true });
177
+ if (!results.length) return false;
178
+ for (const { address } of results) {
179
+ if (isBlockedAddress(address)) return false;
180
+ }
181
+ return true;
182
+ } catch {
183
+ return false;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * @param {string} hostname
189
+ * @param {string[]} allowHosts
190
+ * @returns {boolean}
191
+ */
192
+ export function isHostAllowed(hostname, allowHosts) {
193
+ const host = hostname.toLowerCase();
194
+ return allowHosts.some((entry) => {
195
+ const pattern = entry.toLowerCase();
196
+ if (pattern.startsWith("*.")) {
197
+ const suffix = pattern.slice(1); // ".example.com"
198
+ return host.endsWith(suffix) || host === pattern.slice(2);
199
+ }
200
+ return host === pattern;
201
+ });
202
+ }
203
+
204
+ /**
205
+ * Literal private / link-local / loopback host'ları reddet (SSRF).
206
+ * Allowlist asıl koruma; bu ek bir savunma katmanı.
207
+ * @param {string} hostname
208
+ * @returns {boolean}
209
+ */
210
+ export function isBlockedAddress(hostname) {
211
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
212
+ if (host === "localhost" || host === "0.0.0.0" || host.endsWith(".localhost")) {
213
+ return true;
214
+ }
215
+ if (host === "::1" || host === "0:0:0:0:0:0:0:1") return true;
216
+
217
+ // IPv4
218
+ const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
219
+ if (ipv4) {
220
+ const parts = ipv4.slice(1).map(Number);
221
+ if (parts.some((n) => n > 255)) return true;
222
+ const [a, b] = parts;
223
+ if (a === 10 || a === 127 || a === 0) return true;
224
+ if (a === 169 && b === 254) return true;
225
+ if (a === 172 && b >= 16 && b <= 31) return true;
226
+ if (a === 192 && b === 168) return true;
227
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
228
+ return false;
229
+ }
230
+
231
+ // Ham IPv6 private / ULA — basit önek kontrolü
232
+ if (host.includes(":")) {
233
+ if (host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80")) {
234
+ return true;
235
+ }
236
+ }
237
+
238
+ return false;
239
+ }
240
+
241
+ /**
242
+ * Optimizer URL'si üret. `image()` ve elle URL kuran uygulamalar için.
243
+ * @param {string} src Uzak görsel URL'si
244
+ * @param {{ width: number, quality?: number }} options
245
+ * @returns {string | null} Allowlist dışıysa null
246
+ */
247
+ export function remoteImageUrl(src, options) {
248
+ const remote = getRemoteImages();
249
+ const allowed = parseAllowedRemoteUrl(src);
250
+ if (!remote || !allowed) return null;
251
+
252
+ const images = getConfig().images;
253
+ const quality =
254
+ options.quality ??
255
+ (images && images !== false ? images.quality : 78) ??
256
+ 78;
257
+ const width = clampWidth(options.width, remote.maxWidth);
258
+
259
+ const params = new URLSearchParams({
260
+ url: allowed.href,
261
+ w: String(width),
262
+ q: String(quality),
263
+ });
264
+ return `${remote.path}?${params}`;
265
+ }
266
+
267
+ /**
268
+ * @param {number} width
269
+ * @param {number} maxWidth
270
+ * @returns {number}
271
+ */
272
+ export function clampWidth(width, maxWidth) {
273
+ const n = Math.round(Number(width));
274
+ if (!Number.isFinite(n) || n < 1) return Math.min(640, maxWidth);
275
+ return Math.min(n, maxWidth);
276
+ }
277
+
278
+ /**
279
+ * Görüntülenen genişliğe göre srcset adayları (1x + 2x + config widths).
280
+ * @param {number} displayWidth
281
+ * @param {number[]} widths
282
+ * @param {number} maxWidth
283
+ * @returns {number[]}
284
+ */
285
+ export function srcsetWidths(displayWidth, widths, maxWidth) {
286
+ const base = Math.max(1, Math.round(displayWidth));
287
+ const candidates = new Set([
288
+ base,
289
+ Math.min(base * 2, maxWidth),
290
+ ...widths.filter((w) => w >= base && w <= maxWidth),
291
+ ]);
292
+ return [...candidates].sort((a, b) => a - b);
293
+ }
294
+
295
+ /**
296
+ * @param {import('express').Express} app
297
+ * @returns {Promise<void>}
298
+ */
299
+ export async function mountImageOptimizer(app) {
300
+ const remote = getRemoteImages();
301
+ if (!remote) return;
302
+
303
+ const config = getConfig();
304
+ cacheDir = path.join(config.dirs.generated, "image-cache");
305
+ fs.mkdirSync(cacheDir, { recursive: true });
306
+ loadImageCacheIndex(cacheDir);
307
+
308
+ sharpModule = await tryImportFromApp(config.root, "sharp");
309
+ if (!sharpModule) {
310
+ console.warn(
311
+ "[images.remote] sharp not installed; optimizer will redirect to the source URL",
312
+ );
313
+ }
314
+
315
+ app.get(remote.path, (req, res) => {
316
+ void handleOptimize(req, res, remote);
317
+ });
318
+ }
319
+
320
+ /**
321
+ * @param {import('express').Request} req
322
+ * @param {import('express').Response} res
323
+ * @param {import('../config/index.js').ImagesRemoteConfig} remote
324
+ */
325
+ async function handleOptimize(req, res, remote) {
326
+ const rawUrl = typeof req.query.url === "string" ? req.query.url : "";
327
+ const allowed = parseAllowedRemoteUrl(rawUrl);
328
+ if (!allowed) {
329
+ res.status(400).type("text").send("Invalid or disallowed image url");
330
+ return;
331
+ }
332
+
333
+ const images = getConfig().images;
334
+ const defaultQ = images && images !== false ? images.quality : 78;
335
+ const quality = clampQuality(
336
+ typeof req.query.q === "string" ? req.query.q : defaultQ,
337
+ defaultQ,
338
+ );
339
+ const width = clampWidth(
340
+ typeof req.query.w === "string" ? req.query.w : 640,
341
+ remote.maxWidth,
342
+ );
343
+
344
+ if (!sharpModule) {
345
+ res.redirect(302, allowed.href);
346
+ return;
347
+ }
348
+
349
+ const key = cacheKey(allowed.href, width, quality);
350
+ const filePath = path.join(/** @type {string} */ (cacheDir), `${key}.webp`);
351
+
352
+ try {
353
+ if (imageCacheIndex.has(filePath)) {
354
+ sendCached(res, filePath, remote.cacheMaxAge);
355
+ return;
356
+ }
357
+
358
+ const upstream = await fetchUpstream(allowed.href, remote);
359
+ if (!upstream.ok) {
360
+ res.status(502).type("text").send("Upstream image fetch failed");
361
+ return;
362
+ }
363
+
364
+ const sharp = sharpModule.default;
365
+ const buffer = await sharp(upstream.buffer, { failOn: "none" })
366
+ .rotate()
367
+ .resize({
368
+ width,
369
+ withoutEnlargement: true,
370
+ fit: "inside",
371
+ })
372
+ .webp({ quality, effort: 4 })
373
+ .toBuffer();
374
+
375
+ // Atomik yaz: yarım dosya immutable cache'e düşmesin.
376
+ const tmp = `${filePath}.${process.pid}.tmp`;
377
+ await fs.promises.writeFile(tmp, buffer);
378
+ await fs.promises.rename(tmp, filePath);
379
+ // Yeni dosya için yer aç: en eski düşer. Az önce yazılan, tek başına
380
+ // tavanı aşıyorsa kalır; bir sonraki yazım onu düşürür. Silme, bu
381
+ // yanıtın `sendFile`'ından önce olmasın.
382
+ noteImageCacheFile(filePath, buffer.length);
383
+ sendCached(res, filePath, remote.cacheMaxAge);
384
+ } catch (error) {
385
+ console.warn("[images.remote] optimize failed:", error);
386
+ // Bozuk kaynakta sayfa boş kalmasın: orijinale düş.
387
+ if (!res.headersSent) res.redirect(302, allowed.href);
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Redirect'leri elle takip eder; her hop allowlist + DNS private kontrolünden geçer.
393
+ *
394
+ * @param {string} href
395
+ * @param {import('../config/index.js').ImagesRemoteConfig} remote
396
+ * @returns {Promise<{ ok: true, buffer: Buffer } | { ok: false }>}
397
+ */
398
+ async function fetchUpstream(href, remote) {
399
+ const controller = new AbortController();
400
+ const timer = setTimeout(() => controller.abort(), remote.fetchTimeoutMs);
401
+
402
+ try {
403
+ let current = href;
404
+
405
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
406
+ let url;
407
+ try {
408
+ url = new URL(current);
409
+ } catch {
410
+ return { ok: false };
411
+ }
412
+
413
+ if (!isRemoteUrlShapeAllowed(url, remote.allowHosts)) return { ok: false };
414
+ if (!(await assertResolvedHostSafe(url.hostname))) return { ok: false };
415
+
416
+ const response = await fetch(url.href, {
417
+ signal: controller.signal,
418
+ redirect: "manual",
419
+ headers: {
420
+ // Bazı CDN'ler bot UA reddeder; tarayıcıya yakın tut.
421
+ Accept: "image/avif,image/webp,image/*,*/*;q=0.8",
422
+ "User-Agent": "jskelet-image-optimizer/1",
423
+ },
424
+ });
425
+
426
+ if (response.status >= 300 && response.status < 400) {
427
+ const location = response.headers.get("location");
428
+ if (!location) return { ok: false };
429
+ try {
430
+ current = new URL(location, url).href;
431
+ } catch {
432
+ return { ok: false };
433
+ }
434
+ continue;
435
+ }
436
+
437
+ if (!response.ok) return { ok: false };
438
+
439
+ const type = response.headers.get("content-type") ?? "";
440
+ if (type && !type.startsWith("image/") && !type.includes("octet-stream")) {
441
+ return { ok: false };
442
+ }
443
+
444
+ const length = Number(response.headers.get("content-length") ?? 0);
445
+ if (length > remote.maxBytes) return { ok: false };
446
+
447
+ const buffer = Buffer.from(await response.arrayBuffer());
448
+ if (buffer.byteLength > remote.maxBytes) return { ok: false };
449
+
450
+ return { ok: true, buffer };
451
+ }
452
+
453
+ return { ok: false };
454
+ } catch {
455
+ return { ok: false };
456
+ } finally {
457
+ clearTimeout(timer);
458
+ }
459
+ }
460
+
461
+ /**
462
+ * @param {import('express').Response} res
463
+ * @param {string} filePath
464
+ * @param {number} maxAge
465
+ */
466
+ function sendCached(res, filePath, maxAge) {
467
+ res.setHeader("Content-Type", "image/webp");
468
+ res.setHeader(
469
+ "Cache-Control",
470
+ `public, max-age=${maxAge}, stale-while-revalidate=${Math.min(maxAge, 86400)}`,
471
+ );
472
+ res.setHeader("Vary", "Accept");
473
+ // Cache dir is under .jskelet/; Express send ignores dotfiles by default.
474
+ res.sendFile(path.resolve(filePath), { dotfiles: "allow" });
475
+ }
476
+
477
+ /**
478
+ * @param {string} href
479
+ * @param {number} width
480
+ * @param {number} quality
481
+ * @returns {string}
482
+ */
483
+ function cacheKey(href, width, quality) {
484
+ return crypto
485
+ .createHash("sha256")
486
+ .update(`webp-q${quality}-e4:${width}:${href}`)
487
+ .digest("hex")
488
+ .slice(0, 32);
489
+ }
490
+
491
+ /**
492
+ * @param {unknown} value
493
+ * @param {number} fallback
494
+ * @returns {number}
495
+ */
496
+ function clampQuality(value, fallback) {
497
+ const n = Math.round(Number(value));
498
+ if (!Number.isFinite(n)) return fallback;
499
+ return Math.min(100, Math.max(1, n));
500
+ }