jskelet 0.5.5 → 0.6.0
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 +18 -13
- package/CHANGELOG.md +95 -0
- package/README.md +9 -7
- package/bin/jskelet.mjs +24 -10
- package/docs/01-baslangic.md +4 -3
- package/docs/02-mimari.md +4 -3
- package/docs/03-routing.md +11 -6
- package/docs/04-render-ve-sablonlar.md +35 -43
- package/docs/05-islands.md +12 -8
- package/docs/07-yapilandirma.md +40 -18
- package/docs/08-build.md +15 -9
- package/docs/09-dev-araclari.md +5 -1
- package/docs/10-dagitim.md +6 -1
- package/docs/11-tasima.md +51 -17
- package/docs/12-panel-ve-oturum.md +10 -4
- package/docs/README.md +7 -5
- package/docs/en/01-getting-started.md +4 -3
- package/docs/en/02-architecture.md +5 -5
- package/docs/en/03-routing.md +12 -7
- package/docs/en/04-rendering.md +47 -59
- package/docs/en/05-islands.md +13 -8
- package/docs/en/07-configuration.md +40 -20
- package/docs/en/08-build.md +16 -10
- package/docs/en/09-dev-tools.md +6 -1
- package/docs/en/10-deployment.md +6 -1
- package/docs/en/11-migration.md +51 -16
- package/docs/en/12-dashboards-and-sessions.md +9 -4
- package/docs/en/README.md +7 -5
- package/package.json +49 -14
- package/src/build/tasks/client.mjs +91 -10
- package/src/build/tasks/icons.mjs +11 -1
- package/src/client/index.js +2 -2
- package/src/compile/codegen.js +4 -0
- package/src/compile/compile-all.js +12 -21
- package/src/compile/expr.js +5 -0
- package/src/compile/parse.js +64 -8
- package/src/compile/resolve.js +3 -0
- package/src/config/defaults.js +12 -2
- package/src/config/index.js +1 -1
- package/src/dev-server.mjs +26 -3
- package/src/http/cookies-entry.js +1 -0
- package/src/http/cookies.js +18 -0
- package/src/logo.png +0 -0
- package/src/migrate/apply.mjs +262 -0
- package/src/migrate/babel.mjs +79 -0
- package/src/migrate/classify.mjs +155 -0
- package/src/migrate/config.mjs +126 -0
- package/src/migrate/fs-walk.mjs +191 -0
- package/src/migrate/parse.mjs +26 -0
- package/src/migrate/scan.mjs +177 -0
- package/src/migrate/transform/expr-source.mjs +168 -0
- package/src/migrate/transform/island.mjs +67 -0
- package/src/migrate/transform/jsx-to-component.mjs +302 -0
- package/src/migrate/transform/jsx-to-jsk.mjs +330 -0
- package/src/migrate/transform/page-split.mjs +435 -0
- package/src/migrate/write.mjs +81 -0
- package/src/migrate.mjs +171 -0
- package/src/server/auth/handoff.js +94 -11
- package/src/server/create-app.js +28 -10
- package/src/server/ejs-adapter.js +59 -0
- package/src/server/image-optimizer.js +94 -26
- package/src/server/port-guard.js +255 -0
- package/src/server/render.js +27 -9
- package/src/server/status-page.js +105 -4
- package/src/start.mjs +18 -3
- package/src/templates/layout.ejs +8 -28
- package/src/templates/layout.jsk +30 -0
- package/src/templates/layout.render.js +41 -0
- package/src/views/helpers/tags.js +86 -3
- package/types/build/resolve-peer.d.mts +13 -0
- package/types/client/dom.d.ts +55 -0
- package/types/client/form.d.ts +19 -0
- package/types/client/index.d.ts +20 -0
- package/types/client/registry.d.ts +53 -0
- package/types/client/safe-image.d.ts +19 -0
- package/types/client/shared-cookie.d.ts +82 -0
- package/types/client/store.d.ts +18 -0
- package/types/client/swap.d.ts +46 -0
- package/types/compile/codegen.d.ts +32 -0
- package/types/compile/compile-all.d.ts +42 -0
- package/types/compile/errors.d.ts +30 -0
- package/types/compile/expr.d.ts +67 -0
- package/types/compile/index.d.ts +10 -0
- package/types/compile/parse.d.ts +82 -0
- package/types/compile/resolve.d.ts +46 -0
- package/types/compile/scan-exports.d.ts +9 -0
- package/types/config/defaults.d.ts +449 -0
- package/types/config/index.d.ts +299 -0
- package/types/config/pattern.d.ts +38 -0
- package/types/http/control-flow.d.ts +45 -0
- package/types/http/cookies-entry.d.ts +5 -0
- package/types/http/cookies.d.ts +113 -0
- package/types/http/request-cache.d.ts +13 -0
- package/types/http/request-context.d.ts +67 -0
- package/types/http/shared-cookie.d.ts +73 -0
- package/types/index.d.ts +30 -0
- package/types/log.d.mts +153 -0
- package/types/server/admin/actions.d.ts +16 -0
- package/types/server/admin/auth.d.ts +52 -0
- package/types/server/admin/event-log.d.ts +38 -0
- package/types/server/admin/gate.d.ts +43 -0
- package/types/server/admin/inventory.d.ts +40 -0
- package/types/server/admin/mount.d.ts +6 -0
- package/types/server/admin/router.d.ts +6 -0
- package/types/server/admin/snapshot.d.ts +6 -0
- package/types/server/assets.d.ts +47 -0
- package/types/server/auth/handoff.d.ts +12 -0
- package/types/server/cache-deps.d.ts +16 -0
- package/types/server/cache-vary.d.ts +30 -0
- package/types/server/cloudflare.d.ts +163 -0
- package/types/server/create-app.d.ts +25 -0
- package/types/server/data-cache.d.ts +116 -0
- package/types/server/dev/devtools.d.ts +44 -0
- package/types/server/dev/report.d.ts +229 -0
- package/types/server/dev/socket.d.ts +17 -0
- package/types/server/dev/version-check.d.mts +15 -0
- package/types/server/ejs-adapter.d.ts +11 -0
- package/types/server/head-hints.d.ts +40 -0
- package/types/server/html-cache.d.ts +173 -0
- package/types/server/image-optimizer.d.ts +68 -0
- package/types/server/logs/access-middleware.d.ts +7 -0
- package/types/server/logs/file-sink.d.ts +17 -0
- package/types/server/logs/pipeline.d.ts +37 -0
- package/types/server/logs/s3-put.d.ts +85 -0
- package/types/server/logs/s3-sink.d.ts +26 -0
- package/types/server/metadata.d.ts +38 -0
- package/types/server/middleware/compression.d.ts +17 -0
- package/types/server/middleware/csrf.d.ts +4 -0
- package/types/server/middleware/dev-gate.d.ts +2 -0
- package/types/server/middleware/headers.d.ts +2 -0
- package/types/server/middleware/redirects.d.ts +2 -0
- package/types/server/middleware/static-precompressed.d.ts +5 -0
- package/types/server/middleware/trailing-slash.d.ts +11 -0
- package/types/server/middleware/upstream-proxy.d.ts +21 -0
- package/types/server/og-image.d.ts +149 -0
- package/types/server/port-guard.d.ts +50 -0
- package/types/server/prewarm.d.ts +128 -0
- package/types/server/redis.d.ts +163 -0
- package/types/server/render.d.ts +101 -0
- package/types/server/router.d.ts +5 -0
- package/types/server/status-page.d.ts +24 -0
- package/types/server/upstream-limiter.d.ts +123 -0
- package/types/server/upstream-tracking.d.ts +42 -0
- package/types/shared/cookie-domain.d.ts +29 -0
- package/types/templates/layout.render.d.ts +7 -0
- package/types/version.d.mts +10 -0
- package/types/views/components/loader.d.ts +5 -0
- package/types/views/helpers/html.d.ts +39 -0
- package/types/views/helpers/tags.d.ts +127 -0
|
@@ -9,9 +9,12 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Bilet süreç belleğinde; tek süreçte tüm locale host'ları servis eden
|
|
11
11
|
* kurulumlar için yeterli. Değer kısa session id olmalı.
|
|
12
|
+
*
|
|
13
|
+
* Güvenlik: mint CSRF'den *sonra* mount edilir (create-app); cookie adı
|
|
14
|
+
* allowlist + RFC 6265 doğrulaması; bekleyen bilet ve IP başına mint sınırı.
|
|
12
15
|
*/
|
|
13
16
|
import { getConfig } from "../../config/index.js";
|
|
14
|
-
import { randomToken, setCookie } from "../../http/cookies.js";
|
|
17
|
+
import { isValidCookieName, randomToken, setCookie } from "../../http/cookies.js";
|
|
15
18
|
import {
|
|
16
19
|
SHARED_COOKIE_WARN_BYTES,
|
|
17
20
|
requestIsHttps,
|
|
@@ -22,11 +25,26 @@ import { resolveSharedCookieDomain } from "../../shared/cookie-domain.js";
|
|
|
22
25
|
/** @type {Map<string, { name: string, value: string, maxAge?: number, exp: number }>} */
|
|
23
26
|
const tickets = new Map();
|
|
24
27
|
|
|
28
|
+
/** IP → { count, windowStart } — basit mint rate limit. */
|
|
29
|
+
/** @type {Map<string, { count: number, windowStart: number }>} */
|
|
30
|
+
const mintWindows = new Map();
|
|
31
|
+
|
|
25
32
|
const DEFAULT_TTL_SECONDS = 60;
|
|
33
|
+
const DEFAULT_MAX_PENDING = 256;
|
|
34
|
+
const DEFAULT_MAX_MINTS_PER_IP = 30;
|
|
35
|
+
const MINT_WINDOW_MS = 60_000;
|
|
26
36
|
const HANDOFF_PATH = "/_jskelet/auth/handoff";
|
|
27
37
|
|
|
28
38
|
/**
|
|
29
|
-
* @returns {{
|
|
39
|
+
* @returns {{
|
|
40
|
+
* enabled: boolean,
|
|
41
|
+
* ttlSeconds: number,
|
|
42
|
+
* path: string,
|
|
43
|
+
* maxValueBytes: number,
|
|
44
|
+
* allowedCookieNames: string[],
|
|
45
|
+
* maxPendingTickets: number,
|
|
46
|
+
* maxMintsPerIpPerMinute: number,
|
|
47
|
+
* }}
|
|
30
48
|
*/
|
|
31
49
|
function settings() {
|
|
32
50
|
const auth = getConfig().auth ?? {};
|
|
@@ -37,21 +55,26 @@ function settings() {
|
|
|
37
55
|
ttlSeconds: DEFAULT_TTL_SECONDS,
|
|
38
56
|
path: HANDOFF_PATH,
|
|
39
57
|
maxValueBytes: SHARED_COOKIE_WARN_BYTES,
|
|
58
|
+
allowedCookieNames: [],
|
|
59
|
+
maxPendingTickets: DEFAULT_MAX_PENDING,
|
|
60
|
+
maxMintsPerIpPerMinute: DEFAULT_MAX_MINTS_PER_IP,
|
|
40
61
|
};
|
|
41
62
|
}
|
|
42
63
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
enabled: true,
|
|
46
|
-
ttlSeconds: DEFAULT_TTL_SECONDS,
|
|
47
|
-
path: HANDOFF_PATH,
|
|
48
|
-
maxValueBytes: SHARED_COOKIE_WARN_BYTES,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
64
|
+
/** @type {Record<string, unknown>} */
|
|
65
|
+
const source = raw === true ? {} : /** @type {Record<string, unknown>} */ (raw);
|
|
51
66
|
|
|
52
|
-
const source = /** @type {Record<string, unknown>} */ (raw);
|
|
53
67
|
const ttl = Number(source.ttlSeconds);
|
|
54
68
|
const maxBytes = Number(source.maxValueBytes);
|
|
69
|
+
const maxPending = Number(source.maxPendingTickets);
|
|
70
|
+
const maxMints = Number(source.maxMintsPerIpPerMinute);
|
|
71
|
+
|
|
72
|
+
const namesRaw = source.allowedCookieNames;
|
|
73
|
+
/** @type {string[]} */
|
|
74
|
+
const allowedCookieNames = Array.isArray(namesRaw)
|
|
75
|
+
? namesRaw.filter((n) => typeof n === "string" && isValidCookieName(n.trim())).map((n) => n.trim())
|
|
76
|
+
: [];
|
|
77
|
+
|
|
55
78
|
return {
|
|
56
79
|
enabled: source.enabled !== false,
|
|
57
80
|
ttlSeconds:
|
|
@@ -64,6 +87,15 @@ function settings() {
|
|
|
64
87
|
Number.isFinite(maxBytes) && maxBytes > 0
|
|
65
88
|
? Math.floor(maxBytes)
|
|
66
89
|
: SHARED_COOKIE_WARN_BYTES,
|
|
90
|
+
allowedCookieNames,
|
|
91
|
+
maxPendingTickets:
|
|
92
|
+
Number.isFinite(maxPending) && maxPending > 0
|
|
93
|
+
? Math.floor(maxPending)
|
|
94
|
+
: DEFAULT_MAX_PENDING,
|
|
95
|
+
maxMintsPerIpPerMinute:
|
|
96
|
+
Number.isFinite(maxMints) && maxMints > 0
|
|
97
|
+
? Math.floor(maxMints)
|
|
98
|
+
: DEFAULT_MAX_MINTS_PER_IP,
|
|
67
99
|
};
|
|
68
100
|
}
|
|
69
101
|
|
|
@@ -74,6 +106,24 @@ function sweep() {
|
|
|
74
106
|
}
|
|
75
107
|
}
|
|
76
108
|
|
|
109
|
+
/**
|
|
110
|
+
* @param {string} ip
|
|
111
|
+
* @param {number} limit
|
|
112
|
+
* @returns {boolean} true ise mint'e izin var
|
|
113
|
+
*/
|
|
114
|
+
function allowMint(ip, limit) {
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
const key = ip || "unknown";
|
|
117
|
+
const entry = mintWindows.get(key);
|
|
118
|
+
if (!entry || now - entry.windowStart >= MINT_WINDOW_MS) {
|
|
119
|
+
mintWindows.set(key, { count: 1, windowStart: now });
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
if (entry.count >= limit) return false;
|
|
123
|
+
entry.count += 1;
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
77
127
|
/**
|
|
78
128
|
* @param {string} next
|
|
79
129
|
* @param {import('express').Request} req
|
|
@@ -146,6 +196,7 @@ function redeem(ticketId) {
|
|
|
146
196
|
|
|
147
197
|
/**
|
|
148
198
|
* Handoff uçlarını mount eder. `auth.crossSubdomainHandoff` kapalıysa no-op.
|
|
199
|
+
* create-app CSRF'den *sonra* çağırmalıdır.
|
|
149
200
|
*
|
|
150
201
|
* @param {import('express').Express} app
|
|
151
202
|
* @returns {void}
|
|
@@ -154,9 +205,27 @@ export function mountAuthHandoff(app) {
|
|
|
154
205
|
const cfg = settings();
|
|
155
206
|
if (!cfg.enabled) return;
|
|
156
207
|
|
|
208
|
+
if (cfg.allowedCookieNames.length === 0) {
|
|
209
|
+
console.warn(
|
|
210
|
+
"[auth.handoff] enabled but allowedCookieNames is empty — " +
|
|
211
|
+
"POST mint will return 400 until you list cookie names in " +
|
|
212
|
+
"auth.crossSubdomainHandoff.allowedCookieNames",
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
157
216
|
app.post(cfg.path, (req, res) => {
|
|
158
217
|
sweep();
|
|
159
218
|
|
|
219
|
+
if (!allowMint(String(req.ip || ""), cfg.maxMintsPerIpPerMinute)) {
|
|
220
|
+
res.status(429).json({ ok: false, error: "too many handoff requests" });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (tickets.size >= cfg.maxPendingTickets) {
|
|
225
|
+
res.status(503).json({ ok: false, error: "handoff ticket store is full" });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
160
229
|
const name = typeof req.body?.name === "string" ? req.body.name.trim() : "";
|
|
161
230
|
const value = typeof req.body?.value === "string" ? req.body.value : "";
|
|
162
231
|
const next = typeof req.body?.next === "string" ? req.body.next.trim() : "";
|
|
@@ -170,6 +239,19 @@ export function mountAuthHandoff(app) {
|
|
|
170
239
|
return;
|
|
171
240
|
}
|
|
172
241
|
|
|
242
|
+
if (!isValidCookieName(name)) {
|
|
243
|
+
res.status(400).json({ ok: false, error: "invalid cookie name" });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (cfg.allowedCookieNames.length === 0 || !cfg.allowedCookieNames.includes(name)) {
|
|
248
|
+
res.status(400).json({
|
|
249
|
+
ok: false,
|
|
250
|
+
error: "cookie name is not in allowedCookieNames",
|
|
251
|
+
});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
173
255
|
if (Buffer.byteLength(value, "utf8") > cfg.maxValueBytes) {
|
|
174
256
|
res.status(400).json({
|
|
175
257
|
ok: false,
|
|
@@ -218,6 +300,7 @@ export function mountAuthHandoff(app) {
|
|
|
218
300
|
/** @returns {void} */
|
|
219
301
|
export function _resetHandoffTickets() {
|
|
220
302
|
tickets.clear();
|
|
303
|
+
mintWindows.clear();
|
|
221
304
|
}
|
|
222
305
|
|
|
223
306
|
/** @returns {number} */
|
package/src/server/create-app.js
CHANGED
|
@@ -21,13 +21,12 @@
|
|
|
21
21
|
* body parser'dan önce, admin ile aynı katmanda.
|
|
22
22
|
* 5. body parser'lar — statikten sonra: görsel isteklerinde gövde ayrıştırma
|
|
23
23
|
* maliyeti ödenmesin.
|
|
24
|
-
* 5b.
|
|
25
|
-
* önce değil sonra: JSON gövde parser'ı hazır olsun. Redeem GET route
|
|
26
|
-
* render'ından önce olmalı.
|
|
27
|
-
* 6. csrf — body parser'lardan sonra olmalı: token form alanından okunuyor.
|
|
24
|
+
* 5b. csrf — body parser'lardan sonra: token form alanından okunuyor.
|
|
28
25
|
* Rewrite'lardan önce, çünkü kontrol istemcinin gördüğü yola bakar.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
26
|
+
* 5c. auth handoff (açıksa) — CSRF'den *sonra*: mint POST origin koruması
|
|
27
|
+
* görsün. Redeem GET güvenli metot; route render'ından önce kalır.
|
|
28
|
+
* 6. rewrites(afterFiles) — statik denendikten sonra, sayfalardan önce.
|
|
29
|
+
* 7. route'lar → 404 → hata yönetimi.
|
|
31
30
|
*/
|
|
32
31
|
import path from "node:path";
|
|
33
32
|
import process from "node:process";
|
|
@@ -51,6 +50,7 @@ import { configureUpstreamLimiter } from "./upstream-limiter.js";
|
|
|
51
50
|
import { connectRedis, disconnectRedis } from "./redis.js";
|
|
52
51
|
import { configureLogs, flushLogs, closeLogs } from "./logs/pipeline.js";
|
|
53
52
|
import { isNotFoundError, isRedirectError } from "../http/control-flow.js";
|
|
53
|
+
import { ensurePortFree } from "./port-guard.js";
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
56
|
* @param {{ root?: string, configFile?: string }} [options]
|
|
@@ -152,6 +152,8 @@ export async function createApp(options = {}) {
|
|
|
152
152
|
app.use(express.urlencoded({ extended: false, limit: "64kb" }));
|
|
153
153
|
app.use(express.json({ limit: "256kb" }));
|
|
154
154
|
|
|
155
|
+
app.use(csrf());
|
|
156
|
+
|
|
155
157
|
const handoff = config.auth?.crossSubdomainHandoff;
|
|
156
158
|
if (
|
|
157
159
|
handoff === true ||
|
|
@@ -163,8 +165,6 @@ export async function createApp(options = {}) {
|
|
|
163
165
|
mountAuthHandoff(app);
|
|
164
166
|
}
|
|
165
167
|
|
|
166
|
-
app.use(csrf());
|
|
167
|
-
|
|
168
168
|
app.use(configRewrites("afterFiles"));
|
|
169
169
|
|
|
170
170
|
await registerRoutes(app);
|
|
@@ -222,12 +222,21 @@ export async function createApp(options = {}) {
|
|
|
222
222
|
* Uygulamayı kurup dinlemeye başlar. CLI `jskelet start` bunu çağırır;
|
|
223
223
|
* gömülü kullanımda `createApp()` tercih edilir.
|
|
224
224
|
*
|
|
225
|
-
*
|
|
225
|
+
* Port doluysa başlamaz. `murder: true` veya argv'de `--murder` varsa
|
|
226
|
+
* dinleyen süreç öldürülüp bağlama denenir.
|
|
227
|
+
*
|
|
228
|
+
* @param {{ root?: string, configFile?: string, port?: number, host?: string, murder?: boolean }} [options]
|
|
226
229
|
* @returns {Promise<import('http').Server>}
|
|
227
230
|
*/
|
|
228
231
|
export async function startServer(options = {}) {
|
|
229
|
-
const app = await createApp(options);
|
|
230
232
|
const port = Number(options.port ?? process.env.PORT ?? 3000);
|
|
233
|
+
const murder =
|
|
234
|
+
options.murder === true || process.argv.includes("--murder");
|
|
235
|
+
|
|
236
|
+
// createApp pahalı; dolu portta uygulama kurmadan önce net hata ver.
|
|
237
|
+
await ensurePortFree(port, { murder });
|
|
238
|
+
|
|
239
|
+
const app = await createApp(options);
|
|
231
240
|
|
|
232
241
|
// Varsayılan `::`, `0.0.0.0` değil: ikisi de "tüm arayüzler" demek, ama
|
|
233
242
|
// yalnızca IPv6 soketi çift yığın çalışır ve `localhost`un `::1`e çözüldüğü
|
|
@@ -276,6 +285,15 @@ export async function startServer(options = {}) {
|
|
|
276
285
|
listen("0.0.0.0");
|
|
277
286
|
return;
|
|
278
287
|
}
|
|
288
|
+
if (error.code === "EADDRINUSE") {
|
|
289
|
+
const hint = murder
|
|
290
|
+
? `port ${port} is still in use after --murder`
|
|
291
|
+
: `port ${port} is already in use. Pass --murder to kill it and start, or set PORT to another value.`;
|
|
292
|
+
const wrapped = new Error(hint);
|
|
293
|
+
/** @type {NodeJS.ErrnoException} */ (wrapped).code = "EADDRINUSE";
|
|
294
|
+
reject(wrapped);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
279
297
|
reject(error);
|
|
280
298
|
});
|
|
281
299
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Legacy EJS şablon motoru — opsiyonel peer.
|
|
3
|
+
*
|
|
4
|
+
* `.jsk`-only uygulamalar `ejs` kurmadan çalışır. Bir `.ejs` view veya layout
|
|
5
|
+
* istendiğinde bu modül uygulamadan (yoksa framework'ten) `ejs` yükler;
|
|
6
|
+
* paket yoksa göç yolunu gösteren net bir hata verir.
|
|
7
|
+
*/
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import { pathToFileURL } from "node:url";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { getConfig, FRAMEWORK_ROOT } from "../config/index.js";
|
|
12
|
+
import { tryImportFromApp } from "../build/resolve-peer.mjs";
|
|
13
|
+
|
|
14
|
+
/** @type {import('ejs') | null | undefined} */
|
|
15
|
+
let cached;
|
|
16
|
+
|
|
17
|
+
const MISSING =
|
|
18
|
+
"EJS templates require the optional peer dependency `ejs`. " +
|
|
19
|
+
"Install it (`npm i ejs`) or migrate the template to `.jsk`.";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @returns {Promise<import('ejs')>}
|
|
23
|
+
*/
|
|
24
|
+
export async function loadEjs() {
|
|
25
|
+
if (cached) return cached;
|
|
26
|
+
if (cached === null) throw new Error(MISSING);
|
|
27
|
+
|
|
28
|
+
const config = getConfig();
|
|
29
|
+
let mod = await tryImportFromApp(config.root, "ejs");
|
|
30
|
+
|
|
31
|
+
// Framework kendi test/dev ortamında peer olarak tutuyor olabilir.
|
|
32
|
+
if (!mod) {
|
|
33
|
+
try {
|
|
34
|
+
const require = createRequire(path.join(FRAMEWORK_ROOT, "package.json"));
|
|
35
|
+
mod = await import(pathToFileURL(require.resolve("ejs")).href);
|
|
36
|
+
} catch {
|
|
37
|
+
mod = null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!mod) {
|
|
42
|
+
cached = null;
|
|
43
|
+
throw new Error(MISSING);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
cached = /** @type {import('ejs')} */ (mod.default ?? mod);
|
|
47
|
+
return cached;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} file
|
|
52
|
+
* @param {Record<string, unknown>} data
|
|
53
|
+
* @param {object} options
|
|
54
|
+
* @returns {Promise<string>}
|
|
55
|
+
*/
|
|
56
|
+
export async function renderEjsFile(file, data, options) {
|
|
57
|
+
const ejs = await loadEjs();
|
|
58
|
+
return ejs.renderFile(file, data, options);
|
|
59
|
+
}
|
|
@@ -8,8 +8,13 @@
|
|
|
8
8
|
*
|
|
9
9
|
* sharp yoksa 302 ile orijinale yönlendirilir — sayfa bozulmaz, tasarruf
|
|
10
10
|
* olmaz. Deployment notu: remote açıksa sharp runtime bağımlılığıdır.
|
|
11
|
+
*
|
|
12
|
+
* Fetch redirect'leri elle takip edilir: her hop allowlist + blocked-address
|
|
13
|
+
* (ve mümkünse DNS çözümü) ile yeniden doğrulanır — açık redirect SSRF'sini
|
|
14
|
+
* kapatmak için `redirect: "follow"` kullanılmaz.
|
|
11
15
|
*/
|
|
12
16
|
import crypto from "node:crypto";
|
|
17
|
+
import dns from "node:dns/promises";
|
|
13
18
|
import fs from "node:fs";
|
|
14
19
|
import path from "node:path";
|
|
15
20
|
import process from "node:process";
|
|
@@ -22,6 +27,8 @@ let sharpModule;
|
|
|
22
27
|
/** @type {string | null} */
|
|
23
28
|
let cacheDir = null;
|
|
24
29
|
|
|
30
|
+
const MAX_REDIRECTS = 5;
|
|
31
|
+
|
|
25
32
|
/**
|
|
26
33
|
* @returns {import('../config/index.js').ImagesRemoteConfig | null}
|
|
27
34
|
*/
|
|
@@ -49,13 +56,45 @@ export function parseAllowedRemoteUrl(src) {
|
|
|
49
56
|
return null;
|
|
50
57
|
}
|
|
51
58
|
|
|
52
|
-
if (url
|
|
53
|
-
if (!isHostAllowed(url.hostname, remote.allowHosts)) return null;
|
|
54
|
-
if (isBlockedAddress(url.hostname)) return null;
|
|
55
|
-
|
|
59
|
+
if (!isRemoteUrlShapeAllowed(url, remote.allowHosts)) return null;
|
|
56
60
|
return url;
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Host allowlist + literal private IP; redirect hop'larında da kullanılır.
|
|
65
|
+
*
|
|
66
|
+
* @param {URL} url
|
|
67
|
+
* @param {string[]} allowHosts
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
export function isRemoteUrlShapeAllowed(url, allowHosts) {
|
|
71
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
72
|
+
if (!isHostAllowed(url.hostname, allowHosts)) return false;
|
|
73
|
+
if (isBlockedAddress(url.hostname)) return false;
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Hostname'i çözümleyip private IP'ye düşüyorsa reddet (DNS rebinding
|
|
79
|
+
* savunması; TOCTOU kalır ama check-time private resolve yakalanır).
|
|
80
|
+
*
|
|
81
|
+
* @param {string} hostname
|
|
82
|
+
* @returns {Promise<boolean>} true = güvenli
|
|
83
|
+
*/
|
|
84
|
+
export async function assertResolvedHostSafe(hostname) {
|
|
85
|
+
if (isBlockedAddress(hostname)) return false;
|
|
86
|
+
try {
|
|
87
|
+
const results = await dns.lookup(hostname, { all: true, verbatim: true });
|
|
88
|
+
if (!results.length) return false;
|
|
89
|
+
for (const { address } of results) {
|
|
90
|
+
if (isBlockedAddress(address)) return false;
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
59
98
|
/**
|
|
60
99
|
* @param {string} hostname
|
|
61
100
|
* @param {string[]} allowHosts
|
|
@@ -257,6 +296,8 @@ async function handleOptimize(req, res, remote) {
|
|
|
257
296
|
}
|
|
258
297
|
|
|
259
298
|
/**
|
|
299
|
+
* Redirect'leri elle takip eder; her hop allowlist + DNS private kontrolünden geçer.
|
|
300
|
+
*
|
|
260
301
|
* @param {string} href
|
|
261
302
|
* @param {import('../config/index.js').ImagesRemoteConfig} remote
|
|
262
303
|
* @returns {Promise<{ ok: true, buffer: Buffer } | { ok: false }>}
|
|
@@ -266,30 +307,57 @@ async function fetchUpstream(href, remote) {
|
|
|
266
307
|
const timer = setTimeout(() => controller.abort(), remote.fetchTimeoutMs);
|
|
267
308
|
|
|
268
309
|
try {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
310
|
+
let current = href;
|
|
311
|
+
|
|
312
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
313
|
+
let url;
|
|
314
|
+
try {
|
|
315
|
+
url = new URL(current);
|
|
316
|
+
} catch {
|
|
317
|
+
return { ok: false };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (!isRemoteUrlShapeAllowed(url, remote.allowHosts)) return { ok: false };
|
|
321
|
+
if (!(await assertResolvedHostSafe(url.hostname))) return { ok: false };
|
|
322
|
+
|
|
323
|
+
const response = await fetch(url.href, {
|
|
324
|
+
signal: controller.signal,
|
|
325
|
+
redirect: "manual",
|
|
326
|
+
headers: {
|
|
327
|
+
// Bazı CDN'ler bot UA reddeder; tarayıcıya yakın tut.
|
|
328
|
+
Accept: "image/avif,image/webp,image/*,*/*;q=0.8",
|
|
329
|
+
"User-Agent": "jskelet-image-optimizer/1",
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
if (response.status >= 300 && response.status < 400) {
|
|
334
|
+
const location = response.headers.get("location");
|
|
335
|
+
if (!location) return { ok: false };
|
|
336
|
+
try {
|
|
337
|
+
current = new URL(location, url).href;
|
|
338
|
+
} catch {
|
|
339
|
+
return { ok: false };
|
|
340
|
+
}
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!response.ok) return { ok: false };
|
|
345
|
+
|
|
346
|
+
const type = response.headers.get("content-type") ?? "";
|
|
347
|
+
if (type && !type.startsWith("image/") && !type.includes("octet-stream")) {
|
|
348
|
+
return { ok: false };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const length = Number(response.headers.get("content-length") ?? 0);
|
|
352
|
+
if (length > remote.maxBytes) return { ok: false };
|
|
353
|
+
|
|
354
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
355
|
+
if (buffer.byteLength > remote.maxBytes) return { ok: false };
|
|
356
|
+
|
|
357
|
+
return { ok: true, buffer };
|
|
284
358
|
}
|
|
285
359
|
|
|
286
|
-
|
|
287
|
-
if (length > remote.maxBytes) return { ok: false };
|
|
288
|
-
|
|
289
|
-
const buffer = Buffer.from(await response.arrayBuffer());
|
|
290
|
-
if (buffer.byteLength > remote.maxBytes) return { ok: false };
|
|
291
|
-
|
|
292
|
-
return { ok: true, buffer };
|
|
360
|
+
return { ok: false };
|
|
293
361
|
} catch {
|
|
294
362
|
return { ok: false };
|
|
295
363
|
} finally {
|