diff --git a/CLAUDE.md b/CLAUDE.md index b8afe68..40ee93e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,6 @@ Single Cloudflare Worker built with Hono. Routes: | ------------------------------------ | ---------------------------------------------------------------------- | ------- | | `GET /` | Public status page (monitoring counters + link to admin) | | `POST /api/inbound` | Webhook from ForwardEmail; IP-allowlisted to their MX sources | -| `GET /api/stats` | Deprecated alias of `GET /api/v1/stats` (public monitoring counters) | | `/api/v1/feeds*` | Versioned REST API (Bearer/proxy auth) — feeds + emails CRUD | | `GET /api/v1/stats` | Public monitoring counters (JSON, CORS); canonical stats endpoint | | `GET /api/openapi.json` | OpenAPI 3.1 spec (public) | @@ -65,7 +64,6 @@ src/ files.ts # R2 attachment serving hub.ts # WebSub hub home.tsx # Public status page (GET /) - stats.ts # Monitoring counters API (GET /api/stats) admin.tsx # Admin UI entrypoint (hono/jsx) admin/ # Admin sub-modules feeds.tsx # Feeds CRUD UI diff --git a/INSTALL.md b/INSTALL.md index 16129e0..fa090c2 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -156,7 +156,7 @@ This feature is **optional**. If no R2 bucket is bound, attachments are silently Attachments are deleted from R2 automatically when the corresponding email is deleted from the admin UI, or when an email is dropped during feed size trimming. -**Monitoring storage / free tier:** the status page (`/`) and `/api/stats` report R2 space used (against the **10 GB** R2 free tier) and an estimate of KV space used (against the **1 GB** KV free tier). The figures are refreshed hourly by the cron trigger. KV usage is an estimate based on stored email sizes, so treat it as a lower bound. +**Monitoring storage / free tier:** the status page (`/`) and `/api/v1/stats` report R2 space used (against the **10 GB** R2 free tier) and an estimate of KV space used (against the **1 GB** KV free tier). The figures are refreshed hourly by the cron trigger. KV usage is an estimate based on stored email sizes, so treat it as a lower bound. ### External auth provider (Authelia / Authentik / reverse proxy) diff --git a/README.md b/README.md index 61f57f9..60e488a 100644 --- a/README.md +++ b/README.md @@ -57,13 +57,11 @@ Main routes: - `src/routes/api/`: versioned REST API + OpenAPI spec/docs (`/api/v1/*`, `/api/openapi.json`, `/api/docs`) - `src/lib/feed-service.ts`: shared feed create/update/delete (used by the admin UI and the REST API) - `src/routes/home.tsx`: public status page (`GET /`) -- `src/routes/stats.ts`: monitoring counters API (`GET /api/stats`) ### Monitoring `GET /api/v1/stats` returns JSON counters (public, no auth, CORS-enabled) for -uptime/monitoring tools and the landing page. `GET /api/stats` is a deprecated alias kept -for backward compatibility — prefer the versioned path. Both expose the same fields: +uptime/monitoring tools and the landing page: | Field | Meaning | | ----------------------------- | -------------------------------------------------------- | diff --git a/src/index.test.ts b/src/index.test.ts index c6d0ff9..d6c316c 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -45,9 +45,9 @@ describe("CORS middleware", () => { ); }); - it("makes /api/stats readable from any origin", async () => { + it("makes /api/v1/stats readable from any origin", async () => { const res = await worker.fetch( - req("/api/stats", { headers: { Origin: "https://example.com" } }), + req("/api/v1/stats", { headers: { Origin: "https://example.com" } }), env as unknown as Env, ); expect(res.status).toBe(200); diff --git a/src/index.ts b/src/index.ts index 812d730..41ca3be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,6 @@ import { handle as handleAtom } from "./routes/atom"; import { handle as handleAdmin } from "./routes/admin"; import { handle as handleEntry } from "./routes/entries"; import { handle as handleFiles } from "./routes/files"; -import { handle as handleStats } from "./routes/stats"; import { handle as handleHome } from "./routes/home"; import { handle as handleFavicon, handleFeedFavicon } from "./routes/favicon"; import { hubRouter } from "./routes/hub"; @@ -148,10 +147,6 @@ api.use("/inbound", async (c, next) => { // API routes (inbound webhook) api.post("/inbound", handleInbound); -// Public monitoring stats (JSON) — readable from any origin (landing page, embeds) -api.use("/stats", cors({ origin: "*" })); -api.get("/stats", handleStats); - // RSS feed routes (public) rss.get("/:feedId", handleRSS); @@ -223,7 +218,7 @@ export default { logger.info("Feed TTL cleanup", { deleted: expiredIds.length }); } - // Refresh the cached storage-usage snapshot for the status page / /api/stats. + // Refresh the cached storage-usage snapshot for the status page / /api/v1/stats. try { const r2 = attachmentBucket ? await scanR2Usage(attachmentBucket) diff --git a/src/routes/stats.test.ts b/src/routes/stats.test.ts deleted file mode 100644 index 06fd7b2..0000000 --- a/src/routes/stats.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, it, expect } from "vitest"; -import worker from "../index"; -import { createMockEnv } from "../test/setup"; -import { bumpCounters } from "../utils/stats"; -import { FEEDS_LIST_KEY } from "../config/constants"; -import type { Env, StatsResponse } from "../types"; - -function req(path: string, init: RequestInit = {}): Request { - return new Request(`https://test.getmynews.app${path}`, init); -} - -describe("GET /api/stats", () => { - it("returns zeroed stats for a fresh instance", async () => { - const env = createMockEnv() as unknown as Env; - const res = await worker.fetch(req("/api/stats"), env); - expect(res.status).toBe(200); - const body = (await res.json()) as StatsResponse; - expect(body).toMatchObject({ - feeds_created: 0, - feeds_deleted: 0, - emails_received: 0, - emails_rejected: 0, - active_feeds: 0, - websub_subscriptions_active: 0, - }); - }); - - it("reflects persisted counters and live values", async () => { - const env = createMockEnv() as unknown as Env; - await env.EMAIL_STORAGE.put( - FEEDS_LIST_KEY, - JSON.stringify({ feeds: [{ id: "a", title: "A" }] }), - ); - await env.EMAIL_STORAGE.put("websub:a:hash", "{}"); - await bumpCounters(env.EMAIL_STORAGE, { - emails_received: 3, - emails_rejected: 1, - feeds_created: 1, - }); - - const res = await worker.fetch(req("/api/stats"), env); - const body = (await res.json()) as StatsResponse; - expect(body.active_feeds).toBe(1); - expect(body.websub_subscriptions_active).toBe(1); - expect(body.emails_received).toBe(3); - expect(body.emails_rejected).toBe(1); - expect(body.feeds_created).toBe(1); - }); -}); - -describe("GET / (public status page)", () => { - it("returns an HTML status page with counters and an admin link", async () => { - const env = createMockEnv() as unknown as Env; - await bumpCounters(env.EMAIL_STORAGE, { emails_received: 7 }); - - const res = await worker.fetch(req("/"), env); - expect(res.status).toBe(200); - expect(res.headers.get("Content-Type")).toContain("text/html"); - - const html = await res.text(); - expect(html).toContain('href="/admin"'); - expect(html).toContain("Active feeds"); - expect(html).toContain("Emails received"); - expect(html).toContain("7"); - }); -}); diff --git a/src/routes/stats.ts b/src/routes/stats.ts deleted file mode 100644 index c072f7a..0000000 --- a/src/routes/stats.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Context } from "hono"; -import { Env } from "../types"; -import { getStats } from "../utils/stats"; - -export async function handle(c: Context<{ Bindings: Env }>): Promise { - return c.json(await getStats(c.env)); -}