jskelet 0.2.2 → 0.2.3

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.
@@ -15,7 +15,8 @@
15
15
  * headers() → [{ source, headers: [{ key, value }] }]
16
16
  * redirects() → [{ source, destination, permanent?, statusCode? }]
17
17
  * rewrites() → [{ source, destination }] | { beforeFiles?, afterFiles? }
18
- * cache() → { html?: { [source]: saniye }, maxEntries?: number,
18
+ * cache() → { html?: { [source]: saniye },
19
+ * query?: { [source]: string[] | true }, maxEntries?: number,
19
20
  * data?: {...}, redis?: {...}, prewarm?: {...},
20
21
  * panel?: {...} }
21
22
  *
@@ -30,6 +31,7 @@ import { compilePattern, matchPattern } from "./pattern.js";
30
31
  import {
31
32
  DEFAULT_BRAND,
32
33
  DEFAULT_CACHE_PANEL,
34
+ DEFAULT_CLOUDFLARE,
33
35
  DEFAULT_DATA_CACHE,
34
36
  DEFAULT_DEV_GATE_BYPASS,
35
37
  DEFAULT_DIRS,
@@ -84,6 +86,9 @@ const CONFIG_FILE = "jskelet.config.mjs";
84
86
  * @property {{ pattern: CompiledPattern, destination: string, statusCode: number }[]} redirects
85
87
  * @property {{ phase: "beforeFiles" | "afterFiles", pattern: CompiledPattern, destination: string }[]} rewrites
86
88
  * @property {{ pattern: CompiledPattern, seconds: number }[]} html
89
+ * @property {{ pattern: CompiledPattern, allow: true | string[] }[]} cacheQuery
90
+ * Yol deseni başına, HTML cache anahtarına girmesine izin verilen query
91
+ * parametreleri. Eşleşen kural yoksa query'li istek cache'lenmez.
87
92
  * @property {number} htmlMaxEntries HTML önbelleğinin girdi sınırı.
88
93
  * @property {Record<string, unknown>} data Upstream veri önbelleği ayarları.
89
94
  * @property {boolean} trackUpstream `fetch` sarılıp geçici hatalar otomatik bildirilsin mi.
@@ -92,6 +97,7 @@ const CONFIG_FILE = "jskelet.config.mjs";
92
97
  * @property {RedisConfig} redis Opsiyonel Redis ikinci kademesi.
93
98
  * @property {typeof DEFAULT_UPSTREAM_LIMIT} upstream Upstream hız freni.
94
99
  * @property {typeof DEFAULT_CACHE_PANEL} cachePanel Önbellek yönetim paneli.
100
+ * @property {typeof DEFAULT_CLOUDFLARE} cloudflare Cloudflare cache yüzeyi.
95
101
  * @property {Record<string, unknown>} prewarm
96
102
  * @property {{ source: string, test: (pathname: string) => boolean }[]} prewarmPriority
97
103
  * @property {Record<string, unknown>} brand
@@ -338,15 +344,87 @@ function normalizeCachePanel(raw) {
338
344
  };
339
345
  }
340
346
 
347
+ /**
348
+ * Cloudflare bölümü. Token burada da verilebiliyor ama önerilen yol env;
349
+ * normalizasyon sadece tipleri sabitler, sırrı okumak `cloudflare.js`'in işi.
350
+ *
351
+ * @param {unknown} raw
352
+ * @returns {typeof DEFAULT_CLOUDFLARE}
353
+ */
354
+ function normalizeCloudflare(raw) {
355
+ const source = /** @type {Record<string, any>} */ (raw ?? {});
356
+ const hours = Number(source.analyticsHours);
357
+
358
+ /** @param {unknown} value */
359
+ const text = (value) => (typeof value === "string" && value ? value : null);
360
+
361
+ return {
362
+ enabled: source.enabled !== false,
363
+ zoneId: text(source.zoneId),
364
+ apiToken: text(source.apiToken),
365
+ // Şema yazılırsa purge URL'i `https://https://…` olur; baştaki şema atılır.
366
+ hostname: text(source.hostname)?.replace(/^https?:\/\//, "") ?? null,
367
+ analyticsHours:
368
+ Number.isFinite(hours) && hours > 0
369
+ ? Math.min(72, Math.floor(hours))
370
+ : DEFAULT_CLOUDFLARE.analyticsHours,
371
+ };
372
+ }
373
+
374
+ /**
375
+ * `cache().query` → yol deseni başına, cache anahtarına girmesine izin verilen
376
+ * query parametreleri.
377
+ *
378
+ * Varsayılan bilinçli olarak "query varsa sayfa dinamik": bir yolun bütün
379
+ * query varyantlarını cache'lemek, `?utm_source=…` gibi sonsuz sayıda anahtar
380
+ * üretip LRU'daki gerçek sayfaları dışarı atıyor. Hangi parametrenin çıktıyı
381
+ * gerçekten değiştirdiğini yalnızca uygulama bilir, o yüzden izin listesi
382
+ * config'ten gelir.
383
+ *
384
+ * Bir desen `true` ile eşlenirse bütün parametreler anahtara girer (eski
385
+ * davranış), `[]` ile eşlenirse hiçbiri girmez — yani query yok sayılır ve
386
+ * bütün varyantlar query'siz sürümün HTML'ini paylaşır.
387
+ *
388
+ * @param {unknown} raw
389
+ * @returns {ResolvedConfig["cacheQuery"]}
390
+ */
391
+ function normalizeQueryRules(raw) {
392
+ /** @type {ResolvedConfig["cacheQuery"]} */
393
+ const out = [];
394
+
395
+ for (const [source, value] of Object.entries(raw ?? {})) {
396
+ const pattern = compilePattern(source);
397
+ if (!pattern) continue;
398
+
399
+ if (value === true) {
400
+ out.push({ pattern, allow: true });
401
+ continue;
402
+ }
403
+ if (value === false) continue;
404
+
405
+ const allow = asArray(
406
+ typeof value === "string" ? [value] : value,
407
+ `cache().query["${source}"]`,
408
+ )
409
+ .filter((name) => typeof name === "string" && name)
410
+ .map(String);
411
+ out.push({ pattern, allow });
412
+ }
413
+
414
+ return out;
415
+ }
416
+
341
417
  /**
342
418
  * @param {unknown} raw
343
- * @returns {{ html: ResolvedConfig["html"], htmlMaxEntries: number,
419
+ * @returns {{ html: ResolvedConfig["html"],
420
+ * cacheQuery: ResolvedConfig["cacheQuery"], htmlMaxEntries: number,
344
421
  * data: Record<string, unknown>, trackUpstream: boolean,
345
422
  * trackDependencies: boolean,
346
423
  * transientRetry: { attempts: number, delayMs: number },
347
424
  * redis: RedisConfig,
348
425
  * upstream: typeof DEFAULT_UPSTREAM_LIMIT,
349
426
  * cachePanel: typeof DEFAULT_CACHE_PANEL,
427
+ * cloudflare: typeof DEFAULT_CLOUDFLARE,
350
428
  * prewarm: Record<string, unknown>,
351
429
  * prewarmPriority: ResolvedConfig["prewarmPriority"] }}
352
430
  */
@@ -362,10 +440,12 @@ function normalizeCache(raw) {
362
440
  }
363
441
 
364
442
  const prewarm = { ...DEFAULT_PREWARM, ...(raw?.prewarm ?? {}) };
443
+ const queryRules = normalizeQueryRules(raw?.query);
365
444
  const maxEntries = Number(raw?.maxEntries);
366
445
 
367
446
  return {
368
447
  html,
448
+ cacheQuery: queryRules,
369
449
  htmlMaxEntries:
370
450
  Number.isFinite(maxEntries) && maxEntries > 0
371
451
  ? Math.floor(maxEntries)
@@ -385,6 +465,7 @@ function normalizeCache(raw) {
385
465
  redis: normalizeRedis(raw?.redis),
386
466
  upstream: normalizeUpstream(raw?.upstream),
387
467
  cachePanel: normalizeCachePanel(raw?.panel),
468
+ cloudflare: normalizeCloudflare(raw?.cloudflare),
388
469
  // Desenler derlenmiş hâlde ayrı alanda tutulur: `prewarm` sayısal
389
470
  // ayarların düz torbası olarak kalsın, her turda yeniden derlenmesin.
390
471
  prewarm,
@@ -597,6 +678,7 @@ export async function loadConfig(options = {}) {
597
678
 
598
679
  const {
599
680
  html,
681
+ cacheQuery,
600
682
  htmlMaxEntries,
601
683
  data,
602
684
  trackUpstream,
@@ -605,6 +687,7 @@ export async function loadConfig(options = {}) {
605
687
  redis,
606
688
  upstream,
607
689
  cachePanel,
690
+ cloudflare,
608
691
  prewarm,
609
692
  prewarmPriority,
610
693
  } = normalizeCache(cache);
@@ -619,6 +702,7 @@ export async function loadConfig(options = {}) {
619
702
  redirects: normalizeRedirects(redirects),
620
703
  rewrites: normalizeRewrites(rewrites),
621
704
  html,
705
+ cacheQuery,
622
706
  htmlMaxEntries,
623
707
  data,
624
708
  trackUpstream,
@@ -627,6 +711,7 @@ export async function loadConfig(options = {}) {
627
711
  redis,
628
712
  upstream,
629
713
  cachePanel,
714
+ cloudflare,
630
715
  prewarm,
631
716
  prewarmPriority,
632
717
  brand,
package/src/index.js CHANGED
@@ -58,7 +58,22 @@ export {
58
58
  } from "./server/data-cache.js";
59
59
  // Paylaşımlı önbellek kademesinin durumu. Bağlantı kurulmamışken de güvenle
60
60
  // çağrılır; healthcheck uçları bunu okuyor.
61
- export { getRedisStatus } from "./server/redis.js";
61
+ // `getRedisDetails()` bağlantının nereye kurulduğunu ve hangi türleri
62
+ // paylaştığını söyler (şifre asla dönmez); `inspectRedis()` tür başına anahtar
63
+ // sayar — bir `SCAN` turu olduğu için istek yolunda çağrılmamalı.
64
+ export { getRedisDetails, getRedisStatus, inspectRedis } from "./server/redis.js";
65
+ // CDN kademesi. JSkelet'in önbelleği origin önbelleği; ziyaretçinin gördüğü
66
+ // kopya edge'de duruyor. Bir içeriği gerçekten tazelemek için ikisini birlikte
67
+ // düşürmek gerekiyor: `invalidateHtmlCache()` + `purgeCloudflare()`.
68
+ export {
69
+ cloudflareConfigured,
70
+ fetchCacheAnalytics,
71
+ fetchCloudflareOverview,
72
+ fetchPathEdges,
73
+ getCloudflareStatus,
74
+ purgeCloudflare,
75
+ toCloudflareUrls,
76
+ } from "./server/cloudflare.js";
62
77
  // Upstream hız freninin host başına durumu: healthcheck ve teşhis uçları için.
63
78
  export { getUpstreamLimiterStatus } from "./server/upstream-limiter.js";
64
79
  export { prewarm, prewarmProgress } from "./server/prewarm.js";
package/src/log.mjs CHANGED
@@ -284,6 +284,31 @@ function truncate(text, max) {
284
284
  /** Kutunun içi sabit genişlikte; uzun satırlar kırpılır. */
285
285
  const BOX = 52;
286
286
 
287
+ /**
288
+ * Çerçeveli bilgi kutusu.
289
+ *
290
+ * Açılış logunda kaybolmaması gereken tek şey sır: önbellek panelinin şifresi
291
+ * her restart'ta değişiyor ve kullanıcı onu bir kez, akışın içinde görüyor.
292
+ * Genişlik içeriğe göre büyür — bir URL'i ya da 32 haneli bir şifreyi
293
+ * kırpmak kutunun bütün amacını bozar.
294
+ *
295
+ * @param {{ title: string, lines: string[],
296
+ * tint?: (value: string) => string }} info `lines` içinde boş string ayırıcı
297
+ * satır olur. Satırlar **düz metin** olmalı: bir ANSI dizisi `length`e
298
+ * sayıldığı için hizalamayı bozar, rengi çerçeve taşır.
299
+ */
300
+ export function box({ title, lines, tint = c.cyan }) {
301
+ const width = Math.max(BOX, title.length + 4, ...lines.map((text) => text.length + 4));
302
+ const top = `┌─ ${title} ${"─".repeat(width - title.length - 3)}┐`;
303
+ const bottom = `└${"─".repeat(width)}┘`;
304
+
305
+ write(`\n${tint(top)}\n`);
306
+ for (const text of ["", ...lines, ""]) {
307
+ write(`${tint("│")} ${text}${" ".repeat(width - 4 - text.length)} ${tint("│")}\n`);
308
+ }
309
+ write(`${tint(bottom)}\n\n`);
310
+ }
311
+
287
312
  /**
288
313
  * Çerçeveli hata kutusu — kendi framework'ünü geliştirirken hatanın
289
314
  * akış içinde kaybolmaması için.
@@ -26,10 +26,18 @@
26
26
  */
27
27
  import crypto from "node:crypto";
28
28
  import fs from "node:fs";
29
+ import os from "node:os";
29
30
  import path from "node:path";
30
31
  import process from "node:process";
31
32
  import express from "express";
33
+ import * as log from "../log.mjs";
32
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";
33
41
  import { safeEqual } from "../http/cookies.js";
34
42
  import {
35
43
  clearHtmlCache,
@@ -44,8 +52,25 @@ import {
44
52
  getDataCacheEntries,
45
53
  getDataCacheSize,
46
54
  } from "./data-cache.js";
47
- import { getRedisStatus, redisDropMatching } from "./redis.js";
55
+ import {
56
+ getRedisDetails,
57
+ getRedisStatus,
58
+ inspectRedis,
59
+ redisDropMatching,
60
+ } from "./redis.js";
48
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";
49
74
  import { prewarm, prewarmProgress } from "./prewarm.js";
50
75
 
51
76
  /** Panel dosyaları framework paketinden servis edilir, uygulamadan değil. */
@@ -199,9 +224,9 @@ function notFound(res) {
199
224
  * göndermenin faydası yok.
200
225
  *
201
226
  * @param {string} query Anahtar filtresi (boş → filtre yok).
202
- * @returns {object}
227
+ * @returns {Promise<object>}
203
228
  */
204
- function snapshot(query) {
229
+ async function snapshot(query) {
205
230
  const term = query.trim().toLowerCase();
206
231
  /** @param {{ key: string }} entry */
207
232
  const matches = (entry) => !term || entry.key.toLowerCase().includes(term);
@@ -216,6 +241,13 @@ function snapshot(query) {
216
241
  return {
217
242
  boot: BOOT_ID,
218
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(),
219
251
  process: {
220
252
  pid: process.pid,
221
253
  node: process.version,
@@ -231,7 +263,14 @@ function snapshot(query) {
231
263
  matched: htmlMatched.length,
232
264
  entries: htmlMatched
233
265
  .sort((a, b) => b.bytes - a.bytes)
234
- .slice(0, MAX_LISTED),
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
+ })),
235
274
  },
236
275
  data: {
237
276
  size: getDataCacheSize(),
@@ -242,12 +281,60 @@ function snapshot(query) {
242
281
  .sort((a, b) => a.expiresIn - b.expiresIn)
243
282
  .slice(0, MAX_LISTED),
244
283
  },
245
- redis: getRedisStatus(),
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(),
246
288
  upstream: getUpstreamLimiterStatus(),
247
289
  prewarm: { ...prewarmProgress },
248
290
  };
249
291
  }
250
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
+
251
338
  /* --------------------------------------------------------------- aksiyonlar */
252
339
 
253
340
  /**
@@ -316,6 +403,20 @@ async function runAction(body, req) {
316
403
  };
317
404
  }
318
405
 
406
+ case "redis:inspect": {
407
+ const result = await inspectRedis();
408
+ if (!result.ok) return { ok: false, message: "Redis is not reachable" };
409
+
410
+ const parts = [
411
+ `${result.html} html keys`,
412
+ `${result.data} data keys`,
413
+ result.totalKeys !== null ? `${result.totalKeys} keys in db` : null,
414
+ result.usedMemory ? `${result.usedMemory} used` : null,
415
+ ].filter(Boolean);
416
+
417
+ return { ok: true, message: parts.join(" · ") };
418
+ }
419
+
319
420
  case "redis:drop": {
320
421
  const kind = body.kind === "data" ? "data" : "html";
321
422
  const status = getRedisStatus();
@@ -327,6 +428,93 @@ async function runAction(body, req) {
327
428
  return { ok: true, message: `${dropped} shared ${kind} keys dropped` };
328
429
  }
329
430
 
431
+ /* ------------------------------------------------------- cloudflare */
432
+
433
+ case "cf:purge-everything": {
434
+ const result = await purgeCloudflare({ everything: true });
435
+ return {
436
+ ok: result.ok,
437
+ message: result.ok
438
+ ? "Cloudflare cache purged (everything)"
439
+ : `Cloudflare: ${result.error}`,
440
+ };
441
+ }
442
+
443
+ case "cf:purge-urls": {
444
+ // Panelin elindeki HTML anahtarları yol; Cloudflare tam URL istiyor.
445
+ // Origin, isteğin geldiği host'tan türetilir ki tek zone'lu kurulumda
446
+ // ayrıca ayar gerekmesin.
447
+ const paths = Array.isArray(body.paths) ? body.paths.map(String) : [];
448
+ if (!paths.length) return { ok: false, message: "No paths given" };
449
+
450
+ const urls = toCloudflareUrls(paths, originOf(req));
451
+ if (!urls.length) {
452
+ return {
453
+ ok: false,
454
+ message: "Could not build absolute URLs — set cache().cloudflare.hostname",
455
+ };
456
+ }
457
+
458
+ const result = await purgeCloudflare({ files: urls });
459
+ return {
460
+ ok: result.ok,
461
+ message: result.ok
462
+ ? `Purged ${result.purged} URLs at Cloudflare in ${result.batches} request(s)`
463
+ : `Cloudflare: ${result.error}`,
464
+ };
465
+ }
466
+
467
+ case "cf:purge-keys": {
468
+ /** @type {"prefixes" | "hosts" | "tags"} */
469
+ const kind =
470
+ body.kind === "hosts" ? "hosts" : body.kind === "tags" ? "tags" : "prefixes";
471
+
472
+ const values = String(body.values ?? "")
473
+ .split(/[\s,]+/)
474
+ .filter(Boolean);
475
+
476
+ if (!values.length) return { ok: false, message: "Nothing to purge" };
477
+
478
+ const result = await purgeCloudflare({ [kind]: values });
479
+ return {
480
+ ok: result.ok,
481
+ message: result.ok
482
+ ? `Purged ${result.purged} ${kind} at Cloudflare`
483
+ : `Cloudflare: ${result.error}`,
484
+ };
485
+ }
486
+
487
+ case "cf:setting": {
488
+ const result = await setCloudflareSetting(body.id, body.value);
489
+ return {
490
+ ok: result.ok,
491
+ message: result.ok
492
+ ? `Cloudflare ${body.id} is now ${result.value}`
493
+ : `Cloudflare: ${result.error}`,
494
+ };
495
+ }
496
+
497
+ case "cf:feature": {
498
+ const value = body.value === "on" ? "on" : "off";
499
+ const result = await setCloudflareFeature(body.feature, value);
500
+ return {
501
+ ok: result.ok,
502
+ message: result.ok
503
+ ? `Cloudflare ${body.feature} turned ${value}`
504
+ : `Cloudflare: ${result.error}`,
505
+ };
506
+ }
507
+
508
+ case "cf:clear-reserve": {
509
+ const result = await clearCloudflareCacheReserve();
510
+ return {
511
+ ok: result.ok,
512
+ message: result.ok
513
+ ? "Cache Reserve clear started — it runs asynchronously at Cloudflare"
514
+ : `Cloudflare: ${result.error}`,
515
+ };
516
+ }
517
+
330
518
  case "prewarm": {
331
519
  if (prewarmProgress.active) {
332
520
  return { ok: false, message: "A prewarm round is already running" };
@@ -396,13 +584,18 @@ function router() {
396
584
  sendFile(res, authenticated(req) ? "panel.html" : "login.html");
397
585
  });
398
586
 
399
- // Stil giriş sayfasında da gerekiyor, yani oturumdan önce servis edilir.
400
- // İçinde durum bilgisi yok.
587
+ // Stil ve logo giriş sayfasında da gerekiyor, yani oturumdan önce servis
588
+ // edilir. İkisinde de durum bilgisi yok.
401
589
  api.get("/panel.css", (req, res) => {
402
590
  res.type("text/css");
403
591
  sendFile(res, "panel.css");
404
592
  });
405
593
 
594
+ api.get("/logo.png", (req, res) => {
595
+ res.type("image/png");
596
+ fs.createReadStream(path.join(FRAMEWORK_ROOT, "src", "logo.png")).pipe(res);
597
+ });
598
+
406
599
  api.post("/login", express.json({ limit: "4kb" }), (req, res) => {
407
600
  const password = typeof req.body?.password === "string" ? req.body.password : "";
408
601
 
@@ -457,9 +650,29 @@ function router() {
457
650
  sendFile(res, "panel.js");
458
651
  });
459
652
 
460
- api.get("/data", (req, res) => {
653
+ api.get("/data", async (req, res) => {
461
654
  const query = typeof req.query.q === "string" ? req.query.q : "";
462
- res.json(snapshot(query));
655
+ res.json(await snapshot(query));
656
+ });
657
+
658
+ // Cloudflare ayrı uçlarda: her ikisi de ağa çıkıyor ve döküm turuna
659
+ // bağlanırlarsa panel Cloudflare'in gecikmesi kadar yavaşlar.
660
+ api.get("/cloudflare", async (req, res) => {
661
+ res.json(await fetchCloudflareOverview({ force: req.query.force === "1" }));
662
+ });
663
+
664
+ api.post("/cloudflare/analytics", express.json({ limit: "8kb" }), async (req, res) => {
665
+ if (!cloudflareConfigured()) {
666
+ res.json({ ok: false, error: "not configured" });
667
+ return;
668
+ }
669
+
670
+ const hours = Number(req.body?.hours) || undefined;
671
+ const path = typeof req.body?.path === "string" ? req.body.path.trim() : "";
672
+
673
+ // Yol verildiyse soru "bu sayfa hangi edge'lerden servis edildi", yoksa
674
+ // "zone genelinde cache oranı ne".
675
+ res.json(path ? await fetchPathEdges({ path, hours }) : await fetchCacheAnalytics({ hours }));
463
676
  });
464
677
 
465
678
  api.post("/action", express.json({ limit: "64kb" }), async (req, res) => {
@@ -479,6 +692,18 @@ function router() {
479
692
  return api;
480
693
  }
481
694
 
695
+ /**
696
+ * Purge URL'lerinin kökü. `cloudflare.hostname` verilmediğinde panelin
697
+ * açıldığı origin kullanılır: tek zone'lu bir kurulumda ikisi zaten aynı.
698
+ *
699
+ * @param {import('express').Request} req
700
+ * @returns {string | undefined}
701
+ */
702
+ function originOf(req) {
703
+ const host = req.get("host");
704
+ return host ? `${req.protocol}://${host}` : undefined;
705
+ }
706
+
482
707
  /**
483
708
  * @param {import('express').Response} res
484
709
  * @param {string} name
@@ -497,9 +722,17 @@ export function mountCachePanel(app) {
497
722
 
498
723
  app.use(settings.basePath, router());
499
724
 
725
+ // Şifre akışın içinde tek bir satır olarak kaybolmamalı: kullanıcı onu bir
726
+ // kez görüyor ve her restart'ta değişiyor.
500
727
  const port = process.env.PORT ?? 3000;
501
- console.log(
502
- `[cache-panel] http://localhost:${port}${settings.basePath} — ` +
503
- `password for this run: ${PASSWORD}`,
504
- );
728
+ log.box({
729
+ title: "CACHE PANEL",
730
+ lines: [
731
+ `http://localhost:${port}${settings.basePath}`,
732
+ "",
733
+ `password ${PASSWORD}`,
734
+ "",
735
+ "Valid until this process restarts.",
736
+ ],
737
+ });
505
738
  }