jskelet 0.2.3 → 0.2.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 +132 -132
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -21
- package/bin/jskelet.mjs +103 -103
- package/docs/01-baslangic.md +285 -285
- package/docs/02-mimari.md +287 -287
- package/docs/03-routing.md +480 -480
- package/docs/04-render-ve-sablonlar.md +490 -490
- package/docs/05-islands.md +482 -482
- package/docs/06-cache.md +1209 -1202
- package/docs/08-build.md +366 -366
- package/docs/09-dev-araclari.md +335 -335
- package/docs/10-dagitim.md +329 -329
- package/docs/12-panel-ve-oturum.md +384 -384
- package/docs/README.md +105 -105
- package/docs/en/01-getting-started.md +292 -292
- package/docs/en/02-architecture.md +305 -305
- package/docs/en/03-routing.md +497 -497
- package/docs/en/04-rendering.md +504 -504
- package/docs/en/05-islands.md +492 -492
- package/docs/en/06-caching.md +1239 -1232
- package/docs/en/07-configuration.md +986 -986
- package/docs/en/08-build.md +383 -383
- package/docs/en/09-dev-tools.md +342 -342
- package/docs/en/10-deployment.md +332 -332
- package/docs/en/11-migration.md +359 -359
- package/docs/en/12-dashboards-and-sessions.md +392 -392
- package/docs/en/README.md +112 -112
- package/package.json +102 -102
- package/src/build/ensure-build.mjs +15 -15
- package/src/build/paths.mjs +143 -143
- package/src/build/resolve-peer.mjs +36 -36
- package/src/build/tasks/client.mjs +268 -268
- package/src/build/tasks/css.mjs +124 -124
- package/src/build/tasks/fonts.mjs +146 -146
- package/src/build/tasks/icons.mjs +224 -224
- package/src/build/tasks/images.mjs +244 -244
- package/src/build/tasks/precompress.mjs +78 -78
- package/src/client/cache-panel/i18n.js +670 -0
- package/src/client/cache-panel/login.html +74 -71
- package/src/client/cache-panel/panel.css +756 -740
- package/src/client/cache-panel/panel.html +308 -307
- package/src/client/cache-panel/panel.js +915 -808
- package/src/client/devtools/report.html +185 -185
- package/src/client/devtools/report.js +725 -725
- package/src/client/dom.js +95 -95
- package/src/client/form.js +192 -192
- package/src/client/index.js +35 -35
- package/src/client/registry.js +297 -297
- package/src/client/safe-image.js +91 -91
- package/src/client/store.js +36 -36
- package/src/client/swap.js +188 -188
- package/src/config/pattern.js +107 -107
- package/src/http/control-flow.js +71 -71
- package/src/http/cookies.js +257 -257
- package/src/http/request-cache.js +46 -46
- package/src/http/request-context.js +162 -162
- package/src/index.js +83 -83
- package/src/init.mjs +221 -221
- package/src/runtime/alias-hooks.mjs +119 -119
- package/src/runtime/register.mjs +4 -4
- package/src/server/assets.js +147 -147
- package/src/server/cache-deps.js +42 -42
- package/src/server/cache-panel.js +759 -738
- package/src/server/cloudflare.js +607 -595
- package/src/server/create-app.js +291 -291
- package/src/server/data-cache.js +462 -462
- package/src/server/dev/report.js +369 -369
- package/src/server/dev/socket.js +170 -170
- package/src/server/dev/version-check.mjs +139 -139
- package/src/server/html-cache.js +817 -817
- 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 +62 -62
- package/src/server/middleware/headers.js +37 -37
- package/src/server/middleware/redirects.js +32 -32
- package/src/server/middleware/static-precompressed.js +100 -100
- package/src/server/middleware/upstream-proxy.js +141 -141
- package/src/server/prewarm.js +601 -601
- package/src/server/redis.js +569 -569
- package/src/server/router.js +128 -128
- package/src/server/status-page.js +164 -164
- package/src/server/upstream-limiter.js +376 -376
- package/src/server/upstream-tracking.js +166 -166
- package/src/start.mjs +7 -7
- package/src/templates/layout.ejs +44 -44
- package/src/version.mjs +31 -31
- package/src/views/components/loader.js +85 -85
- package/src/views/helpers/html.js +102 -102
- package/src/views/helpers/tags.js +245 -245
|
@@ -1,738 +1,759 @@
|
|
|
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 os from "node:os";
|
|
30
|
-
import path from "node:path";
|
|
31
|
-
import process from "node:process";
|
|
32
|
-
import express from "express";
|
|
33
|
-
import * as log from "../log.mjs";
|
|
34
|
-
import { FRAMEWORK_ROOT, getConfig } from "../config/index.js";
|
|
35
|
-
import {
|
|
36
|
-
FRAMEWORK_HOMEPAGE,
|
|
37
|
-
FRAMEWORK_LICENSE,
|
|
38
|
-
FRAMEWORK_NODE_RANGE,
|
|
39
|
-
FRAMEWORK_VERSION,
|
|
40
|
-
} from "../version.mjs";
|
|
41
|
-
import { safeEqual } from "../http/cookies.js";
|
|
42
|
-
import {
|
|
43
|
-
clearHtmlCache,
|
|
44
|
-
dropHtmlCacheKey,
|
|
45
|
-
getHtmlCacheEntries,
|
|
46
|
-
getHtmlCacheSize,
|
|
47
|
-
invalidateHtmlCache,
|
|
48
|
-
} from "./html-cache.js";
|
|
49
|
-
import {
|
|
50
|
-
clearDataCache,
|
|
51
|
-
dropDataCacheKey,
|
|
52
|
-
getDataCacheEntries,
|
|
53
|
-
getDataCacheSize,
|
|
54
|
-
} from "./data-cache.js";
|
|
55
|
-
import {
|
|
56
|
-
getRedisDetails,
|
|
57
|
-
getRedisStatus,
|
|
58
|
-
inspectRedis,
|
|
59
|
-
redisDropMatching,
|
|
60
|
-
} from "./redis.js";
|
|
61
|
-
import { getUpstreamLimiterStatus } from "./upstream-limiter.js";
|
|
62
|
-
import {
|
|
63
|
-
clearCloudflareCacheReserve,
|
|
64
|
-
cloudflareConfigured,
|
|
65
|
-
fetchCacheAnalytics,
|
|
66
|
-
fetchCloudflareOverview,
|
|
67
|
-
fetchPathEdges,
|
|
68
|
-
getCloudflareStatus,
|
|
69
|
-
purgeCloudflare,
|
|
70
|
-
setCloudflareFeature,
|
|
71
|
-
setCloudflareSetting,
|
|
72
|
-
toCloudflareUrls,
|
|
73
|
-
} from "./cloudflare.js";
|
|
74
|
-
import { prewarm, prewarmProgress } from "./prewarm.js";
|
|
75
|
-
|
|
76
|
-
/** Panel dosyaları framework paketinden servis edilir, uygulamadan değil. */
|
|
77
|
-
const PANEL_DIR = path.join(FRAMEWORK_ROOT, "src", "client", "cache-panel");
|
|
78
|
-
|
|
79
|
-
const COOKIE_NAME = "jskelet_cache_sid";
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Aksiyon isteklerinde beklenen başlık. Tarayıcı bu başlığı çapraz site bir
|
|
83
|
-
* formla gönderemez (preflight gerekir), yani panelin kendi CSRF freni.
|
|
84
|
-
* Middleware sırasındaki `csrf()`den bağımsız olması gerekiyor: panel body
|
|
85
|
-
* parser'lardan önce mount ediliyor ve kendi gövdesini kendisi ayrıştırıyor.
|
|
86
|
-
*/
|
|
87
|
-
const ACTION_HEADER = "x-jskelet-cache-panel";
|
|
88
|
-
|
|
89
|
-
/** Listelerin üst sınırı: veri önbelleğinde on binlerce anahtar olabiliyor. */
|
|
90
|
-
const MAX_LISTED = 500;
|
|
91
|
-
|
|
92
|
-
const BOOT_ID = `${Date.now()}-${process.pid}`;
|
|
93
|
-
|
|
94
|
-
/** 32 haneli, süreç ömrü kadar geçerli şifre. */
|
|
95
|
-
const PASSWORD = crypto.randomBytes(16).toString("hex");
|
|
96
|
-
|
|
97
|
-
/** @type {Map<string, number>} token → son kullanma zamanı. */
|
|
98
|
-
const sessions = new Map();
|
|
99
|
-
|
|
100
|
-
/** @type {Map<string, { fails: number, bannedUntil: number }>} */
|
|
101
|
-
const offenders = new Map();
|
|
102
|
-
|
|
103
|
-
/** @type {typeof import('../config/defaults.js').DEFAULT_CACHE_PANEL} */
|
|
104
|
-
let settings;
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* @param {import('express').Request} req
|
|
108
|
-
* @returns {string}
|
|
109
|
-
*/
|
|
110
|
-
function clientKey(req) {
|
|
111
|
-
// `trust proxy` ayarı `req.ip`e yansıyor; ters proxy arkasında doğru IP,
|
|
112
|
-
// doğrudan internete açık bir sunucuda soket adresi kullanılır.
|
|
113
|
-
return req.ip ?? req.socket.remoteAddress ?? "unknown";
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* @param {import('express').Request} req
|
|
118
|
-
* @returns {boolean}
|
|
119
|
-
*/
|
|
120
|
-
function banned(req) {
|
|
121
|
-
const record = offenders.get(clientKey(req));
|
|
122
|
-
if (!record) return false;
|
|
123
|
-
|
|
124
|
-
if (record.bannedUntil && Date.now() >= record.bannedUntil) {
|
|
125
|
-
offenders.delete(clientKey(req));
|
|
126
|
-
return false;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return record.bannedUntil > 0;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Başarısız denemeyi işler. Sınır aşılırsa IP yasaklanır ve bu tek satır
|
|
134
|
-
* loglanır: paneli internete açmış bir kurulumda saldırı denemesinin görünür
|
|
135
|
-
* olması gerekiyor.
|
|
136
|
-
*
|
|
137
|
-
* @param {import('express').Request} req
|
|
138
|
-
* @param {string} reason
|
|
139
|
-
*/
|
|
140
|
-
function noteFailure(req, reason) {
|
|
141
|
-
const key = clientKey(req);
|
|
142
|
-
const record = offenders.get(key) ?? { fails: 0, bannedUntil: 0 };
|
|
143
|
-
record.fails += 1;
|
|
144
|
-
|
|
145
|
-
if (record.fails >= settings.banAttempts) {
|
|
146
|
-
record.bannedUntil = Date.now() + settings.banHours * 3600_000;
|
|
147
|
-
console.warn(
|
|
148
|
-
`[cache-panel] ${key} banned for ${settings.banHours}h after ` +
|
|
149
|
-
`${record.fails} failed attempts (${reason})`,
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
offenders.set(key, record);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* @param {import('express').Request} req
|
|
158
|
-
* @returns {boolean}
|
|
159
|
-
*/
|
|
160
|
-
function authenticated(req) {
|
|
161
|
-
const token = readCookie(req, COOKIE_NAME);
|
|
162
|
-
if (!token) return false;
|
|
163
|
-
|
|
164
|
-
const expiresAt = sessions.get(token);
|
|
165
|
-
if (expiresAt === undefined) return false;
|
|
166
|
-
|
|
167
|
-
if (Date.now() >= expiresAt) {
|
|
168
|
-
sessions.delete(token);
|
|
169
|
-
return false;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return true;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Cookie okuma burada elle yapılıyor: `parseCookies()` isteği "kişiye bağlı"
|
|
177
|
-
* işaretliyor ve o işaret render yolundaki önbellek kararı için — panelin
|
|
178
|
-
* kendi isteklerinde anlamı yok.
|
|
179
|
-
*
|
|
180
|
-
* @param {import('express').Request} req
|
|
181
|
-
* @param {string} name
|
|
182
|
-
* @returns {string | null}
|
|
183
|
-
*/
|
|
184
|
-
function readCookie(req, name) {
|
|
185
|
-
const header = req.headers.cookie;
|
|
186
|
-
if (!header) return null;
|
|
187
|
-
|
|
188
|
-
for (const part of header.split(";")) {
|
|
189
|
-
const eq = part.indexOf("=");
|
|
190
|
-
if (eq === -1) continue;
|
|
191
|
-
if (part.slice(0, eq).trim() !== name) continue;
|
|
192
|
-
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
return null;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Süresi geçmiş oturumları eler. Panel uzun süre açık kalabiliyor; harita
|
|
200
|
-
* sessizce büyümesin.
|
|
201
|
-
*/
|
|
202
|
-
function pruneSessions() {
|
|
203
|
-
const now = Date.now();
|
|
204
|
-
for (const [token, expiresAt] of sessions) {
|
|
205
|
-
if (now >= expiresAt) sessions.delete(token);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* @param {import('express').Response} res
|
|
211
|
-
*/
|
|
212
|
-
function notFound(res) {
|
|
213
|
-
res.status(404).type("text/plain").send("Not Found");
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/* ------------------------------------------------------------------ döküm */
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Panelin gösterdiği her şey tek pakette.
|
|
220
|
-
*
|
|
221
|
-
* HTML gövdesi ve veri değerleri **dönmez**: panelin işi durumu göstermek,
|
|
222
|
-
* içeriği dışa vermek değil. Listeler `MAX_LISTED` ile kesilir ve arama
|
|
223
|
-
* sunucuda uygulanır — on binlik bir anahtar listesini tarayıcıya
|
|
224
|
-
* göndermenin faydası yok.
|
|
225
|
-
*
|
|
226
|
-
* @param {string} query Anahtar filtresi (boş → filtre yok).
|
|
227
|
-
* @returns {Promise<object>}
|
|
228
|
-
*/
|
|
229
|
-
async function snapshot(query) {
|
|
230
|
-
const term = query.trim().toLowerCase();
|
|
231
|
-
/** @param {{ key: string }} entry */
|
|
232
|
-
const matches = (entry) => !term || entry.key.toLowerCase().includes(term);
|
|
233
|
-
|
|
234
|
-
const html = getHtmlCacheEntries();
|
|
235
|
-
const data = getDataCacheEntries();
|
|
236
|
-
|
|
237
|
-
const htmlMatched = html.filter(matches);
|
|
238
|
-
const dataMatched = data.filter(matches);
|
|
239
|
-
const usage = process.memoryUsage();
|
|
240
|
-
|
|
241
|
-
return {
|
|
242
|
-
boot: BOOT_ID,
|
|
243
|
-
generatedAt: Date.now(),
|
|
244
|
-
release: {
|
|
245
|
-
version: FRAMEWORK_VERSION,
|
|
246
|
-
license: FRAMEWORK_LICENSE,
|
|
247
|
-
node: FRAMEWORK_NODE_RANGE,
|
|
248
|
-
homepage: FRAMEWORK_HOMEPAGE,
|
|
249
|
-
},
|
|
250
|
-
host: await hostStatus(),
|
|
251
|
-
process: {
|
|
252
|
-
pid: process.pid,
|
|
253
|
-
node: process.version,
|
|
254
|
-
uptime: process.uptime(),
|
|
255
|
-
env: process.env.NODE_ENV ?? "production",
|
|
256
|
-
memory: { rss: usage.rss, heapUsed: usage.heapUsed },
|
|
257
|
-
},
|
|
258
|
-
html: {
|
|
259
|
-
size: getHtmlCacheSize(),
|
|
260
|
-
maxEntries: getConfig().htmlMaxEntries,
|
|
261
|
-
bytes: html.reduce((total, entry) => total + entry.bytes, 0),
|
|
262
|
-
stale: html.filter((entry) => entry.stale).length,
|
|
263
|
-
matched: htmlMatched.length,
|
|
264
|
-
entries: htmlMatched
|
|
265
|
-
.sort((a, b) => b.bytes - a.bytes)
|
|
266
|
-
.slice(0, MAX_LISTED)
|
|
267
|
-
// Anahtar `yol?query`; query boşken sonda yalnız bir `?` kalıyor.
|
|
268
|
-
// Silme işlemleri gerçek anahtarı kullanmaya devam eder, panel
|
|
269
|
-
// ziyaret edilebilir yolu gösterir.
|
|
270
|
-
.map((entry) => ({
|
|
271
|
-
...entry,
|
|
272
|
-
url: entry.key.endsWith("?") ? entry.key.slice(0, -1) : entry.key,
|
|
273
|
-
})),
|
|
274
|
-
},
|
|
275
|
-
data: {
|
|
276
|
-
size: getDataCacheSize(),
|
|
277
|
-
maxEntries: Number(getConfig().data?.maxEntries) || 0,
|
|
278
|
-
stale: data.filter((entry) => entry.stale).length,
|
|
279
|
-
matched: dataMatched.length,
|
|
280
|
-
entries: dataMatched
|
|
281
|
-
.sort((a, b) => a.expiresIn - b.expiresIn)
|
|
282
|
-
.slice(0, MAX_LISTED),
|
|
283
|
-
},
|
|
284
|
-
redis: { ...getRedisStatus(), ...getRedisDetails() },
|
|
285
|
-
// Cloudflare yalnızca kurulum özeti: zone bilgisi ve analitik ağa çıkıyor
|
|
286
|
-
// ve döküm birkaç saniyede bir yenileniyor. Ayrıntı ayrı uçta.
|
|
287
|
-
cloudflare: getCloudflareStatus(),
|
|
288
|
-
upstream: getUpstreamLimiterStatus(),
|
|
289
|
-
prewarm: { ...prewarmProgress },
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* Makinenin RAM ve disk durumu.
|
|
295
|
-
*
|
|
296
|
-
* Paylaşımlı kademe kapalıyken önbelleğin tamamı bu sürecin belleğinde
|
|
297
|
-
* yaşıyor ve `maxEntries` ile RAM arasındaki ilişki panelde görünmediği sürece
|
|
298
|
-
* ayarlanamıyor: "500 girdi çok mu" sorusunun cevabı makinede ne kadar boş
|
|
299
|
-
* bellek kaldığına bağlı. Disk, HTML önbelleğinin kendisi için değil, ısıtma
|
|
300
|
-
* ve build çıktısının yaşadığı yer için: dolu bir diskte `jskelet build`
|
|
301
|
-
* sessizce hatalı manifest bırakıyor.
|
|
302
|
-
*
|
|
303
|
-
* `statfs` desteklenmeyen bir platform ya da erişilemeyen bir yol sadece
|
|
304
|
-
* `null` disk demek; panel yine açılır.
|
|
305
|
-
*
|
|
306
|
-
* @returns {Promise<object>}
|
|
307
|
-
*/
|
|
308
|
-
async function hostStatus() {
|
|
309
|
-
const total = os.totalmem();
|
|
310
|
-
const free = os.freemem();
|
|
311
|
-
|
|
312
|
-
/** @type {{ path: string, total: number, free: number } | null} */
|
|
313
|
-
let disk = null;
|
|
314
|
-
|
|
315
|
-
try {
|
|
316
|
-
const root = getConfig().root;
|
|
317
|
-
const stats = await fs.promises.statfs(root);
|
|
318
|
-
disk = {
|
|
319
|
-
path: root,
|
|
320
|
-
total: stats.blocks * stats.bsize,
|
|
321
|
-
// `bavail`, `bfree` değil: ayrılmış blokları boş göstermek yanıltıcı.
|
|
322
|
-
free: stats.bavail * stats.bsize,
|
|
323
|
-
};
|
|
324
|
-
} catch {
|
|
325
|
-
disk = null;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
return {
|
|
329
|
-
platform: `${os.platform()} ${os.arch()}`,
|
|
330
|
-
cpus: os.cpus().length,
|
|
331
|
-
// Windows'ta `loadavg()` her zaman sıfır döner; panel bunu gizler.
|
|
332
|
-
load: os.loadavg()[0],
|
|
333
|
-
memory: { total, free, used: total - free },
|
|
334
|
-
disk,
|
|
335
|
-
};
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/* --------------------------------------------------------------- aksiyonlar */
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* Panelden gelen işlemi uygular ve
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
case "
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
return {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
const result = await purgeCloudflare({ files: urls });
|
|
459
|
-
return
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
.
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
const
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
case "cf:clear-reserve": {
|
|
509
|
-
const result = await clearCloudflareCacheReserve();
|
|
510
|
-
return
|
|
511
|
-
ok:
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
};
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
res.type("
|
|
591
|
-
sendFile(res, "panel.
|
|
592
|
-
});
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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 os from "node:os";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import process from "node:process";
|
|
32
|
+
import express from "express";
|
|
33
|
+
import * as log from "../log.mjs";
|
|
34
|
+
import { FRAMEWORK_ROOT, getConfig } from "../config/index.js";
|
|
35
|
+
import {
|
|
36
|
+
FRAMEWORK_HOMEPAGE,
|
|
37
|
+
FRAMEWORK_LICENSE,
|
|
38
|
+
FRAMEWORK_NODE_RANGE,
|
|
39
|
+
FRAMEWORK_VERSION,
|
|
40
|
+
} from "../version.mjs";
|
|
41
|
+
import { safeEqual } from "../http/cookies.js";
|
|
42
|
+
import {
|
|
43
|
+
clearHtmlCache,
|
|
44
|
+
dropHtmlCacheKey,
|
|
45
|
+
getHtmlCacheEntries,
|
|
46
|
+
getHtmlCacheSize,
|
|
47
|
+
invalidateHtmlCache,
|
|
48
|
+
} from "./html-cache.js";
|
|
49
|
+
import {
|
|
50
|
+
clearDataCache,
|
|
51
|
+
dropDataCacheKey,
|
|
52
|
+
getDataCacheEntries,
|
|
53
|
+
getDataCacheSize,
|
|
54
|
+
} from "./data-cache.js";
|
|
55
|
+
import {
|
|
56
|
+
getRedisDetails,
|
|
57
|
+
getRedisStatus,
|
|
58
|
+
inspectRedis,
|
|
59
|
+
redisDropMatching,
|
|
60
|
+
} from "./redis.js";
|
|
61
|
+
import { getUpstreamLimiterStatus } from "./upstream-limiter.js";
|
|
62
|
+
import {
|
|
63
|
+
clearCloudflareCacheReserve,
|
|
64
|
+
cloudflareConfigured,
|
|
65
|
+
fetchCacheAnalytics,
|
|
66
|
+
fetchCloudflareOverview,
|
|
67
|
+
fetchPathEdges,
|
|
68
|
+
getCloudflareStatus,
|
|
69
|
+
purgeCloudflare,
|
|
70
|
+
setCloudflareFeature,
|
|
71
|
+
setCloudflareSetting,
|
|
72
|
+
toCloudflareUrls,
|
|
73
|
+
} from "./cloudflare.js";
|
|
74
|
+
import { prewarm, prewarmProgress } from "./prewarm.js";
|
|
75
|
+
|
|
76
|
+
/** Panel dosyaları framework paketinden servis edilir, uygulamadan değil. */
|
|
77
|
+
const PANEL_DIR = path.join(FRAMEWORK_ROOT, "src", "client", "cache-panel");
|
|
78
|
+
|
|
79
|
+
const COOKIE_NAME = "jskelet_cache_sid";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Aksiyon isteklerinde beklenen başlık. Tarayıcı bu başlığı çapraz site bir
|
|
83
|
+
* formla gönderemez (preflight gerekir), yani panelin kendi CSRF freni.
|
|
84
|
+
* Middleware sırasındaki `csrf()`den bağımsız olması gerekiyor: panel body
|
|
85
|
+
* parser'lardan önce mount ediliyor ve kendi gövdesini kendisi ayrıştırıyor.
|
|
86
|
+
*/
|
|
87
|
+
const ACTION_HEADER = "x-jskelet-cache-panel";
|
|
88
|
+
|
|
89
|
+
/** Listelerin üst sınırı: veri önbelleğinde on binlerce anahtar olabiliyor. */
|
|
90
|
+
const MAX_LISTED = 500;
|
|
91
|
+
|
|
92
|
+
const BOOT_ID = `${Date.now()}-${process.pid}`;
|
|
93
|
+
|
|
94
|
+
/** 32 haneli, süreç ömrü kadar geçerli şifre. */
|
|
95
|
+
const PASSWORD = crypto.randomBytes(16).toString("hex");
|
|
96
|
+
|
|
97
|
+
/** @type {Map<string, number>} token → son kullanma zamanı. */
|
|
98
|
+
const sessions = new Map();
|
|
99
|
+
|
|
100
|
+
/** @type {Map<string, { fails: number, bannedUntil: number }>} */
|
|
101
|
+
const offenders = new Map();
|
|
102
|
+
|
|
103
|
+
/** @type {typeof import('../config/defaults.js').DEFAULT_CACHE_PANEL} */
|
|
104
|
+
let settings;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {import('express').Request} req
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
function clientKey(req) {
|
|
111
|
+
// `trust proxy` ayarı `req.ip`e yansıyor; ters proxy arkasında doğru IP,
|
|
112
|
+
// doğrudan internete açık bir sunucuda soket adresi kullanılır.
|
|
113
|
+
return req.ip ?? req.socket.remoteAddress ?? "unknown";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {import('express').Request} req
|
|
118
|
+
* @returns {boolean}
|
|
119
|
+
*/
|
|
120
|
+
function banned(req) {
|
|
121
|
+
const record = offenders.get(clientKey(req));
|
|
122
|
+
if (!record) return false;
|
|
123
|
+
|
|
124
|
+
if (record.bannedUntil && Date.now() >= record.bannedUntil) {
|
|
125
|
+
offenders.delete(clientKey(req));
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return record.bannedUntil > 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Başarısız denemeyi işler. Sınır aşılırsa IP yasaklanır ve bu tek satır
|
|
134
|
+
* loglanır: paneli internete açmış bir kurulumda saldırı denemesinin görünür
|
|
135
|
+
* olması gerekiyor.
|
|
136
|
+
*
|
|
137
|
+
* @param {import('express').Request} req
|
|
138
|
+
* @param {string} reason
|
|
139
|
+
*/
|
|
140
|
+
function noteFailure(req, reason) {
|
|
141
|
+
const key = clientKey(req);
|
|
142
|
+
const record = offenders.get(key) ?? { fails: 0, bannedUntil: 0 };
|
|
143
|
+
record.fails += 1;
|
|
144
|
+
|
|
145
|
+
if (record.fails >= settings.banAttempts) {
|
|
146
|
+
record.bannedUntil = Date.now() + settings.banHours * 3600_000;
|
|
147
|
+
console.warn(
|
|
148
|
+
`[cache-panel] ${key} banned for ${settings.banHours}h after ` +
|
|
149
|
+
`${record.fails} failed attempts (${reason})`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
offenders.set(key, record);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @param {import('express').Request} req
|
|
158
|
+
* @returns {boolean}
|
|
159
|
+
*/
|
|
160
|
+
function authenticated(req) {
|
|
161
|
+
const token = readCookie(req, COOKIE_NAME);
|
|
162
|
+
if (!token) return false;
|
|
163
|
+
|
|
164
|
+
const expiresAt = sessions.get(token);
|
|
165
|
+
if (expiresAt === undefined) return false;
|
|
166
|
+
|
|
167
|
+
if (Date.now() >= expiresAt) {
|
|
168
|
+
sessions.delete(token);
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Cookie okuma burada elle yapılıyor: `parseCookies()` isteği "kişiye bağlı"
|
|
177
|
+
* işaretliyor ve o işaret render yolundaki önbellek kararı için — panelin
|
|
178
|
+
* kendi isteklerinde anlamı yok.
|
|
179
|
+
*
|
|
180
|
+
* @param {import('express').Request} req
|
|
181
|
+
* @param {string} name
|
|
182
|
+
* @returns {string | null}
|
|
183
|
+
*/
|
|
184
|
+
function readCookie(req, name) {
|
|
185
|
+
const header = req.headers.cookie;
|
|
186
|
+
if (!header) return null;
|
|
187
|
+
|
|
188
|
+
for (const part of header.split(";")) {
|
|
189
|
+
const eq = part.indexOf("=");
|
|
190
|
+
if (eq === -1) continue;
|
|
191
|
+
if (part.slice(0, eq).trim() !== name) continue;
|
|
192
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Süresi geçmiş oturumları eler. Panel uzun süre açık kalabiliyor; harita
|
|
200
|
+
* sessizce büyümesin.
|
|
201
|
+
*/
|
|
202
|
+
function pruneSessions() {
|
|
203
|
+
const now = Date.now();
|
|
204
|
+
for (const [token, expiresAt] of sessions) {
|
|
205
|
+
if (now >= expiresAt) sessions.delete(token);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* @param {import('express').Response} res
|
|
211
|
+
*/
|
|
212
|
+
function notFound(res) {
|
|
213
|
+
res.status(404).type("text/plain").send("Not Found");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/* ------------------------------------------------------------------ döküm */
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Panelin gösterdiği her şey tek pakette.
|
|
220
|
+
*
|
|
221
|
+
* HTML gövdesi ve veri değerleri **dönmez**: panelin işi durumu göstermek,
|
|
222
|
+
* içeriği dışa vermek değil. Listeler `MAX_LISTED` ile kesilir ve arama
|
|
223
|
+
* sunucuda uygulanır — on binlik bir anahtar listesini tarayıcıya
|
|
224
|
+
* göndermenin faydası yok.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} query Anahtar filtresi (boş → filtre yok).
|
|
227
|
+
* @returns {Promise<object>}
|
|
228
|
+
*/
|
|
229
|
+
async function snapshot(query) {
|
|
230
|
+
const term = query.trim().toLowerCase();
|
|
231
|
+
/** @param {{ key: string }} entry */
|
|
232
|
+
const matches = (entry) => !term || entry.key.toLowerCase().includes(term);
|
|
233
|
+
|
|
234
|
+
const html = getHtmlCacheEntries();
|
|
235
|
+
const data = getDataCacheEntries();
|
|
236
|
+
|
|
237
|
+
const htmlMatched = html.filter(matches);
|
|
238
|
+
const dataMatched = data.filter(matches);
|
|
239
|
+
const usage = process.memoryUsage();
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
boot: BOOT_ID,
|
|
243
|
+
generatedAt: Date.now(),
|
|
244
|
+
release: {
|
|
245
|
+
version: FRAMEWORK_VERSION,
|
|
246
|
+
license: FRAMEWORK_LICENSE,
|
|
247
|
+
node: FRAMEWORK_NODE_RANGE,
|
|
248
|
+
homepage: FRAMEWORK_HOMEPAGE,
|
|
249
|
+
},
|
|
250
|
+
host: await hostStatus(),
|
|
251
|
+
process: {
|
|
252
|
+
pid: process.pid,
|
|
253
|
+
node: process.version,
|
|
254
|
+
uptime: process.uptime(),
|
|
255
|
+
env: process.env.NODE_ENV ?? "production",
|
|
256
|
+
memory: { rss: usage.rss, heapUsed: usage.heapUsed },
|
|
257
|
+
},
|
|
258
|
+
html: {
|
|
259
|
+
size: getHtmlCacheSize(),
|
|
260
|
+
maxEntries: getConfig().htmlMaxEntries,
|
|
261
|
+
bytes: html.reduce((total, entry) => total + entry.bytes, 0),
|
|
262
|
+
stale: html.filter((entry) => entry.stale).length,
|
|
263
|
+
matched: htmlMatched.length,
|
|
264
|
+
entries: htmlMatched
|
|
265
|
+
.sort((a, b) => b.bytes - a.bytes)
|
|
266
|
+
.slice(0, MAX_LISTED)
|
|
267
|
+
// Anahtar `yol?query`; query boşken sonda yalnız bir `?` kalıyor.
|
|
268
|
+
// Silme işlemleri gerçek anahtarı kullanmaya devam eder, panel
|
|
269
|
+
// ziyaret edilebilir yolu gösterir.
|
|
270
|
+
.map((entry) => ({
|
|
271
|
+
...entry,
|
|
272
|
+
url: entry.key.endsWith("?") ? entry.key.slice(0, -1) : entry.key,
|
|
273
|
+
})),
|
|
274
|
+
},
|
|
275
|
+
data: {
|
|
276
|
+
size: getDataCacheSize(),
|
|
277
|
+
maxEntries: Number(getConfig().data?.maxEntries) || 0,
|
|
278
|
+
stale: data.filter((entry) => entry.stale).length,
|
|
279
|
+
matched: dataMatched.length,
|
|
280
|
+
entries: dataMatched
|
|
281
|
+
.sort((a, b) => a.expiresIn - b.expiresIn)
|
|
282
|
+
.slice(0, MAX_LISTED),
|
|
283
|
+
},
|
|
284
|
+
redis: { ...getRedisStatus(), ...getRedisDetails() },
|
|
285
|
+
// Cloudflare yalnızca kurulum özeti: zone bilgisi ve analitik ağa çıkıyor
|
|
286
|
+
// ve döküm birkaç saniyede bir yenileniyor. Ayrıntı ayrı uçta.
|
|
287
|
+
cloudflare: getCloudflareStatus(),
|
|
288
|
+
upstream: getUpstreamLimiterStatus(),
|
|
289
|
+
prewarm: { ...prewarmProgress },
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Makinenin RAM ve disk durumu.
|
|
295
|
+
*
|
|
296
|
+
* Paylaşımlı kademe kapalıyken önbelleğin tamamı bu sürecin belleğinde
|
|
297
|
+
* yaşıyor ve `maxEntries` ile RAM arasındaki ilişki panelde görünmediği sürece
|
|
298
|
+
* ayarlanamıyor: "500 girdi çok mu" sorusunun cevabı makinede ne kadar boş
|
|
299
|
+
* bellek kaldığına bağlı. Disk, HTML önbelleğinin kendisi için değil, ısıtma
|
|
300
|
+
* ve build çıktısının yaşadığı yer için: dolu bir diskte `jskelet build`
|
|
301
|
+
* sessizce hatalı manifest bırakıyor.
|
|
302
|
+
*
|
|
303
|
+
* `statfs` desteklenmeyen bir platform ya da erişilemeyen bir yol sadece
|
|
304
|
+
* `null` disk demek; panel yine açılır.
|
|
305
|
+
*
|
|
306
|
+
* @returns {Promise<object>}
|
|
307
|
+
*/
|
|
308
|
+
async function hostStatus() {
|
|
309
|
+
const total = os.totalmem();
|
|
310
|
+
const free = os.freemem();
|
|
311
|
+
|
|
312
|
+
/** @type {{ path: string, total: number, free: number } | null} */
|
|
313
|
+
let disk = null;
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
const root = getConfig().root;
|
|
317
|
+
const stats = await fs.promises.statfs(root);
|
|
318
|
+
disk = {
|
|
319
|
+
path: root,
|
|
320
|
+
total: stats.blocks * stats.bsize,
|
|
321
|
+
// `bavail`, `bfree` değil: ayrılmış blokları boş göstermek yanıltıcı.
|
|
322
|
+
free: stats.bavail * stats.bsize,
|
|
323
|
+
};
|
|
324
|
+
} catch {
|
|
325
|
+
disk = null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
platform: `${os.platform()} ${os.arch()}`,
|
|
330
|
+
cpus: os.cpus().length,
|
|
331
|
+
// Windows'ta `loadavg()` her zaman sıfır döner; panel bunu gizler.
|
|
332
|
+
load: os.loadavg()[0],
|
|
333
|
+
memory: { total, free, used: total - free },
|
|
334
|
+
disk,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/* --------------------------------------------------------------- aksiyonlar */
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Panelden gelen işlemi uygular ve sonucu **metin değil kod** olarak döner.
|
|
342
|
+
*
|
|
343
|
+
* Cümleyi panel kuruyor (`i18n.js`): panel iki dilde konuşuyor, sunucunun
|
|
344
|
+
* arayüz dilini bilmesi için bir sebep yok. `params` yer tutucuları besler,
|
|
345
|
+
* `parts` ise eksik kalabilen parçalı cevaplar için.
|
|
346
|
+
*
|
|
347
|
+
* Her işlem yerel kademeyi düşürüp paylaşımlı kademeye de yayılıyor (bkz.
|
|
348
|
+
* `html-cache.js`, `data-cache.js`): tek node'un önbelleğini boşaltmak,
|
|
349
|
+
* kümede çalışan bir kurulumda "temizledim ama hâlâ eski" sorusunu üretir.
|
|
350
|
+
*
|
|
351
|
+
* @param {Record<string, any>} body
|
|
352
|
+
* @param {import('express').Request} req
|
|
353
|
+
* @returns {Promise<{ ok: boolean, code?: string,
|
|
354
|
+
* params?: Record<string, string | number>,
|
|
355
|
+
* parts?: ({ code: string, params?: Record<string, string | number> } | null)[] }>}
|
|
356
|
+
*/
|
|
357
|
+
async function runAction(body, req) {
|
|
358
|
+
const type = String(body?.type ?? "");
|
|
359
|
+
|
|
360
|
+
switch (type) {
|
|
361
|
+
case "html:clear": {
|
|
362
|
+
const size = getHtmlCacheSize();
|
|
363
|
+
clearHtmlCache();
|
|
364
|
+
return { ok: true, code: "html.cleared", params: { count: size } };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
case "data:clear": {
|
|
368
|
+
const prefix = typeof body.prefix === "string" && body.prefix ? body.prefix : undefined;
|
|
369
|
+
const removed = clearDataCache(prefix);
|
|
370
|
+
return prefix
|
|
371
|
+
? { ok: true, code: "data.clearedPrefix", params: { count: removed, prefix } }
|
|
372
|
+
: { ok: true, code: "data.cleared", params: { count: removed } };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
case "html:invalidate": {
|
|
376
|
+
const target = String(body.target ?? "");
|
|
377
|
+
if (!target.startsWith("/")) {
|
|
378
|
+
return { ok: false, code: "target.invalid" };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const hard = body.hard === true;
|
|
382
|
+
const count = invalidateHtmlCache(target, { hard });
|
|
383
|
+
return {
|
|
384
|
+
ok: true,
|
|
385
|
+
code: hard ? "html.dropped" : "html.marked",
|
|
386
|
+
params: { count, target },
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
case "html:drop": {
|
|
391
|
+
const key = String(body.key ?? "");
|
|
392
|
+
if (!key) return { ok: false, code: "key.missing" };
|
|
393
|
+
const existed = dropHtmlCacheKey(key);
|
|
394
|
+
return { ok: true, code: existed ? "entry.dropped" : "entry.absent", params: { key } };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
case "data:drop": {
|
|
398
|
+
const key = String(body.key ?? "");
|
|
399
|
+
if (!key) return { ok: false, code: "key.missing" };
|
|
400
|
+
const existed = dropDataCacheKey(key);
|
|
401
|
+
return { ok: true, code: existed ? "entry.dropped" : "entry.absent", params: { key } };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
case "redis:inspect": {
|
|
405
|
+
const result = await inspectRedis();
|
|
406
|
+
if (!result.ok) return { ok: false, code: "redis.unreachable" };
|
|
407
|
+
|
|
408
|
+
// Sayım tek cümle değil parçalar hâlinde dönüyor: `DBSIZE` ve
|
|
409
|
+
// `used_memory` komut kısıtlı bir Redis'te okunamıyor ve eksik parçayı
|
|
410
|
+
// cümleden düşürme kararı panelin.
|
|
411
|
+
return {
|
|
412
|
+
ok: true,
|
|
413
|
+
parts: [
|
|
414
|
+
{ code: "redis.htmlKeys", params: { count: result.html } },
|
|
415
|
+
{ code: "redis.dataKeys", params: { count: result.data } },
|
|
416
|
+
result.totalKeys !== null
|
|
417
|
+
? { code: "redis.dbKeys", params: { count: result.totalKeys } }
|
|
418
|
+
: null,
|
|
419
|
+
result.usedMemory ? { code: "redis.memory", params: { value: result.usedMemory } } : null,
|
|
420
|
+
].filter(Boolean),
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
case "redis:drop": {
|
|
425
|
+
const kind = body.kind === "data" ? "data" : "html";
|
|
426
|
+
const status = getRedisStatus();
|
|
427
|
+
if (!status.connected) {
|
|
428
|
+
return { ok: false, code: "redis.notConnected" };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const dropped = await redisDropMatching(kind);
|
|
432
|
+
return {
|
|
433
|
+
ok: true,
|
|
434
|
+
code: kind === "data" ? "redis.droppedData" : "redis.droppedHtml",
|
|
435
|
+
params: { count: dropped },
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/* ------------------------------------------------------- cloudflare */
|
|
440
|
+
|
|
441
|
+
case "cf:purge-everything": {
|
|
442
|
+
const result = await purgeCloudflare({ everything: true });
|
|
443
|
+
return result.ok
|
|
444
|
+
? { ok: true, code: "cf.purgedEverything" }
|
|
445
|
+
: cloudflareFailure(result.error);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
case "cf:purge-urls": {
|
|
449
|
+
// Panelin elindeki HTML anahtarları yol; Cloudflare tam URL istiyor.
|
|
450
|
+
// Origin, isteğin geldiği host'tan türetilir ki tek zone'lu kurulumda
|
|
451
|
+
// ayrıca ayar gerekmesin.
|
|
452
|
+
const paths = Array.isArray(body.paths) ? body.paths.map(String) : [];
|
|
453
|
+
if (!paths.length) return { ok: false, code: "cf.noPaths" };
|
|
454
|
+
|
|
455
|
+
const urls = toCloudflareUrls(paths, originOf(req));
|
|
456
|
+
if (!urls.length) return { ok: false, code: "cf.noHostname" };
|
|
457
|
+
|
|
458
|
+
const result = await purgeCloudflare({ files: urls });
|
|
459
|
+
return result.ok
|
|
460
|
+
? {
|
|
461
|
+
ok: true,
|
|
462
|
+
code: "cf.purgedUrls",
|
|
463
|
+
params: { count: result.purged, batches: result.batches },
|
|
464
|
+
}
|
|
465
|
+
: cloudflareFailure(result.error);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
case "cf:purge-keys": {
|
|
469
|
+
/** @type {"prefixes" | "hosts" | "tags"} */
|
|
470
|
+
const kind =
|
|
471
|
+
body.kind === "hosts" ? "hosts" : body.kind === "tags" ? "tags" : "prefixes";
|
|
472
|
+
|
|
473
|
+
const values = String(body.values ?? "")
|
|
474
|
+
.split(/[\s,]+/)
|
|
475
|
+
.filter(Boolean);
|
|
476
|
+
|
|
477
|
+
if (!values.length) return { ok: false, code: "cf.nothingToPurge" };
|
|
478
|
+
|
|
479
|
+
const result = await purgeCloudflare({ [kind]: values });
|
|
480
|
+
return result.ok
|
|
481
|
+
? { ok: true, code: "cf.purgedKeys", params: { count: result.purged, kind } }
|
|
482
|
+
: cloudflareFailure(result.error);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
case "cf:setting": {
|
|
486
|
+
const result = await setCloudflareSetting(body.id, body.value);
|
|
487
|
+
return result.ok
|
|
488
|
+
? {
|
|
489
|
+
ok: true,
|
|
490
|
+
code: "cf.settingChanged",
|
|
491
|
+
params: { id: String(body.id), value: String(result.value) },
|
|
492
|
+
}
|
|
493
|
+
: cloudflareFailure(result.error);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
case "cf:feature": {
|
|
497
|
+
const value = body.value === "on" ? "on" : "off";
|
|
498
|
+
const result = await setCloudflareFeature(body.feature, value);
|
|
499
|
+
return result.ok
|
|
500
|
+
? {
|
|
501
|
+
ok: true,
|
|
502
|
+
code: "cf.featureChanged",
|
|
503
|
+
params: { feature: String(body.feature), value },
|
|
504
|
+
}
|
|
505
|
+
: cloudflareFailure(result.error);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
case "cf:clear-reserve": {
|
|
509
|
+
const result = await clearCloudflareCacheReserve();
|
|
510
|
+
return result.ok
|
|
511
|
+
? { ok: true, code: "cf.reserveClearing" }
|
|
512
|
+
: cloudflareFailure(result.error);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
case "prewarm": {
|
|
516
|
+
if (prewarmProgress.active) {
|
|
517
|
+
return { ok: false, code: "prewarm.busy" };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const requested = Array.isArray(body.paths) ? body.paths : [];
|
|
521
|
+
const paths = requested.filter(
|
|
522
|
+
(/** @type {unknown} */ value) =>
|
|
523
|
+
typeof value === "string" && value.startsWith("/"),
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
// Isıtma beklenmez: yüzlerce yolu tarayan bir tur dakikalar sürebiliyor
|
|
527
|
+
// ve panel ilerlemeyi zaten dökümden okuyor.
|
|
528
|
+
prewarm({
|
|
529
|
+
origin: `${req.protocol}://${req.get("host")}`,
|
|
530
|
+
paths: paths.length ? paths : undefined,
|
|
531
|
+
}).catch((error) => {
|
|
532
|
+
console.error("[cache-panel] prewarm failed", error);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
return paths.length
|
|
536
|
+
? { ok: true, code: "prewarm.paths", params: { count: paths.length } }
|
|
537
|
+
: { ok: true, code: "prewarm.all" };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
default:
|
|
541
|
+
return { ok: false, code: "action.unknown", params: { type } };
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Cloudflare hatası tek bir kodla döner: hata metni Cloudflare'den geliyor ve
|
|
547
|
+
* çevrilemez, ama etrafındaki cümle çevrilebilir.
|
|
548
|
+
*
|
|
549
|
+
* @param {string | undefined} error
|
|
550
|
+
* @returns {{ ok: false, code: string, params: { error: string } }}
|
|
551
|
+
*/
|
|
552
|
+
function cloudflareFailure(error) {
|
|
553
|
+
return { ok: false, code: "cf.failed", params: { error: error ?? "unknown error" } };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/* ------------------------------------------------------------------ router */
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* @returns {import('express').Router}
|
|
560
|
+
*/
|
|
561
|
+
function router() {
|
|
562
|
+
const api = express.Router();
|
|
563
|
+
|
|
564
|
+
// Panelin hiçbir cevabı saklanmamalı ve hiçbir yeri indekslenmemeli:
|
|
565
|
+
// önbellek durumu saniyeler içinde değişiyor ve yol arama motorunda
|
|
566
|
+
// görünmemeli.
|
|
567
|
+
api.use((req, res, next) => {
|
|
568
|
+
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
|
|
569
|
+
res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive, nosnippet");
|
|
570
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
571
|
+
|
|
572
|
+
if (banned(req)) {
|
|
573
|
+
notFound(res);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
next();
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
// Giriş sayfası. Yetkisiz GET burada **sayaca yazılmaz**: paneli açmak
|
|
581
|
+
// doğal ilk adım ve kullanıcı kendini üç sekme açmakla yasaklamamalı.
|
|
582
|
+
api.get("/", (req, res) => {
|
|
583
|
+
// Sayfa varlıklarını göreli yolla istiyor; sondaki `/` olmadan
|
|
584
|
+
// `panel.js` bir üst dizine düşer.
|
|
585
|
+
if (!req.originalUrl.split("?")[0].endsWith("/")) {
|
|
586
|
+
res.redirect(302, `${settings.basePath}/`);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
res.type("html");
|
|
591
|
+
sendFile(res, authenticated(req) ? "panel.html" : "login.html");
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// Stil ve logo giriş sayfasında da gerekiyor, yani oturumdan önce servis
|
|
595
|
+
// edilir. İkisinde de durum bilgisi yok.
|
|
596
|
+
api.get("/panel.css", (req, res) => {
|
|
597
|
+
res.type("text/css");
|
|
598
|
+
sendFile(res, "panel.css");
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
// Sözlük giriş sayfasında da gerekiyor: dil seçimi oturumdan önce yapılıyor.
|
|
602
|
+
api.get("/i18n.js", (req, res) => {
|
|
603
|
+
res.type("application/javascript");
|
|
604
|
+
sendFile(res, "i18n.js");
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
api.get("/logo.png", (req, res) => {
|
|
608
|
+
res.type("image/png");
|
|
609
|
+
fs.createReadStream(path.join(FRAMEWORK_ROOT, "src", "logo.png")).pipe(res);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
api.post("/login", express.json({ limit: "4kb" }), (req, res) => {
|
|
613
|
+
const password = typeof req.body?.password === "string" ? req.body.password : "";
|
|
614
|
+
|
|
615
|
+
if (!password || !safeEqual(password, PASSWORD)) {
|
|
616
|
+
noteFailure(req, password ? "wrong password" : "empty password");
|
|
617
|
+
// Kalan deneme sayısı bilinçli olarak bildirilmez.
|
|
618
|
+
res.status(401).json({ ok: false, message: "Invalid password" });
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
pruneSessions();
|
|
623
|
+
|
|
624
|
+
const token = crypto.randomBytes(32).toString("base64url");
|
|
625
|
+
const maxAge = Math.round(settings.sessionHours * 3600);
|
|
626
|
+
sessions.set(token, Date.now() + maxAge * 1000);
|
|
627
|
+
offenders.delete(clientKey(req));
|
|
628
|
+
|
|
629
|
+
// `SameSite=Strict`: panel hiçbir çapraz site gezinmesinde oturum
|
|
630
|
+
// taşımamalı. `Secure` üretimde açık, dev'de kapalı (http://localhost).
|
|
631
|
+
res.setHeader(
|
|
632
|
+
"Set-Cookie",
|
|
633
|
+
[
|
|
634
|
+
`${COOKIE_NAME}=${token}`,
|
|
635
|
+
`Path=${settings.basePath}`,
|
|
636
|
+
`Max-Age=${maxAge}`,
|
|
637
|
+
"HttpOnly",
|
|
638
|
+
"SameSite=Strict",
|
|
639
|
+
...(process.env.NODE_ENV === "development" ? [] : ["Secure"]),
|
|
640
|
+
].join("; "),
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
res.json({ ok: true });
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// Buradan sonrası oturum ister.
|
|
647
|
+
api.use((req, res, next) => {
|
|
648
|
+
if (authenticated(req)) return next();
|
|
649
|
+
|
|
650
|
+
// Yetkisiz **okuma** sayaca yazılmaz. Süreç yeniden başladığında eski
|
|
651
|
+
// oturumlar ölüyor ve açık kalmış bir panel sekmesi yoklamaya devam
|
|
652
|
+
// ediyor: sayarsak operatör kendi IP'sini üç saniyede yasaklıyor. Okuma
|
|
653
|
+
// zaten `404` dönüyor, yani saymamanın bir bedeli yok — korunması gereken
|
|
654
|
+
// şey şifre denemesi ve durumu değiştiren istek.
|
|
655
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
656
|
+
noteFailure(req, `unauthenticated ${req.method} ${req.path}`);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
notFound(res);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
api.post("/logout", (req, res) => {
|
|
663
|
+
const token = readCookie(req, COOKIE_NAME);
|
|
664
|
+
if (token) sessions.delete(token);
|
|
665
|
+
res.setHeader("Set-Cookie", `${COOKIE_NAME}=; Path=${settings.basePath}; Max-Age=0`);
|
|
666
|
+
res.json({ ok: true });
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
api.get("/panel.js", (req, res) => {
|
|
670
|
+
res.type("application/javascript");
|
|
671
|
+
sendFile(res, "panel.js");
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
api.get("/data", async (req, res) => {
|
|
675
|
+
const query = typeof req.query.q === "string" ? req.query.q : "";
|
|
676
|
+
res.json(await snapshot(query));
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
// Cloudflare ayrı uçlarda: her ikisi de ağa çıkıyor ve döküm turuna
|
|
680
|
+
// bağlanırlarsa panel Cloudflare'in gecikmesi kadar yavaşlar.
|
|
681
|
+
api.get("/cloudflare", async (req, res) => {
|
|
682
|
+
res.json(await fetchCloudflareOverview({ force: req.query.force === "1" }));
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
api.post("/cloudflare/analytics", express.json({ limit: "8kb" }), async (req, res) => {
|
|
686
|
+
if (!cloudflareConfigured()) {
|
|
687
|
+
res.json({ ok: false, error: "not configured" });
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const hours = Number(req.body?.hours) || undefined;
|
|
692
|
+
const path = typeof req.body?.path === "string" ? req.body.path.trim() : "";
|
|
693
|
+
|
|
694
|
+
// Yol verildiyse soru "bu sayfa hangi edge'lerden servis edildi", yoksa
|
|
695
|
+
// "zone genelinde cache oranı ne".
|
|
696
|
+
res.json(path ? await fetchPathEdges({ path, hours }) : await fetchCacheAnalytics({ hours }));
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
api.post("/action", express.json({ limit: "64kb" }), async (req, res) => {
|
|
700
|
+
if (req.get(ACTION_HEADER) !== "1") {
|
|
701
|
+
notFound(res);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
try {
|
|
706
|
+
res.json(await runAction(req.body ?? {}, req));
|
|
707
|
+
} catch (error) {
|
|
708
|
+
console.error("[cache-panel] action failed", error);
|
|
709
|
+
res.status(500).json({ ok: false, message: "Action failed, see server logs" });
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
return api;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Purge URL'lerinin kökü. `cloudflare.hostname` verilmediğinde panelin
|
|
718
|
+
* açıldığı origin kullanılır: tek zone'lu bir kurulumda ikisi zaten aynı.
|
|
719
|
+
*
|
|
720
|
+
* @param {import('express').Request} req
|
|
721
|
+
* @returns {string | undefined}
|
|
722
|
+
*/
|
|
723
|
+
function originOf(req) {
|
|
724
|
+
const host = req.get("host");
|
|
725
|
+
return host ? `${req.protocol}://${host}` : undefined;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* @param {import('express').Response} res
|
|
730
|
+
* @param {string} name
|
|
731
|
+
*/
|
|
732
|
+
function sendFile(res, name) {
|
|
733
|
+
fs.createReadStream(path.join(PANEL_DIR, name)).pipe(res);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Paneli uygulamaya bağlar ve şifreyi loglar.
|
|
738
|
+
*
|
|
739
|
+
* @param {import('express').Express} app
|
|
740
|
+
*/
|
|
741
|
+
export function mountCachePanel(app) {
|
|
742
|
+
settings = getConfig().cachePanel;
|
|
743
|
+
|
|
744
|
+
app.use(settings.basePath, router());
|
|
745
|
+
|
|
746
|
+
// Şifre akışın içinde tek bir satır olarak kaybolmamalı: kullanıcı onu bir
|
|
747
|
+
// kez görüyor ve her restart'ta değişiyor.
|
|
748
|
+
const port = process.env.PORT ?? 3000;
|
|
749
|
+
log.box({
|
|
750
|
+
title: "CACHE PANEL",
|
|
751
|
+
lines: [
|
|
752
|
+
`http://localhost:${port}${settings.basePath}`,
|
|
753
|
+
"",
|
|
754
|
+
`password ${PASSWORD}`,
|
|
755
|
+
"",
|
|
756
|
+
"Valid until this process restarts.",
|
|
757
|
+
],
|
|
758
|
+
});
|
|
759
|
+
}
|