jskelet 0.2.2 → 0.2.4

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.
@@ -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,16 +281,68 @@ 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
  /**
254
- * Panelden gelen işlemi uygular ve kullanıcıya gösterilecek özeti döner.
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.
255
346
  *
256
347
  * Her işlem yerel kademeyi düşürüp paylaşımlı kademeye de yayılıyor (bkz.
257
348
  * `html-cache.js`, `data-cache.js`): tek node'un önbelleğini boşaltmak,
@@ -259,7 +350,9 @@ function snapshot(query) {
259
350
  *
260
351
  * @param {Record<string, any>} body
261
352
  * @param {import('express').Request} req
262
- * @returns {Promise<{ ok: boolean, message: string }>}
353
+ * @returns {Promise<{ ok: boolean, code?: string,
354
+ * params?: Record<string, string | number>,
355
+ * parts?: ({ code: string, params?: Record<string, string | number> } | null)[] }>}
263
356
  */
264
357
  async function runAction(body, req) {
265
358
  const type = String(body?.type ?? "");
@@ -268,51 +361,63 @@ async function runAction(body, req) {
268
361
  case "html:clear": {
269
362
  const size = getHtmlCacheSize();
270
363
  clearHtmlCache();
271
- return { ok: true, message: `HTML cache cleared (${size} entries)` };
364
+ return { ok: true, code: "html.cleared", params: { count: size } };
272
365
  }
273
366
 
274
367
  case "data:clear": {
275
368
  const prefix = typeof body.prefix === "string" && body.prefix ? body.prefix : undefined;
276
369
  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
- };
370
+ return prefix
371
+ ? { ok: true, code: "data.clearedPrefix", params: { count: removed, prefix } }
372
+ : { ok: true, code: "data.cleared", params: { count: removed } };
283
373
  }
284
374
 
285
375
  case "html:invalidate": {
286
376
  const target = String(body.target ?? "");
287
377
  if (!target.startsWith("/")) {
288
- return { ok: false, message: "Target must start with `/`" };
378
+ return { ok: false, code: "target.invalid" };
289
379
  }
290
380
 
291
381
  const hard = body.hard === true;
292
382
  const count = invalidateHtmlCache(target, { hard });
293
383
  return {
294
384
  ok: true,
295
- message: `${count} entries ${hard ? "dropped" : "marked stale"} for \`${target}\``,
385
+ code: hard ? "html.dropped" : "html.marked",
386
+ params: { count, target },
296
387
  };
297
388
  }
298
389
 
299
390
  case "html:drop": {
300
391
  const key = String(body.key ?? "");
301
- if (!key) return { ok: false, message: "Missing key" };
392
+ if (!key) return { ok: false, code: "key.missing" };
302
393
  const existed = dropHtmlCacheKey(key);
303
- return {
304
- ok: true,
305
- message: existed ? `Dropped \`${key}\`` : `\`${key}\` was not cached`,
306
- };
394
+ return { ok: true, code: existed ? "entry.dropped" : "entry.absent", params: { key } };
307
395
  }
308
396
 
309
397
  case "data:drop": {
310
398
  const key = String(body.key ?? "");
311
- if (!key) return { ok: false, message: "Missing key" };
399
+ if (!key) return { ok: false, code: "key.missing" };
312
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.
313
411
  return {
314
412
  ok: true,
315
- message: existed ? `Dropped \`${key}\`` : `\`${key}\` was not cached`,
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),
316
421
  };
317
422
  }
318
423
 
@@ -320,16 +425,96 @@ async function runAction(body, req) {
320
425
  const kind = body.kind === "data" ? "data" : "html";
321
426
  const status = getRedisStatus();
322
427
  if (!status.connected) {
323
- return { ok: false, message: "Redis is not connected" };
428
+ return { ok: false, code: "redis.notConnected" };
324
429
  }
325
430
 
326
431
  const dropped = await redisDropMatching(kind);
327
- return { ok: true, message: `${dropped} shared ${kind} keys dropped` };
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);
328
513
  }
329
514
 
330
515
  case "prewarm": {
331
516
  if (prewarmProgress.active) {
332
- return { ok: false, message: "A prewarm round is already running" };
517
+ return { ok: false, code: "prewarm.busy" };
333
518
  }
334
519
 
335
520
  const requested = Array.isArray(body.paths) ? body.paths : [];
@@ -347,17 +532,27 @@ async function runAction(body, req) {
347
532
  console.error("[cache-panel] prewarm failed", error);
348
533
  });
349
534
 
350
- return {
351
- ok: true,
352
- message: paths.length ? `Prewarming ${paths.length} paths` : "Prewarming all paths",
353
- };
535
+ return paths.length
536
+ ? { ok: true, code: "prewarm.paths", params: { count: paths.length } }
537
+ : { ok: true, code: "prewarm.all" };
354
538
  }
355
539
 
356
540
  default:
357
- return { ok: false, message: `Unknown action: ${type}` };
541
+ return { ok: false, code: "action.unknown", params: { type } };
358
542
  }
359
543
  }
360
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
+
361
556
  /* ------------------------------------------------------------------ router */
362
557
 
363
558
  /**
@@ -396,13 +591,24 @@ function router() {
396
591
  sendFile(res, authenticated(req) ? "panel.html" : "login.html");
397
592
  });
398
593
 
399
- // Stil giriş sayfasında da gerekiyor, yani oturumdan önce servis edilir.
400
- // İçinde durum bilgisi yok.
594
+ // Stil ve logo giriş sayfasında da gerekiyor, yani oturumdan önce servis
595
+ // edilir. İkisinde de durum bilgisi yok.
401
596
  api.get("/panel.css", (req, res) => {
402
597
  res.type("text/css");
403
598
  sendFile(res, "panel.css");
404
599
  });
405
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
+
406
612
  api.post("/login", express.json({ limit: "4kb" }), (req, res) => {
407
613
  const password = typeof req.body?.password === "string" ? req.body.password : "";
408
614
 
@@ -441,7 +647,15 @@ function router() {
441
647
  api.use((req, res, next) => {
442
648
  if (authenticated(req)) return next();
443
649
 
444
- noteFailure(req, `unauthenticated ${req.method} ${req.path}`);
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
+
445
659
  notFound(res);
446
660
  });
447
661
 
@@ -457,9 +671,29 @@ function router() {
457
671
  sendFile(res, "panel.js");
458
672
  });
459
673
 
460
- api.get("/data", (req, res) => {
674
+ api.get("/data", async (req, res) => {
461
675
  const query = typeof req.query.q === "string" ? req.query.q : "";
462
- res.json(snapshot(query));
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 }));
463
697
  });
464
698
 
465
699
  api.post("/action", express.json({ limit: "64kb" }), async (req, res) => {
@@ -479,6 +713,18 @@ function router() {
479
713
  return api;
480
714
  }
481
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
+
482
728
  /**
483
729
  * @param {import('express').Response} res
484
730
  * @param {string} name
@@ -497,9 +743,17 @@ export function mountCachePanel(app) {
497
743
 
498
744
  app.use(settings.basePath, router());
499
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.
500
748
  const port = process.env.PORT ?? 3000;
501
- console.log(
502
- `[cache-panel] http://localhost:${port}${settings.basePath} — ` +
503
- `password for this run: ${PASSWORD}`,
504
- );
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
+ });
505
759
  }