jskelet 0.2.1 → 0.2.2
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 +54 -0
- package/docs/06-cache.md +195 -4
- package/docs/07-yapilandirma.md +70 -2
- package/docs/en/06-caching.md +202 -5
- package/docs/en/07-configuration.md +72 -2
- package/package.json +1 -1
- package/src/client/cache-panel/login.html +63 -0
- package/src/client/cache-panel/panel.css +448 -0
- package/src/client/cache-panel/panel.html +133 -0
- package/src/client/cache-panel/panel.js +339 -0
- package/src/client/devtools/overlay.js +85 -0
- package/src/client/devtools/report.js +13 -0
- package/src/config/defaults.js +67 -2
- package/src/config/index.js +98 -1
- package/src/index.js +5 -0
- package/src/server/cache-panel.js +505 -0
- package/src/server/create-app.js +16 -0
- package/src/server/data-cache.js +92 -2
- package/src/server/dev/report.js +8 -1
- package/src/server/html-cache.js +36 -0
- package/src/server/prewarm.js +94 -15
- package/src/server/upstream-limiter.js +376 -0
- package/src/server/upstream-tracking.js +25 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Önbellek yönetim paneli: bellek içi kademenin (L1) ve paylaşımlı Redis
|
|
3
|
+
* kademesinin (L2) durumunu gösterir, hedefli invalidation ve ısıtma
|
|
4
|
+
* tetikler.
|
|
5
|
+
*
|
|
6
|
+
* Neden dev araçlarından ayrı: dev overlay yalnızca `NODE_ENV=development`
|
|
7
|
+
* iken mount ediliyor ve asıl soru ("şu sayfa neden bayat", "webhook purge'ü
|
|
8
|
+
* geçti mi") üretimde soruluyor. Bu yüzden panel ortama bakmaz, **config'e**
|
|
9
|
+
* bakar: `cache().panel.enabled` ya da `JSKELET_CACHE_PANEL` verilmedikçe
|
|
10
|
+
* router hiç mount edilmez — kapalıyken yolun kendisi de yoktur.
|
|
11
|
+
*
|
|
12
|
+
* ## Erişim
|
|
13
|
+
*
|
|
14
|
+
* Şifre her süreç başlangıcında yeniden üretilir ve **yalnızca sunucu
|
|
15
|
+
* logunda** görünür. Kalıcı bir sır (config alanı, env) tutmamanın iki
|
|
16
|
+
* sebebi var: sızan bir sır önbelleği boşaltma yetkisi demek, ve her deploy
|
|
17
|
+
* eski erişimi kendiliğinden iptal etmeli. Şifre query string ile de kabul
|
|
18
|
+
* edilmez; erişim logları ve `Referer` başlığı sırrı taşımasın.
|
|
19
|
+
*
|
|
20
|
+
* Başarısız denemeler IP başına sayılır ve sınır aşıldığında IP 24 saat
|
|
21
|
+
* yasaklanır. Yasaklı ve yetkisiz her cevap **404**: 401/403 panelin var
|
|
22
|
+
* olduğunu doğrular, 404 hiç yokmuş gibi davranır.
|
|
23
|
+
*
|
|
24
|
+
* Sayaç ve yasaklar süreç belleğinde durur. Diske yazmak, şifresi zaten her
|
|
25
|
+
* restart'ta değişen bir panel için yanlış takas olurdu.
|
|
26
|
+
*/
|
|
27
|
+
import crypto from "node:crypto";
|
|
28
|
+
import fs from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import process from "node:process";
|
|
31
|
+
import express from "express";
|
|
32
|
+
import { FRAMEWORK_ROOT, getConfig } from "../config/index.js";
|
|
33
|
+
import { safeEqual } from "../http/cookies.js";
|
|
34
|
+
import {
|
|
35
|
+
clearHtmlCache,
|
|
36
|
+
dropHtmlCacheKey,
|
|
37
|
+
getHtmlCacheEntries,
|
|
38
|
+
getHtmlCacheSize,
|
|
39
|
+
invalidateHtmlCache,
|
|
40
|
+
} from "./html-cache.js";
|
|
41
|
+
import {
|
|
42
|
+
clearDataCache,
|
|
43
|
+
dropDataCacheKey,
|
|
44
|
+
getDataCacheEntries,
|
|
45
|
+
getDataCacheSize,
|
|
46
|
+
} from "./data-cache.js";
|
|
47
|
+
import { getRedisStatus, redisDropMatching } from "./redis.js";
|
|
48
|
+
import { getUpstreamLimiterStatus } from "./upstream-limiter.js";
|
|
49
|
+
import { prewarm, prewarmProgress } from "./prewarm.js";
|
|
50
|
+
|
|
51
|
+
/** Panel dosyaları framework paketinden servis edilir, uygulamadan değil. */
|
|
52
|
+
const PANEL_DIR = path.join(FRAMEWORK_ROOT, "src", "client", "cache-panel");
|
|
53
|
+
|
|
54
|
+
const COOKIE_NAME = "jskelet_cache_sid";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Aksiyon isteklerinde beklenen başlık. Tarayıcı bu başlığı çapraz site bir
|
|
58
|
+
* formla gönderemez (preflight gerekir), yani panelin kendi CSRF freni.
|
|
59
|
+
* Middleware sırasındaki `csrf()`den bağımsız olması gerekiyor: panel body
|
|
60
|
+
* parser'lardan önce mount ediliyor ve kendi gövdesini kendisi ayrıştırıyor.
|
|
61
|
+
*/
|
|
62
|
+
const ACTION_HEADER = "x-jskelet-cache-panel";
|
|
63
|
+
|
|
64
|
+
/** Listelerin üst sınırı: veri önbelleğinde on binlerce anahtar olabiliyor. */
|
|
65
|
+
const MAX_LISTED = 500;
|
|
66
|
+
|
|
67
|
+
const BOOT_ID = `${Date.now()}-${process.pid}`;
|
|
68
|
+
|
|
69
|
+
/** 32 haneli, süreç ömrü kadar geçerli şifre. */
|
|
70
|
+
const PASSWORD = crypto.randomBytes(16).toString("hex");
|
|
71
|
+
|
|
72
|
+
/** @type {Map<string, number>} token → son kullanma zamanı. */
|
|
73
|
+
const sessions = new Map();
|
|
74
|
+
|
|
75
|
+
/** @type {Map<string, { fails: number, bannedUntil: number }>} */
|
|
76
|
+
const offenders = new Map();
|
|
77
|
+
|
|
78
|
+
/** @type {typeof import('../config/defaults.js').DEFAULT_CACHE_PANEL} */
|
|
79
|
+
let settings;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {import('express').Request} req
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
function clientKey(req) {
|
|
86
|
+
// `trust proxy` ayarı `req.ip`e yansıyor; ters proxy arkasında doğru IP,
|
|
87
|
+
// doğrudan internete açık bir sunucuda soket adresi kullanılır.
|
|
88
|
+
return req.ip ?? req.socket.remoteAddress ?? "unknown";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {import('express').Request} req
|
|
93
|
+
* @returns {boolean}
|
|
94
|
+
*/
|
|
95
|
+
function banned(req) {
|
|
96
|
+
const record = offenders.get(clientKey(req));
|
|
97
|
+
if (!record) return false;
|
|
98
|
+
|
|
99
|
+
if (record.bannedUntil && Date.now() >= record.bannedUntil) {
|
|
100
|
+
offenders.delete(clientKey(req));
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return record.bannedUntil > 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Başarısız denemeyi işler. Sınır aşılırsa IP yasaklanır ve bu tek satır
|
|
109
|
+
* loglanır: paneli internete açmış bir kurulumda saldırı denemesinin görünür
|
|
110
|
+
* olması gerekiyor.
|
|
111
|
+
*
|
|
112
|
+
* @param {import('express').Request} req
|
|
113
|
+
* @param {string} reason
|
|
114
|
+
*/
|
|
115
|
+
function noteFailure(req, reason) {
|
|
116
|
+
const key = clientKey(req);
|
|
117
|
+
const record = offenders.get(key) ?? { fails: 0, bannedUntil: 0 };
|
|
118
|
+
record.fails += 1;
|
|
119
|
+
|
|
120
|
+
if (record.fails >= settings.banAttempts) {
|
|
121
|
+
record.bannedUntil = Date.now() + settings.banHours * 3600_000;
|
|
122
|
+
console.warn(
|
|
123
|
+
`[cache-panel] ${key} banned for ${settings.banHours}h after ` +
|
|
124
|
+
`${record.fails} failed attempts (${reason})`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
offenders.set(key, record);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {import('express').Request} req
|
|
133
|
+
* @returns {boolean}
|
|
134
|
+
*/
|
|
135
|
+
function authenticated(req) {
|
|
136
|
+
const token = readCookie(req, COOKIE_NAME);
|
|
137
|
+
if (!token) return false;
|
|
138
|
+
|
|
139
|
+
const expiresAt = sessions.get(token);
|
|
140
|
+
if (expiresAt === undefined) return false;
|
|
141
|
+
|
|
142
|
+
if (Date.now() >= expiresAt) {
|
|
143
|
+
sessions.delete(token);
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Cookie okuma burada elle yapılıyor: `parseCookies()` isteği "kişiye bağlı"
|
|
152
|
+
* işaretliyor ve o işaret render yolundaki önbellek kararı için — panelin
|
|
153
|
+
* kendi isteklerinde anlamı yok.
|
|
154
|
+
*
|
|
155
|
+
* @param {import('express').Request} req
|
|
156
|
+
* @param {string} name
|
|
157
|
+
* @returns {string | null}
|
|
158
|
+
*/
|
|
159
|
+
function readCookie(req, name) {
|
|
160
|
+
const header = req.headers.cookie;
|
|
161
|
+
if (!header) return null;
|
|
162
|
+
|
|
163
|
+
for (const part of header.split(";")) {
|
|
164
|
+
const eq = part.indexOf("=");
|
|
165
|
+
if (eq === -1) continue;
|
|
166
|
+
if (part.slice(0, eq).trim() !== name) continue;
|
|
167
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Süresi geçmiş oturumları eler. Panel uzun süre açık kalabiliyor; harita
|
|
175
|
+
* sessizce büyümesin.
|
|
176
|
+
*/
|
|
177
|
+
function pruneSessions() {
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
for (const [token, expiresAt] of sessions) {
|
|
180
|
+
if (now >= expiresAt) sessions.delete(token);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param {import('express').Response} res
|
|
186
|
+
*/
|
|
187
|
+
function notFound(res) {
|
|
188
|
+
res.status(404).type("text/plain").send("Not Found");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* ------------------------------------------------------------------ döküm */
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Panelin gösterdiği her şey tek pakette.
|
|
195
|
+
*
|
|
196
|
+
* HTML gövdesi ve veri değerleri **dönmez**: panelin işi durumu göstermek,
|
|
197
|
+
* içeriği dışa vermek değil. Listeler `MAX_LISTED` ile kesilir ve arama
|
|
198
|
+
* sunucuda uygulanır — on binlik bir anahtar listesini tarayıcıya
|
|
199
|
+
* göndermenin faydası yok.
|
|
200
|
+
*
|
|
201
|
+
* @param {string} query Anahtar filtresi (boş → filtre yok).
|
|
202
|
+
* @returns {object}
|
|
203
|
+
*/
|
|
204
|
+
function snapshot(query) {
|
|
205
|
+
const term = query.trim().toLowerCase();
|
|
206
|
+
/** @param {{ key: string }} entry */
|
|
207
|
+
const matches = (entry) => !term || entry.key.toLowerCase().includes(term);
|
|
208
|
+
|
|
209
|
+
const html = getHtmlCacheEntries();
|
|
210
|
+
const data = getDataCacheEntries();
|
|
211
|
+
|
|
212
|
+
const htmlMatched = html.filter(matches);
|
|
213
|
+
const dataMatched = data.filter(matches);
|
|
214
|
+
const usage = process.memoryUsage();
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
boot: BOOT_ID,
|
|
218
|
+
generatedAt: Date.now(),
|
|
219
|
+
process: {
|
|
220
|
+
pid: process.pid,
|
|
221
|
+
node: process.version,
|
|
222
|
+
uptime: process.uptime(),
|
|
223
|
+
env: process.env.NODE_ENV ?? "production",
|
|
224
|
+
memory: { rss: usage.rss, heapUsed: usage.heapUsed },
|
|
225
|
+
},
|
|
226
|
+
html: {
|
|
227
|
+
size: getHtmlCacheSize(),
|
|
228
|
+
maxEntries: getConfig().htmlMaxEntries,
|
|
229
|
+
bytes: html.reduce((total, entry) => total + entry.bytes, 0),
|
|
230
|
+
stale: html.filter((entry) => entry.stale).length,
|
|
231
|
+
matched: htmlMatched.length,
|
|
232
|
+
entries: htmlMatched
|
|
233
|
+
.sort((a, b) => b.bytes - a.bytes)
|
|
234
|
+
.slice(0, MAX_LISTED),
|
|
235
|
+
},
|
|
236
|
+
data: {
|
|
237
|
+
size: getDataCacheSize(),
|
|
238
|
+
maxEntries: Number(getConfig().data?.maxEntries) || 0,
|
|
239
|
+
stale: data.filter((entry) => entry.stale).length,
|
|
240
|
+
matched: dataMatched.length,
|
|
241
|
+
entries: dataMatched
|
|
242
|
+
.sort((a, b) => a.expiresIn - b.expiresIn)
|
|
243
|
+
.slice(0, MAX_LISTED),
|
|
244
|
+
},
|
|
245
|
+
redis: getRedisStatus(),
|
|
246
|
+
upstream: getUpstreamLimiterStatus(),
|
|
247
|
+
prewarm: { ...prewarmProgress },
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/* --------------------------------------------------------------- aksiyonlar */
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Panelden gelen işlemi uygular ve kullanıcıya gösterilecek özeti döner.
|
|
255
|
+
*
|
|
256
|
+
* Her işlem yerel kademeyi düşürüp paylaşımlı kademeye de yayılıyor (bkz.
|
|
257
|
+
* `html-cache.js`, `data-cache.js`): tek node'un önbelleğini boşaltmak,
|
|
258
|
+
* kümede çalışan bir kurulumda "temizledim ama hâlâ eski" sorusunu üretir.
|
|
259
|
+
*
|
|
260
|
+
* @param {Record<string, any>} body
|
|
261
|
+
* @param {import('express').Request} req
|
|
262
|
+
* @returns {Promise<{ ok: boolean, message: string }>}
|
|
263
|
+
*/
|
|
264
|
+
async function runAction(body, req) {
|
|
265
|
+
const type = String(body?.type ?? "");
|
|
266
|
+
|
|
267
|
+
switch (type) {
|
|
268
|
+
case "html:clear": {
|
|
269
|
+
const size = getHtmlCacheSize();
|
|
270
|
+
clearHtmlCache();
|
|
271
|
+
return { ok: true, message: `HTML cache cleared (${size} entries)` };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
case "data:clear": {
|
|
275
|
+
const prefix = typeof body.prefix === "string" && body.prefix ? body.prefix : undefined;
|
|
276
|
+
const removed = clearDataCache(prefix);
|
|
277
|
+
return {
|
|
278
|
+
ok: true,
|
|
279
|
+
message: prefix
|
|
280
|
+
? `${removed} data entries dropped under \`${prefix}\``
|
|
281
|
+
: `Data cache cleared (${removed} entries)`,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
case "html:invalidate": {
|
|
286
|
+
const target = String(body.target ?? "");
|
|
287
|
+
if (!target.startsWith("/")) {
|
|
288
|
+
return { ok: false, message: "Target must start with `/`" };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const hard = body.hard === true;
|
|
292
|
+
const count = invalidateHtmlCache(target, { hard });
|
|
293
|
+
return {
|
|
294
|
+
ok: true,
|
|
295
|
+
message: `${count} entries ${hard ? "dropped" : "marked stale"} for \`${target}\``,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
case "html:drop": {
|
|
300
|
+
const key = String(body.key ?? "");
|
|
301
|
+
if (!key) return { ok: false, message: "Missing key" };
|
|
302
|
+
const existed = dropHtmlCacheKey(key);
|
|
303
|
+
return {
|
|
304
|
+
ok: true,
|
|
305
|
+
message: existed ? `Dropped \`${key}\`` : `\`${key}\` was not cached`,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
case "data:drop": {
|
|
310
|
+
const key = String(body.key ?? "");
|
|
311
|
+
if (!key) return { ok: false, message: "Missing key" };
|
|
312
|
+
const existed = dropDataCacheKey(key);
|
|
313
|
+
return {
|
|
314
|
+
ok: true,
|
|
315
|
+
message: existed ? `Dropped \`${key}\`` : `\`${key}\` was not cached`,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
case "redis:drop": {
|
|
320
|
+
const kind = body.kind === "data" ? "data" : "html";
|
|
321
|
+
const status = getRedisStatus();
|
|
322
|
+
if (!status.connected) {
|
|
323
|
+
return { ok: false, message: "Redis is not connected" };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const dropped = await redisDropMatching(kind);
|
|
327
|
+
return { ok: true, message: `${dropped} shared ${kind} keys dropped` };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
case "prewarm": {
|
|
331
|
+
if (prewarmProgress.active) {
|
|
332
|
+
return { ok: false, message: "A prewarm round is already running" };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const requested = Array.isArray(body.paths) ? body.paths : [];
|
|
336
|
+
const paths = requested.filter(
|
|
337
|
+
(/** @type {unknown} */ value) =>
|
|
338
|
+
typeof value === "string" && value.startsWith("/"),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
// Isıtma beklenmez: yüzlerce yolu tarayan bir tur dakikalar sürebiliyor
|
|
342
|
+
// ve panel ilerlemeyi zaten dökümden okuyor.
|
|
343
|
+
prewarm({
|
|
344
|
+
origin: `${req.protocol}://${req.get("host")}`,
|
|
345
|
+
paths: paths.length ? paths : undefined,
|
|
346
|
+
}).catch((error) => {
|
|
347
|
+
console.error("[cache-panel] prewarm failed", error);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
ok: true,
|
|
352
|
+
message: paths.length ? `Prewarming ${paths.length} paths` : "Prewarming all paths",
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
default:
|
|
357
|
+
return { ok: false, message: `Unknown action: ${type}` };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/* ------------------------------------------------------------------ router */
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* @returns {import('express').Router}
|
|
365
|
+
*/
|
|
366
|
+
function router() {
|
|
367
|
+
const api = express.Router();
|
|
368
|
+
|
|
369
|
+
// Panelin hiçbir cevabı saklanmamalı ve hiçbir yeri indekslenmemeli:
|
|
370
|
+
// önbellek durumu saniyeler içinde değişiyor ve yol arama motorunda
|
|
371
|
+
// görünmemeli.
|
|
372
|
+
api.use((req, res, next) => {
|
|
373
|
+
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
|
|
374
|
+
res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive, nosnippet");
|
|
375
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
376
|
+
|
|
377
|
+
if (banned(req)) {
|
|
378
|
+
notFound(res);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
next();
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// Giriş sayfası. Yetkisiz GET burada **sayaca yazılmaz**: paneli açmak
|
|
386
|
+
// doğal ilk adım ve kullanıcı kendini üç sekme açmakla yasaklamamalı.
|
|
387
|
+
api.get("/", (req, res) => {
|
|
388
|
+
// Sayfa varlıklarını göreli yolla istiyor; sondaki `/` olmadan
|
|
389
|
+
// `panel.js` bir üst dizine düşer.
|
|
390
|
+
if (!req.originalUrl.split("?")[0].endsWith("/")) {
|
|
391
|
+
res.redirect(302, `${settings.basePath}/`);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
res.type("html");
|
|
396
|
+
sendFile(res, authenticated(req) ? "panel.html" : "login.html");
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// Stil giriş sayfasında da gerekiyor, yani oturumdan önce servis edilir.
|
|
400
|
+
// İçinde durum bilgisi yok.
|
|
401
|
+
api.get("/panel.css", (req, res) => {
|
|
402
|
+
res.type("text/css");
|
|
403
|
+
sendFile(res, "panel.css");
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
api.post("/login", express.json({ limit: "4kb" }), (req, res) => {
|
|
407
|
+
const password = typeof req.body?.password === "string" ? req.body.password : "";
|
|
408
|
+
|
|
409
|
+
if (!password || !safeEqual(password, PASSWORD)) {
|
|
410
|
+
noteFailure(req, password ? "wrong password" : "empty password");
|
|
411
|
+
// Kalan deneme sayısı bilinçli olarak bildirilmez.
|
|
412
|
+
res.status(401).json({ ok: false, message: "Invalid password" });
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
pruneSessions();
|
|
417
|
+
|
|
418
|
+
const token = crypto.randomBytes(32).toString("base64url");
|
|
419
|
+
const maxAge = Math.round(settings.sessionHours * 3600);
|
|
420
|
+
sessions.set(token, Date.now() + maxAge * 1000);
|
|
421
|
+
offenders.delete(clientKey(req));
|
|
422
|
+
|
|
423
|
+
// `SameSite=Strict`: panel hiçbir çapraz site gezinmesinde oturum
|
|
424
|
+
// taşımamalı. `Secure` üretimde açık, dev'de kapalı (http://localhost).
|
|
425
|
+
res.setHeader(
|
|
426
|
+
"Set-Cookie",
|
|
427
|
+
[
|
|
428
|
+
`${COOKIE_NAME}=${token}`,
|
|
429
|
+
`Path=${settings.basePath}`,
|
|
430
|
+
`Max-Age=${maxAge}`,
|
|
431
|
+
"HttpOnly",
|
|
432
|
+
"SameSite=Strict",
|
|
433
|
+
...(process.env.NODE_ENV === "development" ? [] : ["Secure"]),
|
|
434
|
+
].join("; "),
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
res.json({ ok: true });
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// Buradan sonrası oturum ister.
|
|
441
|
+
api.use((req, res, next) => {
|
|
442
|
+
if (authenticated(req)) return next();
|
|
443
|
+
|
|
444
|
+
noteFailure(req, `unauthenticated ${req.method} ${req.path}`);
|
|
445
|
+
notFound(res);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
api.post("/logout", (req, res) => {
|
|
449
|
+
const token = readCookie(req, COOKIE_NAME);
|
|
450
|
+
if (token) sessions.delete(token);
|
|
451
|
+
res.setHeader("Set-Cookie", `${COOKIE_NAME}=; Path=${settings.basePath}; Max-Age=0`);
|
|
452
|
+
res.json({ ok: true });
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
api.get("/panel.js", (req, res) => {
|
|
456
|
+
res.type("application/javascript");
|
|
457
|
+
sendFile(res, "panel.js");
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
api.get("/data", (req, res) => {
|
|
461
|
+
const query = typeof req.query.q === "string" ? req.query.q : "";
|
|
462
|
+
res.json(snapshot(query));
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
api.post("/action", express.json({ limit: "64kb" }), async (req, res) => {
|
|
466
|
+
if (req.get(ACTION_HEADER) !== "1") {
|
|
467
|
+
notFound(res);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
res.json(await runAction(req.body ?? {}, req));
|
|
473
|
+
} catch (error) {
|
|
474
|
+
console.error("[cache-panel] action failed", error);
|
|
475
|
+
res.status(500).json({ ok: false, message: "Action failed, see server logs" });
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
return api;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* @param {import('express').Response} res
|
|
484
|
+
* @param {string} name
|
|
485
|
+
*/
|
|
486
|
+
function sendFile(res, name) {
|
|
487
|
+
fs.createReadStream(path.join(PANEL_DIR, name)).pipe(res);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Paneli uygulamaya bağlar ve şifreyi loglar.
|
|
492
|
+
*
|
|
493
|
+
* @param {import('express').Express} app
|
|
494
|
+
*/
|
|
495
|
+
export function mountCachePanel(app) {
|
|
496
|
+
settings = getConfig().cachePanel;
|
|
497
|
+
|
|
498
|
+
app.use(settings.basePath, router());
|
|
499
|
+
|
|
500
|
+
const port = process.env.PORT ?? 3000;
|
|
501
|
+
console.log(
|
|
502
|
+
`[cache-panel] http://localhost:${port}${settings.basePath} — ` +
|
|
503
|
+
`password for this run: ${PASSWORD}`,
|
|
504
|
+
);
|
|
505
|
+
}
|
package/src/server/create-app.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* 4. staticPrecompressed → express.static — build'de üretilmiş `.br`/`.gz`
|
|
12
12
|
* kopyalar varsa onlar servis edilir (kalite 11), yoksa istek altındaki
|
|
13
13
|
* static'e düşer ve middleware anında sıkıştırır (kalite 5).
|
|
14
|
+
* 4b. cache paneli (açıksa) — statikten sonra, route'lardan önce: kendi
|
|
15
|
+
* gövde ayrıştırıcısını taşır ve uygulama yolunu gölgeleyemez.
|
|
14
16
|
* 5. body parser'lar — statikten sonra: görsel isteklerinde gövde ayrıştırma
|
|
15
17
|
* maliyeti ödenmesin.
|
|
16
18
|
* 6. csrf — body parser'lardan sonra olmalı: token form alanından okunuyor.
|
|
@@ -35,6 +37,7 @@ import { renderNotFound } from "./render.js";
|
|
|
35
37
|
import { renderStatusPage, statusFromError } from "./status-page.js";
|
|
36
38
|
import { isPrewarmRequest, notePrewarmError, startPrewarm } from "./prewarm.js";
|
|
37
39
|
import { trackUpstreamFetch } from "./upstream-tracking.js";
|
|
40
|
+
import { configureUpstreamLimiter } from "./upstream-limiter.js";
|
|
38
41
|
import { connectRedis, disconnectRedis } from "./redis.js";
|
|
39
42
|
import { isNotFoundError, isRedirectError } from "../http/control-flow.js";
|
|
40
43
|
|
|
@@ -52,6 +55,10 @@ export async function createApp(options = {}) {
|
|
|
52
55
|
// ilk kurulduğu istek de kapsanmalı.
|
|
53
56
|
if (config.trackUpstream) trackUpstreamFetch();
|
|
54
57
|
|
|
58
|
+
// Hız freni sarmalayıcının içinden okunuyor; ayarı ona vermek yeterli.
|
|
59
|
+
// `cache().upstream.rate` verilmedikçe hiçbir istek beklemez.
|
|
60
|
+
configureUpstreamLimiter(config.upstream);
|
|
61
|
+
|
|
55
62
|
// Önbelleğin ikinci kademesi route'lardan önce kurulmalı: ilk istek de
|
|
56
63
|
// paylaşımlı kopyayı görebilsin. Bağlanamazsa uyarı basılır ve uygulama
|
|
57
64
|
// bellek içi önbellekle çalışmaya devam eder — middleware sırasına
|
|
@@ -103,6 +110,15 @@ export async function createApp(options = {}) {
|
|
|
103
110
|
mountDevtools(app);
|
|
104
111
|
}
|
|
105
112
|
|
|
113
|
+
// Önbellek paneli ortama bakmaz, config'e bakar: açıkça etkinleştirilmedikçe
|
|
114
|
+
// modül hiç yüklenmez ve yol da yoktur. Kendi gövde ayrıştırıcısını
|
|
115
|
+
// taşıdığı için aşağıdaki parser'lardan önce durabiliyor; route'lardan
|
|
116
|
+
// önce olması gerekiyor ki uygulama aynı yolu gölgeleyemesin.
|
|
117
|
+
if (config.cachePanel.enabled) {
|
|
118
|
+
const { mountCachePanel } = await import("./cache-panel.js");
|
|
119
|
+
mountCachePanel(app);
|
|
120
|
+
}
|
|
121
|
+
|
|
106
122
|
app.use(express.urlencoded({ extended: false, limit: "64kb" }));
|
|
107
123
|
app.use(express.json({ limit: "256kb" }));
|
|
108
124
|
|
package/src/server/data-cache.js
CHANGED
|
@@ -47,6 +47,31 @@ const store = new Map();
|
|
|
47
47
|
/** @type {Map<string, Promise<unknown>>} */
|
|
48
48
|
const inflight = new Map();
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Süreç ömrü boyunca biriken sayaçlar.
|
|
52
|
+
*
|
|
53
|
+
* Isıtma turunun kotayı ne kadar harcadığı ancak buradan görülüyor: tur
|
|
54
|
+
* bittiğinde `produced` kaç gerçek upstream çağrısı yapıldığını, `hits` kaçının
|
|
55
|
+
* hiç gitmediğini söyler. Oran düşükse çözüm hız freni değil, TTL'i uzatmak —
|
|
56
|
+
* fren çağrıları yavaşlatır, sayısını azaltmaz.
|
|
57
|
+
*/
|
|
58
|
+
const stats = {
|
|
59
|
+
/** Taze girdiden servis edildi. */
|
|
60
|
+
hits: 0,
|
|
61
|
+
/** Bayat girdiden servis edildi; tazeleme arkada koştu. */
|
|
62
|
+
stale: 0,
|
|
63
|
+
/** Girdi yoktu, çağıran bekledi. */
|
|
64
|
+
misses: 0,
|
|
65
|
+
/** Aynı anahtarı eşzamanlı isteyen çağrılar tek üretime düştü. */
|
|
66
|
+
coalesced: 0,
|
|
67
|
+
/** Paylaşımlı kademeden geldi; upstream'e gitmedi. */
|
|
68
|
+
shared: 0,
|
|
69
|
+
/** `producer` gerçekten çalıştı — kotaya yazılan tek sayı. */
|
|
70
|
+
produced: 0,
|
|
71
|
+
/** `ttlSeconds: 0` ile önbellek tamamen atlandı. */
|
|
72
|
+
bypassed: 0,
|
|
73
|
+
};
|
|
74
|
+
|
|
50
75
|
/**
|
|
51
76
|
* Ayarlar config'ten okunur ama config yüklenmemiş olabilir: bu modül
|
|
52
77
|
* script'lerden ve testlerden de çağrılabiliyor. `getConfig()` fırlatırsa
|
|
@@ -171,7 +196,10 @@ function refresh(key, ttlSeconds, producer, options) {
|
|
|
171
196
|
// Aynı anahtarı eşzamanlı isteyen yüz sayfa tek upstream isteğine düşer.
|
|
172
197
|
// Isıtma turlarında bu tek başına kotanın büyük kısmını kurtarıyor.
|
|
173
198
|
const pending = inflight.get(key);
|
|
174
|
-
if (pending)
|
|
199
|
+
if (pending) {
|
|
200
|
+
stats.coalesced += 1;
|
|
201
|
+
return pending;
|
|
202
|
+
}
|
|
175
203
|
|
|
176
204
|
const staleFactor = options.staleFactor ?? settings().staleFactor;
|
|
177
205
|
|
|
@@ -196,10 +224,12 @@ async function produce(key, ttlSeconds, producer, options, staleFactor) {
|
|
|
196
224
|
// Kotayı koruyan `inflight` birleştirmesinin küme çapındaki karşılığı bu.
|
|
197
225
|
const shared = await readShared(key);
|
|
198
226
|
if (shared) {
|
|
227
|
+
stats.shared += 1;
|
|
199
228
|
promote(key, shared);
|
|
200
229
|
return shared.value;
|
|
201
230
|
}
|
|
202
231
|
|
|
232
|
+
stats.produced += 1;
|
|
203
233
|
const value = await producer();
|
|
204
234
|
|
|
205
235
|
const empty = value === undefined || value === null;
|
|
@@ -224,7 +254,10 @@ async function produce(key, ttlSeconds, producer, options, staleFactor) {
|
|
|
224
254
|
* @template T
|
|
225
255
|
*/
|
|
226
256
|
export async function withDataCache(key, ttlSeconds, producer, options = {}) {
|
|
227
|
-
if (!ttlSeconds)
|
|
257
|
+
if (!ttlSeconds) {
|
|
258
|
+
stats.bypassed += 1;
|
|
259
|
+
return producer();
|
|
260
|
+
}
|
|
228
261
|
|
|
229
262
|
// Bu anahtarı okuyan render, `clearDataCache(key)` çağrıldığında etkilenen
|
|
230
263
|
// sayfalar arasında sayılsın. Render bağlamı yoksa çağrı no-op.
|
|
@@ -233,6 +266,9 @@ export async function withDataCache(key, ttlSeconds, producer, options = {}) {
|
|
|
233
266
|
const hit = read(key);
|
|
234
267
|
|
|
235
268
|
if (hit) {
|
|
269
|
+
if (hit.stale) stats.stale += 1;
|
|
270
|
+
else stats.hits += 1;
|
|
271
|
+
|
|
236
272
|
// Bayat girdi anında döner; tazeleme arkada yürür ve hatası bu isteği
|
|
237
273
|
// etkilemez — çağıran taraf bir şey beklemediği için upstream'in yavaş
|
|
238
274
|
// olması sayfaya yansımaz.
|
|
@@ -244,6 +280,8 @@ export async function withDataCache(key, ttlSeconds, producer, options = {}) {
|
|
|
244
280
|
return /** @type {T} */ (hit.value);
|
|
245
281
|
}
|
|
246
282
|
|
|
283
|
+
stats.misses += 1;
|
|
284
|
+
|
|
247
285
|
try {
|
|
248
286
|
return /** @type {T} */ (await refresh(key, ttlSeconds, producer, options));
|
|
249
287
|
} catch (error) {
|
|
@@ -346,15 +384,67 @@ function clearLocal(prefix) {
|
|
|
346
384
|
// Uzak bir node veri düşürdüğünde bu proses de kendi L1'ini temizler; zincir
|
|
347
385
|
// `invalidateHtmlByDependency` üzerinden etkilenen sayfalara kadar gider.
|
|
348
386
|
onCacheEvent((event) => {
|
|
387
|
+
if (event.type === "data:drop") {
|
|
388
|
+
if (typeof event.key !== "string") return;
|
|
389
|
+
store.delete(event.key);
|
|
390
|
+
invalidateHtmlByDependency([event.key]);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
349
394
|
if (event.type !== "data:clear") return;
|
|
350
395
|
clearLocal(typeof event.prefix === "string" ? event.prefix : undefined);
|
|
351
396
|
});
|
|
352
397
|
|
|
398
|
+
/**
|
|
399
|
+
* Tek bir veri anahtarını düşürür.
|
|
400
|
+
*
|
|
401
|
+
* `clearDataCache()` **önek** eşleştiriyor: `quote:v2:AAPL` verildiğinde
|
|
402
|
+
* `quote:v2:AAPLX` de düşer. Yönetim panelinde listeden seçilen satır tam
|
|
403
|
+
* olarak o anahtar olmalı, komşusu değil.
|
|
404
|
+
*
|
|
405
|
+
* @param {string} key
|
|
406
|
+
* @returns {boolean} Girdi var mıydı.
|
|
407
|
+
*/
|
|
408
|
+
export function dropDataCacheKey(key) {
|
|
409
|
+
const existed = store.delete(key);
|
|
410
|
+
|
|
411
|
+
// Silme, girdi bu node'da olmasa da yayılır: anahtar başka bir node'da ya da
|
|
412
|
+
// yalnızca Redis'te sıcak olabilir.
|
|
413
|
+
invalidateHtmlByDependency([key]);
|
|
414
|
+
|
|
415
|
+
if (redisShares("data")) {
|
|
416
|
+
void redisDropMatching("data", (candidate) => candidate === key);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
publishCacheEvent({ type: "data:drop", key });
|
|
420
|
+
|
|
421
|
+
return existed;
|
|
422
|
+
}
|
|
423
|
+
|
|
353
424
|
/** @returns {number} */
|
|
354
425
|
export function getDataCacheSize() {
|
|
355
426
|
return store.size;
|
|
356
427
|
}
|
|
357
428
|
|
|
429
|
+
/**
|
|
430
|
+
* Süreç başından beri biriken sayaçlar. `produced` kotaya yazılan tek sayıdır:
|
|
431
|
+
* geri kalan her şey upstream'e hiç gitmemiş bir okuma.
|
|
432
|
+
*
|
|
433
|
+
* @returns {typeof stats & { reads: number, hitRatio: number }}
|
|
434
|
+
* `reads` önbellekten geçen toplam okuma, `hitRatio` bunların kaçının
|
|
435
|
+
* upstream'e gitmediği (0–1).
|
|
436
|
+
*/
|
|
437
|
+
export function getDataCacheStats() {
|
|
438
|
+
const reads = stats.hits + stats.stale + stats.misses;
|
|
439
|
+
const avoided = stats.hits + stats.stale;
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
...stats,
|
|
443
|
+
reads,
|
|
444
|
+
hitRatio: reads ? Number((avoided / reads).toFixed(3)) : 0,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
358
448
|
/**
|
|
359
449
|
* Dev raporu ve yönetim uçları için döküm. Değerin kendisi dönmez: JSON'un
|
|
360
450
|
* tamamını bir teşhis ucundan dışa vermek istenmez.
|