jskelet 0.4.3 → 0.4.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/CHANGELOG.md +14 -0
- package/docs/02-mimari.md +2 -0
- package/docs/04-render-ve-sablonlar.md +9 -5
- package/docs/07-yapilandirma.md +36 -9
- package/docs/08-build.md +7 -0
- package/docs/09-dev-araclari.md +12 -3
- package/docs/10-dagitim.md +5 -2
- package/docs/11-tasima.md +4 -3
- package/docs/README.md +1 -1
- package/docs/en/02-architecture.md +2 -0
- package/docs/en/04-rendering.md +9 -5
- package/docs/en/07-configuration.md +37 -9
- package/docs/en/08-build.md +7 -0
- package/docs/en/09-dev-tools.md +13 -3
- package/docs/en/10-deployment.md +6 -3
- package/docs/en/11-migration.md +4 -3
- package/docs/en/README.md +1 -1
- package/package.json +1 -1
- package/src/client/devtools/overlay.js +233 -18
- package/src/client/devtools/seo.js +628 -0
- package/src/config/defaults.js +26 -0
- package/src/config/index.js +99 -2
- package/src/index.js +1 -0
- package/src/server/create-app.js +9 -0
- package/src/server/dev/devtools.js +7 -0
- package/src/server/image-optimizer.js +338 -0
- package/src/views/helpers/tags.js +52 -5
package/src/config/index.js
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
DEFAULT_DEV_GATE_BYPASS,
|
|
38
38
|
DEFAULT_DIRS,
|
|
39
39
|
DEFAULT_HTML_CACHE_MAX_ENTRIES,
|
|
40
|
+
DEFAULT_IMAGES,
|
|
40
41
|
DEFAULT_LOGS,
|
|
41
42
|
DEFAULT_NAVIGATION,
|
|
42
43
|
DEFAULT_NAVIGATION_EXCLUDE,
|
|
@@ -130,10 +131,29 @@ const CONFIG_FILE = "jskelet.config.mjs";
|
|
|
130
131
|
* @property {string[]} watch Dev sunucusunun izlediği ek dizinler.
|
|
131
132
|
* @property {{ family: string, slug?: string, weights: number[] }[]} fonts
|
|
132
133
|
* @property {{ scan?: string[] } | false} icons
|
|
133
|
-
* @property {
|
|
134
|
+
* @property {ImagesConfig | false} images
|
|
134
135
|
* @property {string[]} clientEnv Client bundle'a gömülecek env anahtarları.
|
|
135
136
|
*/
|
|
136
137
|
|
|
138
|
+
/**
|
|
139
|
+
* @typedef {object} ImagesRemoteConfig
|
|
140
|
+
* @property {boolean} enabled
|
|
141
|
+
* @property {string[]} allowHosts
|
|
142
|
+
* @property {string} path
|
|
143
|
+
* @property {number} maxWidth
|
|
144
|
+
* @property {number} cacheMaxAge
|
|
145
|
+
* @property {number} fetchTimeoutMs
|
|
146
|
+
* @property {number} maxBytes
|
|
147
|
+
*/
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @typedef {object} ImagesConfig
|
|
151
|
+
* @property {number[]} widths
|
|
152
|
+
* @property {number} quality
|
|
153
|
+
* @property {string[]} skip
|
|
154
|
+
* @property {ImagesRemoteConfig | false} remote
|
|
155
|
+
*/
|
|
156
|
+
|
|
137
157
|
/** @type {ResolvedConfig | null} */
|
|
138
158
|
let config = null;
|
|
139
159
|
|
|
@@ -772,6 +792,83 @@ function normalizeSecurity(raw) {
|
|
|
772
792
|
};
|
|
773
793
|
}
|
|
774
794
|
|
|
795
|
+
/**
|
|
796
|
+
* Build + runtime görsel ayarları. `false` → her iki yüzey de kapalı.
|
|
797
|
+
* `remote.allowHosts` boşsa remote kapalı kalır (açık proxy olmasın).
|
|
798
|
+
*
|
|
799
|
+
* @param {unknown} raw
|
|
800
|
+
* @returns {ImagesConfig | false}
|
|
801
|
+
*/
|
|
802
|
+
function normalizeImages(raw) {
|
|
803
|
+
if (raw === false) return false;
|
|
804
|
+
|
|
805
|
+
const source = /** @type {Record<string, any>} */ (raw ?? {});
|
|
806
|
+
const widths = asArray(source.widths ?? DEFAULT_IMAGES.widths, "images.widths")
|
|
807
|
+
.map((entry) => Number(entry))
|
|
808
|
+
.filter((entry) => Number.isFinite(entry) && entry > 0)
|
|
809
|
+
.map((entry) => Math.round(entry));
|
|
810
|
+
|
|
811
|
+
const quality = Number(source.quality ?? DEFAULT_IMAGES.quality);
|
|
812
|
+
const skip = asArray(source.skip ?? DEFAULT_IMAGES.skip, "images.skip")
|
|
813
|
+
.filter((entry) => typeof entry === "string")
|
|
814
|
+
.map(String);
|
|
815
|
+
|
|
816
|
+
/** @type {ImagesRemoteConfig | false} */
|
|
817
|
+
let remote = false;
|
|
818
|
+
if (source.remote !== false && source.remote != null) {
|
|
819
|
+
const rem = /** @type {Record<string, any>} */ (
|
|
820
|
+
source.remote === true ? {} : source.remote
|
|
821
|
+
);
|
|
822
|
+
const allowHosts = asArray(
|
|
823
|
+
rem.allowHosts ?? DEFAULT_IMAGES.remote.allowHosts,
|
|
824
|
+
"images.remote.allowHosts",
|
|
825
|
+
)
|
|
826
|
+
.filter((entry) => typeof entry === "string" && entry.trim())
|
|
827
|
+
.map((entry) => String(entry).trim().toLowerCase());
|
|
828
|
+
|
|
829
|
+
if (allowHosts.length === 0) {
|
|
830
|
+
if (source.remote === true || rem.allowHosts != null) {
|
|
831
|
+
console.warn(
|
|
832
|
+
"[config] images.remote needs a non-empty allowHosts list; remote optimizer disabled",
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
} else {
|
|
836
|
+
remote = {
|
|
837
|
+
enabled: true,
|
|
838
|
+
allowHosts,
|
|
839
|
+
path: String(rem.path ?? DEFAULT_IMAGES.remote.path),
|
|
840
|
+
maxWidth: Math.max(
|
|
841
|
+
1,
|
|
842
|
+
Number(rem.maxWidth ?? DEFAULT_IMAGES.remote.maxWidth) ||
|
|
843
|
+
DEFAULT_IMAGES.remote.maxWidth,
|
|
844
|
+
),
|
|
845
|
+
cacheMaxAge: Math.max(
|
|
846
|
+
0,
|
|
847
|
+
Number(rem.cacheMaxAge ?? DEFAULT_IMAGES.remote.cacheMaxAge) ||
|
|
848
|
+
DEFAULT_IMAGES.remote.cacheMaxAge,
|
|
849
|
+
),
|
|
850
|
+
fetchTimeoutMs: Math.max(
|
|
851
|
+
1000,
|
|
852
|
+
Number(rem.fetchTimeoutMs ?? DEFAULT_IMAGES.remote.fetchTimeoutMs) ||
|
|
853
|
+
DEFAULT_IMAGES.remote.fetchTimeoutMs,
|
|
854
|
+
),
|
|
855
|
+
maxBytes: Math.max(
|
|
856
|
+
1024,
|
|
857
|
+
Number(rem.maxBytes ?? DEFAULT_IMAGES.remote.maxBytes) ||
|
|
858
|
+
DEFAULT_IMAGES.remote.maxBytes,
|
|
859
|
+
),
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
return {
|
|
865
|
+
widths: widths.length ? widths : [...DEFAULT_IMAGES.widths],
|
|
866
|
+
quality: Number.isFinite(quality) && quality > 0 ? quality : DEFAULT_IMAGES.quality,
|
|
867
|
+
skip,
|
|
868
|
+
remote,
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
|
|
775
872
|
/**
|
|
776
873
|
* Dizin adlarını mutlak yola çevirir. `styles` bir dosya yolu olduğu için
|
|
777
874
|
* de aynı çözümlemeden geçer; ayrı bir alan tutmaya değmez.
|
|
@@ -942,7 +1039,7 @@ export async function loadConfig(options = {}) {
|
|
|
942
1039
|
// olsun diye aynı yerden geçer.
|
|
943
1040
|
fonts: source.fonts ?? [],
|
|
944
1041
|
icons: source.icons ?? {},
|
|
945
|
-
images: source.images
|
|
1042
|
+
images: normalizeImages(source.images),
|
|
946
1043
|
clientEnv: source.clientEnv ?? [],
|
|
947
1044
|
};
|
|
948
1045
|
|
package/src/index.js
CHANGED
|
@@ -37,6 +37,7 @@ export {
|
|
|
37
37
|
} from "./http/cookies.js";
|
|
38
38
|
export { reportUpstreamFailure } from "./server/upstream-tracking.js";
|
|
39
39
|
export { asset, hasAsset, optimizedImage, getSpriteIds } from "./server/assets.js";
|
|
40
|
+
export { remoteImageUrl, parseAllowedRemoteUrl } from "./server/image-optimizer.js";
|
|
40
41
|
export { headHints } from "./server/head-hints.js";
|
|
41
42
|
export { renderHeadMeta } from "./server/metadata.js";
|
|
42
43
|
export {
|
package/src/server/create-app.js
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* static'e düşer ve middleware anında sıkıştırır (kalite 5).
|
|
18
18
|
* 4b. admin paneli (açıksa) — statikten sonra, route'lardan önce: kendi
|
|
19
19
|
* gövde ayrıştırıcısını taşır ve uygulama yolunu gölgeleyemez.
|
|
20
|
+
* 4c. image optimizer (images.remote) — uzak görselleri webp'ye çevirir;
|
|
21
|
+
* body parser'dan önce, admin ile aynı katmanda.
|
|
20
22
|
* 5. body parser'lar — statikten sonra: görsel isteklerinde gövde ayrıştırma
|
|
21
23
|
* maliyeti ödenmesin.
|
|
22
24
|
* 6. csrf — body parser'lardan sonra olmalı: token form alanından okunuyor.
|
|
@@ -137,6 +139,13 @@ export async function createApp(options = {}) {
|
|
|
137
139
|
mountAdmin(app);
|
|
138
140
|
}
|
|
139
141
|
|
|
142
|
+
// Uzak görsel proxy: allowHosts doluysa mount. Statikten sonra, body
|
|
143
|
+
// parser'dan önce — görsel GET'lerinde gövde ayrıştırma maliyeti ödenmesin.
|
|
144
|
+
if (config.images && config.images !== false && config.images.remote) {
|
|
145
|
+
const { mountImageOptimizer } = await import("./image-optimizer.js");
|
|
146
|
+
await mountImageOptimizer(app);
|
|
147
|
+
}
|
|
148
|
+
|
|
140
149
|
app.use(express.urlencoded({ extended: false, limit: "64kb" }));
|
|
141
150
|
app.use(express.json({ limit: "256kb" }));
|
|
142
151
|
|
|
@@ -339,6 +339,13 @@ function router() {
|
|
|
339
339
|
fs.createReadStream(OVERLAY_FILE).pipe(res);
|
|
340
340
|
});
|
|
341
341
|
|
|
342
|
+
// Overlay'in SEO taraması; native ESM import ile yüklenir.
|
|
343
|
+
api.get("/seo.js", (req, res) => {
|
|
344
|
+
res.type("application/javascript");
|
|
345
|
+
res.setHeader("Cache-Control", "no-store");
|
|
346
|
+
fs.createReadStream(path.join(DEVTOOLS_DIR, "seo.js")).pipe(res);
|
|
347
|
+
});
|
|
348
|
+
|
|
342
349
|
api.get("/logo.png", (req, res) => {
|
|
343
350
|
res.type("image/png");
|
|
344
351
|
// Logo geliştirme sırasında değişmiyor; her gezinmede yeniden indirmesin.
|
|
@@ -0,0 +1,338 @@
|
|
|
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
|
+
import crypto from "node:crypto";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import process from "node:process";
|
|
16
|
+
import { tryImportFromApp } from "../build/resolve-peer.mjs";
|
|
17
|
+
import { getConfig } from "../config/index.js";
|
|
18
|
+
|
|
19
|
+
/** @type {typeof import('sharp') | null | undefined} */
|
|
20
|
+
let sharpModule;
|
|
21
|
+
|
|
22
|
+
/** @type {string | null} */
|
|
23
|
+
let cacheDir = null;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @returns {import('../config/index.js').ImagesRemoteConfig | null}
|
|
27
|
+
*/
|
|
28
|
+
export function getRemoteImages() {
|
|
29
|
+
const images = getConfig().images;
|
|
30
|
+
if (!images || images === false || !images.remote || !images.remote.enabled) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return images.remote;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Mutlak http(s) URL mi ve allowHosts'ta mı.
|
|
38
|
+
* @param {string} src
|
|
39
|
+
* @returns {URL | null}
|
|
40
|
+
*/
|
|
41
|
+
export function parseAllowedRemoteUrl(src) {
|
|
42
|
+
const remote = getRemoteImages();
|
|
43
|
+
if (!remote || typeof src !== "string") return null;
|
|
44
|
+
|
|
45
|
+
let url;
|
|
46
|
+
try {
|
|
47
|
+
url = new URL(src);
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
53
|
+
if (!isHostAllowed(url.hostname, remote.allowHosts)) return null;
|
|
54
|
+
if (isBlockedAddress(url.hostname)) return null;
|
|
55
|
+
|
|
56
|
+
return url;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {string} hostname
|
|
61
|
+
* @param {string[]} allowHosts
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
export function isHostAllowed(hostname, allowHosts) {
|
|
65
|
+
const host = hostname.toLowerCase();
|
|
66
|
+
return allowHosts.some((entry) => {
|
|
67
|
+
const pattern = entry.toLowerCase();
|
|
68
|
+
if (pattern.startsWith("*.")) {
|
|
69
|
+
const suffix = pattern.slice(1); // ".example.com"
|
|
70
|
+
return host.endsWith(suffix) || host === pattern.slice(2);
|
|
71
|
+
}
|
|
72
|
+
return host === pattern;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Literal private / link-local / loopback host'ları reddet (SSRF).
|
|
78
|
+
* Allowlist asıl koruma; bu ek bir savunma katmanı.
|
|
79
|
+
* @param {string} hostname
|
|
80
|
+
* @returns {boolean}
|
|
81
|
+
*/
|
|
82
|
+
export function isBlockedAddress(hostname) {
|
|
83
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
84
|
+
if (host === "localhost" || host === "0.0.0.0" || host.endsWith(".localhost")) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
if (host === "::1" || host === "0:0:0:0:0:0:0:1") return true;
|
|
88
|
+
|
|
89
|
+
// IPv4
|
|
90
|
+
const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
91
|
+
if (ipv4) {
|
|
92
|
+
const parts = ipv4.slice(1).map(Number);
|
|
93
|
+
if (parts.some((n) => n > 255)) return true;
|
|
94
|
+
const [a, b] = parts;
|
|
95
|
+
if (a === 10 || a === 127 || a === 0) return true;
|
|
96
|
+
if (a === 169 && b === 254) return true;
|
|
97
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
98
|
+
if (a === 192 && b === 168) return true;
|
|
99
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Ham IPv6 private / ULA — basit önek kontrolü
|
|
104
|
+
if (host.includes(":")) {
|
|
105
|
+
if (host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80")) {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Optimizer URL'si üret. `image()` ve elle URL kuran uygulamalar için.
|
|
115
|
+
* @param {string} src Uzak görsel URL'si
|
|
116
|
+
* @param {{ width: number, quality?: number }} options
|
|
117
|
+
* @returns {string | null} Allowlist dışıysa null
|
|
118
|
+
*/
|
|
119
|
+
export function remoteImageUrl(src, options) {
|
|
120
|
+
const remote = getRemoteImages();
|
|
121
|
+
const allowed = parseAllowedRemoteUrl(src);
|
|
122
|
+
if (!remote || !allowed) return null;
|
|
123
|
+
|
|
124
|
+
const images = getConfig().images;
|
|
125
|
+
const quality =
|
|
126
|
+
options.quality ??
|
|
127
|
+
(images && images !== false ? images.quality : 78) ??
|
|
128
|
+
78;
|
|
129
|
+
const width = clampWidth(options.width, remote.maxWidth);
|
|
130
|
+
|
|
131
|
+
const params = new URLSearchParams({
|
|
132
|
+
url: allowed.href,
|
|
133
|
+
w: String(width),
|
|
134
|
+
q: String(quality),
|
|
135
|
+
});
|
|
136
|
+
return `${remote.path}?${params}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @param {number} width
|
|
141
|
+
* @param {number} maxWidth
|
|
142
|
+
* @returns {number}
|
|
143
|
+
*/
|
|
144
|
+
export function clampWidth(width, maxWidth) {
|
|
145
|
+
const n = Math.round(Number(width));
|
|
146
|
+
if (!Number.isFinite(n) || n < 1) return Math.min(640, maxWidth);
|
|
147
|
+
return Math.min(n, maxWidth);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Görüntülenen genişliğe göre srcset adayları (1x + 2x + config widths).
|
|
152
|
+
* @param {number} displayWidth
|
|
153
|
+
* @param {number[]} widths
|
|
154
|
+
* @param {number} maxWidth
|
|
155
|
+
* @returns {number[]}
|
|
156
|
+
*/
|
|
157
|
+
export function srcsetWidths(displayWidth, widths, maxWidth) {
|
|
158
|
+
const base = Math.max(1, Math.round(displayWidth));
|
|
159
|
+
const candidates = new Set([
|
|
160
|
+
base,
|
|
161
|
+
Math.min(base * 2, maxWidth),
|
|
162
|
+
...widths.filter((w) => w >= base && w <= maxWidth),
|
|
163
|
+
]);
|
|
164
|
+
return [...candidates].sort((a, b) => a - b);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {import('express').Express} app
|
|
169
|
+
* @returns {Promise<void>}
|
|
170
|
+
*/
|
|
171
|
+
export async function mountImageOptimizer(app) {
|
|
172
|
+
const remote = getRemoteImages();
|
|
173
|
+
if (!remote) return;
|
|
174
|
+
|
|
175
|
+
const config = getConfig();
|
|
176
|
+
cacheDir = path.join(config.dirs.generated, "image-cache");
|
|
177
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
178
|
+
|
|
179
|
+
sharpModule = await tryImportFromApp(config.root, "sharp");
|
|
180
|
+
if (!sharpModule) {
|
|
181
|
+
console.warn(
|
|
182
|
+
"[images.remote] sharp not installed; optimizer will redirect to the source URL",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
app.get(remote.path, (req, res) => {
|
|
187
|
+
void handleOptimize(req, res, remote);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* @param {import('express').Request} req
|
|
193
|
+
* @param {import('express').Response} res
|
|
194
|
+
* @param {import('../config/index.js').ImagesRemoteConfig} remote
|
|
195
|
+
*/
|
|
196
|
+
async function handleOptimize(req, res, remote) {
|
|
197
|
+
const rawUrl = typeof req.query.url === "string" ? req.query.url : "";
|
|
198
|
+
const allowed = parseAllowedRemoteUrl(rawUrl);
|
|
199
|
+
if (!allowed) {
|
|
200
|
+
res.status(400).type("text").send("Invalid or disallowed image url");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const images = getConfig().images;
|
|
205
|
+
const defaultQ = images && images !== false ? images.quality : 78;
|
|
206
|
+
const quality = clampQuality(
|
|
207
|
+
typeof req.query.q === "string" ? req.query.q : defaultQ,
|
|
208
|
+
defaultQ,
|
|
209
|
+
);
|
|
210
|
+
const width = clampWidth(
|
|
211
|
+
typeof req.query.w === "string" ? req.query.w : 640,
|
|
212
|
+
remote.maxWidth,
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
if (!sharpModule) {
|
|
216
|
+
res.redirect(302, allowed.href);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const key = cacheKey(allowed.href, width, quality);
|
|
221
|
+
const filePath = path.join(/** @type {string} */ (cacheDir), `${key}.webp`);
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
if (fs.existsSync(filePath)) {
|
|
225
|
+
sendCached(res, filePath, remote.cacheMaxAge);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const upstream = await fetchUpstream(allowed.href, remote);
|
|
230
|
+
if (!upstream.ok) {
|
|
231
|
+
res.status(502).type("text").send("Upstream image fetch failed");
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const sharp = sharpModule.default;
|
|
236
|
+
const buffer = await sharp(upstream.buffer, { failOn: "none" })
|
|
237
|
+
.rotate()
|
|
238
|
+
.resize({
|
|
239
|
+
width,
|
|
240
|
+
withoutEnlargement: true,
|
|
241
|
+
fit: "inside",
|
|
242
|
+
})
|
|
243
|
+
.webp({ quality, effort: 4 })
|
|
244
|
+
.toBuffer();
|
|
245
|
+
|
|
246
|
+
// Atomik yaz: yarım dosya immutable cache'e düşmesin.
|
|
247
|
+
const tmp = `${filePath}.${process.pid}.tmp`;
|
|
248
|
+
await fs.promises.writeFile(tmp, buffer);
|
|
249
|
+
await fs.promises.rename(tmp, filePath);
|
|
250
|
+
|
|
251
|
+
sendCached(res, filePath, remote.cacheMaxAge);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
console.warn("[images.remote] optimize failed:", error);
|
|
254
|
+
// Bozuk kaynakta sayfa boş kalmasın: orijinale düş.
|
|
255
|
+
if (!res.headersSent) res.redirect(302, allowed.href);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* @param {string} href
|
|
261
|
+
* @param {import('../config/index.js').ImagesRemoteConfig} remote
|
|
262
|
+
* @returns {Promise<{ ok: true, buffer: Buffer } | { ok: false }>}
|
|
263
|
+
*/
|
|
264
|
+
async function fetchUpstream(href, remote) {
|
|
265
|
+
const controller = new AbortController();
|
|
266
|
+
const timer = setTimeout(() => controller.abort(), remote.fetchTimeoutMs);
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
const response = await fetch(href, {
|
|
270
|
+
signal: controller.signal,
|
|
271
|
+
redirect: "follow",
|
|
272
|
+
headers: {
|
|
273
|
+
// Bazı CDN'ler bot UA reddeder; tarayıcıya yakın tut.
|
|
274
|
+
Accept: "image/avif,image/webp,image/*,*/*;q=0.8",
|
|
275
|
+
"User-Agent": "jskelet-image-optimizer/1",
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (!response.ok) return { ok: false };
|
|
280
|
+
|
|
281
|
+
const type = response.headers.get("content-type") ?? "";
|
|
282
|
+
if (type && !type.startsWith("image/") && !type.includes("octet-stream")) {
|
|
283
|
+
return { ok: false };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const length = Number(response.headers.get("content-length") ?? 0);
|
|
287
|
+
if (length > remote.maxBytes) return { ok: false };
|
|
288
|
+
|
|
289
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
290
|
+
if (buffer.byteLength > remote.maxBytes) return { ok: false };
|
|
291
|
+
|
|
292
|
+
return { ok: true, buffer };
|
|
293
|
+
} catch {
|
|
294
|
+
return { ok: false };
|
|
295
|
+
} finally {
|
|
296
|
+
clearTimeout(timer);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* @param {import('express').Response} res
|
|
302
|
+
* @param {string} filePath
|
|
303
|
+
* @param {number} maxAge
|
|
304
|
+
*/
|
|
305
|
+
function sendCached(res, filePath, maxAge) {
|
|
306
|
+
res.setHeader("Content-Type", "image/webp");
|
|
307
|
+
res.setHeader(
|
|
308
|
+
"Cache-Control",
|
|
309
|
+
`public, max-age=${maxAge}, stale-while-revalidate=${Math.min(maxAge, 86400)}`,
|
|
310
|
+
);
|
|
311
|
+
res.setHeader("Vary", "Accept");
|
|
312
|
+
res.sendFile(path.resolve(filePath));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* @param {string} href
|
|
317
|
+
* @param {number} width
|
|
318
|
+
* @param {number} quality
|
|
319
|
+
* @returns {string}
|
|
320
|
+
*/
|
|
321
|
+
function cacheKey(href, width, quality) {
|
|
322
|
+
return crypto
|
|
323
|
+
.createHash("sha256")
|
|
324
|
+
.update(`webp-q${quality}-e4:${width}:${href}`)
|
|
325
|
+
.digest("hex")
|
|
326
|
+
.slice(0, 32);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* @param {unknown} value
|
|
331
|
+
* @param {number} fallback
|
|
332
|
+
* @returns {number}
|
|
333
|
+
*/
|
|
334
|
+
function clampQuality(value, fallback) {
|
|
335
|
+
const n = Math.round(Number(value));
|
|
336
|
+
if (!Number.isFinite(n)) return fallback;
|
|
337
|
+
return Math.min(100, Math.max(1, n));
|
|
338
|
+
}
|
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { attrs, esc, cn } from "./html.js";
|
|
6
6
|
import { asset, getSpriteIds, optimizedImage } from "../../server/assets.js";
|
|
7
|
+
import {
|
|
8
|
+
parseAllowedRemoteUrl,
|
|
9
|
+
remoteImageUrl,
|
|
10
|
+
srcsetWidths,
|
|
11
|
+
} from "../../server/image-optimizer.js";
|
|
7
12
|
import { getRequestContext, markTainted } from "../../http/request-context.js";
|
|
8
13
|
import { getSignedCookie, randomToken, setSignedCookie } from "../../http/cookies.js";
|
|
9
14
|
import { getConfig } from "../../config/index.js";
|
|
@@ -53,7 +58,11 @@ export function link(props) {
|
|
|
53
58
|
* `next/image` karşılığı. `public/` altındaki yerel görseller için build'de
|
|
54
59
|
* üretilen webp varyantları (`build/tasks/images.mjs`) otomatik olarak
|
|
55
60
|
* `srcset` + intrinsic `width`/`height` olarak eklenir; manifest'te olmayan
|
|
56
|
-
*
|
|
61
|
+
* yerel yollar olduğu gibi basılır.
|
|
62
|
+
*
|
|
63
|
+
* `images.remote.allowHosts` açıksa uzak http(s) URL'leri `/_jskelet/image`
|
|
64
|
+
* proxy'sine çevrilir (webp + `w`). `unoptimized` veya elle `srcset` bunu
|
|
65
|
+
* atlar.
|
|
57
66
|
*
|
|
58
67
|
* `priority` LCP görselleri için `fetchpriority=high` + eager yükleme yapar.
|
|
59
68
|
* @param {{ src: string, alt: string, width?: number, height?: number,
|
|
@@ -79,20 +88,27 @@ export function image(props) {
|
|
|
79
88
|
} = props;
|
|
80
89
|
|
|
81
90
|
const optimized = unoptimized || srcset ? undefined : optimizedImage(src);
|
|
91
|
+
const remote = unoptimized || srcset ? null : remoteResponsive(src, width);
|
|
82
92
|
const largest = optimized?.variants.at(-1);
|
|
83
93
|
// Tek varyant üretilmişse (kaynak zaten küçükse) srcset/sizes gürültüden ibaret.
|
|
84
|
-
const responsive =
|
|
94
|
+
const responsive =
|
|
95
|
+
(optimized && optimized.variants.length > 1) ||
|
|
96
|
+
(remote && remote.srcset);
|
|
85
97
|
|
|
86
98
|
const attributes = attrs({
|
|
87
|
-
src: largest?.url ?? src,
|
|
99
|
+
src: largest?.url ?? remote?.src ?? src,
|
|
88
100
|
alt: alt ?? "",
|
|
89
101
|
width: fill ? undefined : (width ?? optimized?.width),
|
|
90
102
|
height: fill ? undefined : (height ?? optimized?.height),
|
|
91
103
|
class: fill
|
|
92
104
|
? cn("absolute inset-0 h-full w-full object-cover", className)
|
|
93
105
|
: className,
|
|
94
|
-
sizes: responsive
|
|
95
|
-
|
|
106
|
+
sizes: responsive
|
|
107
|
+
? (sizes ?? (optimized ? defaultSizes(optimized) : remote?.sizes))
|
|
108
|
+
: sizes,
|
|
109
|
+
srcset: responsive
|
|
110
|
+
? (optimized ? toSrcSet(optimized) : remote?.srcset)
|
|
111
|
+
: srcset,
|
|
96
112
|
loading: loading ?? (priority ? "eager" : "lazy"),
|
|
97
113
|
decoding: priority ? "sync" : "async",
|
|
98
114
|
fetchpriority: priority ? "high" : undefined,
|
|
@@ -102,6 +118,37 @@ export function image(props) {
|
|
|
102
118
|
return `<img${attributes}>`;
|
|
103
119
|
}
|
|
104
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Uzak URL → optimizer `src` / `srcset`. Allowlist dışıysa null.
|
|
123
|
+
* @param {string} src
|
|
124
|
+
* @param {number | undefined} width
|
|
125
|
+
* @returns {{ src: string, srcset?: string, sizes?: string } | null}
|
|
126
|
+
*/
|
|
127
|
+
function remoteResponsive(src, width) {
|
|
128
|
+
if (!parseAllowedRemoteUrl(src)) return null;
|
|
129
|
+
|
|
130
|
+
const images = getConfig().images;
|
|
131
|
+
if (!images || images === false || !images.remote) return null;
|
|
132
|
+
|
|
133
|
+
const display = width && width > 0 ? width : 640;
|
|
134
|
+
const widths = srcsetWidths(display, images.widths, images.remote.maxWidth);
|
|
135
|
+
const urls = widths
|
|
136
|
+
.map((w) => {
|
|
137
|
+
const href = remoteImageUrl(src, { width: w });
|
|
138
|
+
return href ? `${href} ${w}w` : null;
|
|
139
|
+
})
|
|
140
|
+
.filter(Boolean);
|
|
141
|
+
|
|
142
|
+
const primary = remoteImageUrl(src, { width: display });
|
|
143
|
+
if (!primary) return null;
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
src: primary,
|
|
147
|
+
srcset: urls.length > 1 ? urls.join(", ") : undefined,
|
|
148
|
+
sizes: `(max-width: ${display}px) 100vw, ${display}px`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
105
152
|
/**
|
|
106
153
|
* @param {import("../../server/assets.js").OptimizedImage} optimized
|
|
107
154
|
* @returns {string}
|