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.
- package/AGENTS.md +136 -136
- package/CHANGELOG.md +633 -620
- package/LICENSE +21 -21
- package/README.md +2 -0
- package/bin/jskelet.mjs +130 -130
- package/docs/01-baslangic.md +291 -291
- package/docs/02-mimari.md +310 -310
- package/docs/03-routing.md +515 -515
- package/docs/04-render-ve-sablonlar.md +700 -661
- package/docs/05-islands.md +486 -486
- package/docs/06-cache.md +1467 -1443
- package/docs/07-yapilandirma.md +1208 -1197
- package/docs/08-build.md +429 -429
- package/docs/09-dev-araclari.md +364 -364
- package/docs/10-dagitim.md +351 -338
- package/docs/12-panel-ve-oturum.md +479 -478
- package/docs/README.md +83 -83
- package/docs/en/01-getting-started.md +298 -298
- package/docs/en/02-architecture.md +329 -329
- package/docs/en/03-routing.md +531 -531
- package/docs/en/04-rendering.md +708 -669
- package/docs/en/05-islands.md +497 -497
- package/docs/en/06-caching.md +1476 -1453
- package/docs/en/07-configuration.md +1229 -1219
- package/docs/en/08-build.md +447 -447
- package/docs/en/09-dev-tools.md +373 -373
- package/docs/en/10-deployment.md +355 -340
- package/docs/en/11-migration.md +398 -398
- package/docs/en/12-dashboards-and-sessions.md +489 -488
- package/docs/en/README.md +87 -87
- package/package.json +137 -137
- package/src/build/ensure-build.mjs +19 -19
- package/src/build/paths.mjs +153 -153
- package/src/build/resolve-peer.mjs +36 -36
- package/src/build/tasks/client.mjs +349 -349
- package/src/build/tasks/css.mjs +235 -235
- package/src/build/tasks/fonts.mjs +146 -146
- package/src/build/tasks/icons.mjs +357 -357
- package/src/build/tasks/images.mjs +244 -244
- package/src/build/tasks/precompress.mjs +78 -78
- package/src/build/tasks/templates.mjs +20 -20
- package/src/client/admin/i18n.js +764 -764
- package/src/client/admin/login.html +74 -74
- package/src/client/admin/panel.css +809 -809
- package/src/client/admin/panel.html +495 -495
- package/src/client/admin/panel.js +1251 -1251
- package/src/client/devtools/report.html +185 -185
- package/src/client/devtools/report.js +745 -745
- package/src/client/devtools/seo.js +628 -628
- package/src/client/dom.js +95 -95
- package/src/client/form.js +192 -192
- package/src/client/index.js +45 -45
- package/src/client/registry.js +305 -305
- package/src/client/safe-image.js +91 -91
- package/src/client/shared-cookie.js +225 -225
- package/src/client/store.js +36 -36
- package/src/client/swap.js +188 -188
- package/src/compile/codegen.js +336 -336
- package/src/compile/compile-all.js +149 -149
- package/src/compile/errors.js +66 -66
- package/src/compile/expr.js +409 -409
- package/src/compile/index.js +17 -17
- package/src/compile/parse.js +541 -541
- package/src/compile/resolve.js +211 -211
- package/src/compile/scan-exports.js +51 -51
- package/src/config/defaults.js +541 -534
- package/src/config/index.js +1500 -1469
- package/src/config/pattern.js +107 -107
- package/src/generate.mjs +163 -163
- package/src/http/control-flow.js +71 -71
- package/src/http/cookies-entry.js +21 -21
- package/src/http/cookies.js +277 -277
- package/src/http/request-cache.js +46 -46
- package/src/http/request-context.js +165 -165
- package/src/http/shared-cookie.js +178 -178
- package/src/index.js +101 -101
- package/src/init.mjs +232 -230
- package/src/migrate/apply.mjs +262 -262
- package/src/migrate/babel.mjs +79 -79
- package/src/migrate/classify.mjs +155 -155
- package/src/migrate/config.mjs +126 -126
- package/src/migrate/fs-walk.mjs +191 -191
- package/src/migrate/parse.mjs +26 -26
- package/src/migrate/scan.mjs +177 -177
- package/src/migrate/transform/expr-source.mjs +168 -168
- package/src/migrate/transform/island.mjs +67 -67
- package/src/migrate/transform/jsx-to-component.mjs +302 -302
- package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
- package/src/migrate/transform/page-split.mjs +435 -435
- package/src/migrate/write.mjs +81 -81
- package/src/migrate.mjs +171 -171
- package/src/runtime/alias-hooks.mjs +119 -119
- package/src/runtime/register.mjs +4 -4
- package/src/server/admin/actions.js +229 -229
- package/src/server/admin/auth.js +125 -125
- package/src/server/admin/event-log.js +151 -151
- package/src/server/admin/gate.js +209 -209
- package/src/server/admin/inventory.js +188 -188
- package/src/server/admin/mount.js +56 -56
- package/src/server/admin/router.js +216 -216
- package/src/server/admin/snapshot.js +241 -241
- package/src/server/assets.js +147 -147
- package/src/server/auth/handoff.js +309 -309
- package/src/server/cache-blob.js +70 -70
- package/src/server/cache-control.js +45 -0
- package/src/server/cache-deps.js +42 -42
- package/src/server/cache-vary.js +113 -113
- package/src/server/cloudflare.js +607 -607
- package/src/server/create-app.js +366 -366
- package/src/server/data-cache.js +553 -553
- package/src/server/dev/report.js +485 -485
- package/src/server/dev/socket.js +170 -170
- package/src/server/dev/version-check.mjs +139 -139
- package/src/server/disk-cache.js +233 -233
- package/src/server/ejs-adapter.js +59 -59
- package/src/server/html-cache.js +1196 -1196
- package/src/server/image-optimizer.js +500 -500
- package/src/server/logs/access-middleware.js +66 -66
- package/src/server/logs/file-sink.js +193 -193
- package/src/server/logs/pipeline.js +165 -165
- package/src/server/logs/s3-put.js +214 -214
- package/src/server/logs/s3-sink.js +112 -112
- package/src/server/metadata.js +102 -102
- package/src/server/middleware/compression.js +205 -205
- package/src/server/middleware/csrf.js +134 -134
- package/src/server/middleware/dev-gate.js +75 -75
- package/src/server/middleware/headers.js +37 -37
- package/src/server/middleware/redirects.js +32 -32
- package/src/server/middleware/robots-txt.js +341 -341
- package/src/server/middleware/static-precompressed.js +121 -121
- package/src/server/middleware/trailing-slash.js +53 -53
- package/src/server/middleware/upstream-proxy.js +141 -141
- package/src/server/og-image.js +965 -356
- package/src/server/og-raster.mjs +21 -0
- package/src/server/port-guard.js +255 -255
- package/src/server/prewarm.js +1082 -1082
- package/src/server/redis.js +588 -588
- package/src/server/render.js +910 -910
- package/src/server/router.js +157 -157
- package/src/server/status-page.js +265 -265
- package/src/server/upstream-limiter.js +376 -376
- package/src/server/upstream-tracking.js +166 -166
- package/src/shared/cookie-domain.js +66 -66
- package/src/start.mjs +22 -22
- package/src/templates/layout.ejs +30 -30
- package/src/templates/layout.jsk +30 -30
- package/src/version.mjs +31 -31
- package/src/views/components/loader.js +101 -101
- package/src/views/helpers/html.js +102 -102
- package/src/views/helpers/tags.js +375 -375
- package/types/config/defaults.d.ts +6 -0
- package/types/config/index.d.ts +6 -0
- package/types/server/cache-control.d.ts +28 -0
- 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
|
+
}
|