jskelet 0.6.3 → 0.6.5

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 (154) hide show
  1. package/AGENTS.md +136 -136
  2. package/CHANGELOG.md +633 -620
  3. package/LICENSE +21 -21
  4. package/README.md +2 -0
  5. package/bin/jskelet.mjs +130 -130
  6. package/docs/01-baslangic.md +291 -291
  7. package/docs/02-mimari.md +310 -310
  8. package/docs/03-routing.md +515 -515
  9. package/docs/04-render-ve-sablonlar.md +700 -661
  10. package/docs/05-islands.md +486 -486
  11. package/docs/06-cache.md +1467 -1443
  12. package/docs/07-yapilandirma.md +1208 -1197
  13. package/docs/08-build.md +429 -429
  14. package/docs/09-dev-araclari.md +364 -364
  15. package/docs/10-dagitim.md +351 -338
  16. package/docs/12-panel-ve-oturum.md +479 -478
  17. package/docs/README.md +83 -83
  18. package/docs/en/01-getting-started.md +298 -298
  19. package/docs/en/02-architecture.md +329 -329
  20. package/docs/en/03-routing.md +531 -531
  21. package/docs/en/04-rendering.md +708 -669
  22. package/docs/en/05-islands.md +497 -497
  23. package/docs/en/06-caching.md +1476 -1453
  24. package/docs/en/07-configuration.md +1229 -1219
  25. package/docs/en/08-build.md +447 -447
  26. package/docs/en/09-dev-tools.md +373 -373
  27. package/docs/en/10-deployment.md +355 -340
  28. package/docs/en/11-migration.md +398 -398
  29. package/docs/en/12-dashboards-and-sessions.md +489 -488
  30. package/docs/en/README.md +87 -87
  31. package/package.json +137 -137
  32. package/src/build/ensure-build.mjs +19 -19
  33. package/src/build/paths.mjs +153 -153
  34. package/src/build/resolve-peer.mjs +36 -36
  35. package/src/build/tasks/client.mjs +349 -349
  36. package/src/build/tasks/css.mjs +235 -235
  37. package/src/build/tasks/fonts.mjs +146 -146
  38. package/src/build/tasks/icons.mjs +357 -357
  39. package/src/build/tasks/images.mjs +244 -244
  40. package/src/build/tasks/precompress.mjs +78 -78
  41. package/src/build/tasks/templates.mjs +20 -20
  42. package/src/client/admin/i18n.js +764 -764
  43. package/src/client/admin/login.html +74 -74
  44. package/src/client/admin/panel.css +809 -809
  45. package/src/client/admin/panel.html +495 -495
  46. package/src/client/admin/panel.js +1251 -1251
  47. package/src/client/devtools/report.html +185 -185
  48. package/src/client/devtools/report.js +745 -745
  49. package/src/client/devtools/seo.js +628 -628
  50. package/src/client/dom.js +95 -95
  51. package/src/client/form.js +192 -192
  52. package/src/client/index.js +45 -45
  53. package/src/client/registry.js +305 -305
  54. package/src/client/safe-image.js +91 -91
  55. package/src/client/shared-cookie.js +225 -225
  56. package/src/client/store.js +36 -36
  57. package/src/client/swap.js +188 -188
  58. package/src/compile/codegen.js +336 -336
  59. package/src/compile/compile-all.js +149 -149
  60. package/src/compile/errors.js +66 -66
  61. package/src/compile/expr.js +409 -409
  62. package/src/compile/index.js +17 -17
  63. package/src/compile/parse.js +541 -541
  64. package/src/compile/resolve.js +211 -211
  65. package/src/compile/scan-exports.js +51 -51
  66. package/src/config/defaults.js +541 -534
  67. package/src/config/index.js +1500 -1469
  68. package/src/config/pattern.js +107 -107
  69. package/src/generate.mjs +163 -163
  70. package/src/http/control-flow.js +71 -71
  71. package/src/http/cookies-entry.js +21 -21
  72. package/src/http/cookies.js +277 -277
  73. package/src/http/request-cache.js +46 -46
  74. package/src/http/request-context.js +165 -165
  75. package/src/http/shared-cookie.js +178 -178
  76. package/src/index.js +101 -101
  77. package/src/init.mjs +232 -230
  78. package/src/migrate/apply.mjs +262 -262
  79. package/src/migrate/babel.mjs +79 -79
  80. package/src/migrate/classify.mjs +155 -155
  81. package/src/migrate/config.mjs +126 -126
  82. package/src/migrate/fs-walk.mjs +191 -191
  83. package/src/migrate/parse.mjs +26 -26
  84. package/src/migrate/scan.mjs +177 -177
  85. package/src/migrate/transform/expr-source.mjs +168 -168
  86. package/src/migrate/transform/island.mjs +67 -67
  87. package/src/migrate/transform/jsx-to-component.mjs +302 -302
  88. package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
  89. package/src/migrate/transform/page-split.mjs +435 -435
  90. package/src/migrate/write.mjs +81 -81
  91. package/src/migrate.mjs +171 -171
  92. package/src/runtime/alias-hooks.mjs +119 -119
  93. package/src/runtime/register.mjs +4 -4
  94. package/src/server/admin/actions.js +229 -229
  95. package/src/server/admin/auth.js +125 -125
  96. package/src/server/admin/event-log.js +151 -151
  97. package/src/server/admin/gate.js +209 -209
  98. package/src/server/admin/inventory.js +188 -188
  99. package/src/server/admin/mount.js +56 -56
  100. package/src/server/admin/router.js +216 -216
  101. package/src/server/admin/snapshot.js +241 -241
  102. package/src/server/assets.js +147 -147
  103. package/src/server/auth/handoff.js +309 -309
  104. package/src/server/cache-blob.js +70 -70
  105. package/src/server/cache-control.js +45 -0
  106. package/src/server/cache-deps.js +42 -42
  107. package/src/server/cache-vary.js +113 -113
  108. package/src/server/cloudflare.js +607 -607
  109. package/src/server/create-app.js +366 -366
  110. package/src/server/data-cache.js +553 -553
  111. package/src/server/dev/report.js +485 -485
  112. package/src/server/dev/socket.js +170 -170
  113. package/src/server/dev/version-check.mjs +139 -139
  114. package/src/server/disk-cache.js +233 -233
  115. package/src/server/ejs-adapter.js +59 -59
  116. package/src/server/html-cache.js +1196 -1196
  117. package/src/server/image-optimizer.js +500 -500
  118. package/src/server/logs/access-middleware.js +66 -66
  119. package/src/server/logs/file-sink.js +193 -193
  120. package/src/server/logs/pipeline.js +165 -165
  121. package/src/server/logs/s3-put.js +214 -214
  122. package/src/server/logs/s3-sink.js +112 -112
  123. package/src/server/metadata.js +102 -102
  124. package/src/server/middleware/compression.js +205 -205
  125. package/src/server/middleware/csrf.js +134 -134
  126. package/src/server/middleware/dev-gate.js +75 -75
  127. package/src/server/middleware/headers.js +37 -37
  128. package/src/server/middleware/redirects.js +32 -32
  129. package/src/server/middleware/robots-txt.js +341 -341
  130. package/src/server/middleware/static-precompressed.js +121 -121
  131. package/src/server/middleware/trailing-slash.js +53 -53
  132. package/src/server/middleware/upstream-proxy.js +141 -141
  133. package/src/server/og-image.js +965 -356
  134. package/src/server/og-raster.mjs +21 -0
  135. package/src/server/port-guard.js +255 -255
  136. package/src/server/prewarm.js +1082 -1082
  137. package/src/server/redis.js +588 -588
  138. package/src/server/render.js +910 -910
  139. package/src/server/router.js +157 -157
  140. package/src/server/status-page.js +265 -265
  141. package/src/server/upstream-limiter.js +376 -376
  142. package/src/server/upstream-tracking.js +166 -166
  143. package/src/shared/cookie-domain.js +66 -66
  144. package/src/start.mjs +22 -22
  145. package/src/templates/layout.ejs +30 -30
  146. package/src/templates/layout.jsk +30 -30
  147. package/src/version.mjs +31 -31
  148. package/src/views/components/loader.js +101 -101
  149. package/src/views/helpers/html.js +102 -102
  150. package/src/views/helpers/tags.js +375 -375
  151. package/types/config/defaults.d.ts +6 -0
  152. package/types/config/index.d.ts +6 -0
  153. package/types/server/cache-control.d.ts +28 -0
  154. package/types/server/og-image.d.ts +51 -3
@@ -1,500 +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 { 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
- }
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
+ }