mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
feat(api): make /api/v1/stats public and point the landing at it
Unify the monitoring stats on the versioned API: /api/v1/stats is now public (no auth) and CORS-enabled, mirroring the legacy /api/stats. The marketing landing (docs/index.html) now fetches /api/v1/stats; /api/stats is kept as a deprecated alias for existing monitors. Feed/email routes remain token-gated. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -34,8 +34,9 @@ Single Cloudflare Worker built with Hono. Routes:
|
|||||||
| ------------------------------------ | ---------------------------------------------------------------------- | ------- |
|
| ------------------------------------ | ---------------------------------------------------------------------- | ------- |
|
||||||
| `GET /` | Public status page (monitoring counters + link to admin) |
|
| `GET /` | Public status page (monitoring counters + link to admin) |
|
||||||
| `POST /api/inbound` | Webhook from ForwardEmail; IP-allowlisted to their MX sources |
|
| `POST /api/inbound` | Webhook from ForwardEmail; IP-allowlisted to their MX sources |
|
||||||
| `GET /api/stats` | Public monitoring counters (JSON) |
|
| `GET /api/stats` | Deprecated alias of `GET /api/v1/stats` (public monitoring counters) |
|
||||||
| `/api/v1/*` | Versioned REST API (Bearer/proxy auth) — feeds + emails CRUD, stats |
|
| `/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) |
|
| `GET /api/openapi.json` | OpenAPI 3.1 spec (public) |
|
||||||
| `GET /api/docs` | Rendered API reference (Scalar, public) |
|
| `GET /api/docs` | Rendered API reference (Scalar, public) |
|
||||||
| `GET /rss/:feedId` | Public RSS 2.0 feed |
|
| `GET /rss/:feedId` | Public RSS 2.0 feed |
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ Main routes:
|
|||||||
|
|
||||||
### Monitoring
|
### Monitoring
|
||||||
|
|
||||||
`GET /api/stats` returns JSON counters (public, no auth) for uptime/monitoring tools:
|
`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:
|
||||||
|
|
||||||
| Field | Meaning |
|
| Field | Meaning |
|
||||||
| ----------------------------- | -------------------------------------------------------- |
|
| ----------------------------- | -------------------------------------------------------- |
|
||||||
@@ -84,23 +86,25 @@ A versioned REST API lets you automate feed and email management without scrapin
|
|||||||
admin UI. The OpenAPI 3.1 spec is served at `GET /api/openapi.json` and a rendered
|
admin UI. The OpenAPI 3.1 spec is served at `GET /api/openapi.json` and a rendered
|
||||||
reference (Scalar) at `GET /api/docs` — both public.
|
reference (Scalar) at `GET /api/docs` — both public.
|
||||||
|
|
||||||
All `/api/v1/*` endpoints require authentication, using either:
|
The feed and email endpoints require authentication, using either:
|
||||||
|
|
||||||
- **Bearer token**: `Authorization: Bearer <ADMIN_PASSWORD>`, or
|
- **Bearer token**: `Authorization: Bearer <ADMIN_PASSWORD>`, or
|
||||||
- **Reverse-proxy auth**: the same trusted-IP + `X-Auth-Proxy-Secret` + `Remote-User`
|
- **Reverse-proxy auth**: the same trusted-IP + `X-Auth-Proxy-Secret` + `Remote-User`
|
||||||
headers as the admin UI (see [INSTALL.md](INSTALL.md)).
|
headers as the admin UI (see [INSTALL.md](INSTALL.md)).
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
`GET /api/v1/stats`, the OpenAPI spec, and the docs page are public.
|
||||||
| -------- | ------------------------------------ | ------------------------ |
|
|
||||||
| `GET` | `/api/v1/feeds` | List feeds |
|
| Method | Path | Auth | Purpose |
|
||||||
| `POST` | `/api/v1/feeds` | Create a feed |
|
| -------- | ------------------------------------ | ------ | ------------------------ |
|
||||||
| `GET` | `/api/v1/feeds/{feedId}` | Get a feed |
|
| `GET` | `/api/v1/feeds` | yes | List feeds |
|
||||||
| `PATCH` | `/api/v1/feeds/{feedId}` | Update a feed |
|
| `POST` | `/api/v1/feeds` | yes | Create a feed |
|
||||||
| `DELETE` | `/api/v1/feeds/{feedId}` | Delete a feed |
|
| `GET` | `/api/v1/feeds/{feedId}` | yes | Get a feed |
|
||||||
| `GET` | `/api/v1/feeds/{feedId}/emails` | List a feed's emails |
|
| `PATCH` | `/api/v1/feeds/{feedId}` | yes | Update a feed |
|
||||||
| `GET` | `/api/v1/feeds/{feedId}/emails/{id}` | Get a single email |
|
| `DELETE` | `/api/v1/feeds/{feedId}` | yes | Delete a feed |
|
||||||
| `DELETE` | `/api/v1/feeds/{feedId}/emails/{id}` | Delete a single email |
|
| `GET` | `/api/v1/feeds/{feedId}/emails` | yes | List a feed's emails |
|
||||||
| `GET` | `/api/v1/stats` | Read monitoring counters |
|
| `GET` | `/api/v1/feeds/{feedId}/emails/{id}` | yes | Get a single email |
|
||||||
|
| `DELETE` | `/api/v1/feeds/{feedId}/emails/{id}` | yes | Delete a single email |
|
||||||
|
| `GET` | `/api/v1/stats` | public | Read monitoring counters |
|
||||||
|
|
||||||
The email `{id}` is the email's `receivedAt` timestamp (as returned by the list endpoint).
|
The email `{id}` is the email's `receivedAt` timestamp (as returned by the list endpoint).
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1249,7 +1249,7 @@ bucket_name = "kill-the-news-attachments"</span></pre>
|
|||||||
const section = document.getElementById('stats');
|
const section = document.getElementById('stats');
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('https://demo.kill-the.news/api/stats', { cache: 'no-store' });
|
const res = await fetch('https://demo.kill-the.news/api/v1/stats', { cache: 'no-store' });
|
||||||
if (!res.ok) return; // section stays hidden
|
if (!res.ok) return; // section stays hidden
|
||||||
data = await res.json();
|
data = await res.json();
|
||||||
} catch { return; }
|
} catch { return; }
|
||||||
|
|||||||
@@ -252,10 +252,11 @@ describe("REST API (/api/v1)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Stats", () => {
|
describe("Stats", () => {
|
||||||
it("returns monitoring counters", async () => {
|
it("returns monitoring counters without a token (public)", async () => {
|
||||||
await createFeed();
|
await createFeed();
|
||||||
const res = await request("/api/v1/stats", { headers: authHeaders });
|
const res = await request("/api/v1/stats");
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||||
const stats = (await res.json()) as {
|
const stats = (await res.json()) as {
|
||||||
feeds_created: number;
|
feeds_created: number;
|
||||||
active_feeds: number;
|
active_feeds: number;
|
||||||
@@ -273,12 +274,15 @@ describe("REST API (/api/v1)", () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const doc = (await res.json()) as {
|
const doc = (await res.json()) as {
|
||||||
openapi: string;
|
openapi: string;
|
||||||
paths: Record<string, unknown>;
|
paths: Record<string, { get?: { security?: unknown[] } }>;
|
||||||
};
|
};
|
||||||
expect(doc.openapi).toBe("3.1.0");
|
expect(doc.openapi).toBe("3.1.0");
|
||||||
expect(doc.paths).toHaveProperty("/v1/feeds");
|
expect(doc.paths).toHaveProperty("/v1/feeds");
|
||||||
expect(doc.paths).toHaveProperty("/v1/feeds/{feedId}");
|
expect(doc.paths).toHaveProperty("/v1/feeds/{feedId}");
|
||||||
expect(doc.paths).toHaveProperty("/v1/stats");
|
expect(doc.paths).toHaveProperty("/v1/stats");
|
||||||
|
// Feed routes are secured; stats is public.
|
||||||
|
expect(doc.paths["/v1/feeds"].get?.security).toBeTruthy();
|
||||||
|
expect(doc.paths["/v1/stats"].get?.security).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
|
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
|
||||||
|
import { cors } from "hono/cors";
|
||||||
import { Scalar } from "@scalar/hono-api-reference";
|
import { Scalar } from "@scalar/hono-api-reference";
|
||||||
import { Env, FeedConfig } from "../../types";
|
import { Env, FeedConfig } from "../../types";
|
||||||
import { apiAuthMiddleware } from "../../lib/auth";
|
import { apiAuthMiddleware } from "../../lib/auth";
|
||||||
@@ -77,8 +78,12 @@ export const apiApp = new OpenAPIHono<AppEnv>({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Token auth on every /v1 route. The spec + docs stay public.
|
// Token auth on the feed/email routes. The spec, docs, and /v1/stats stay public.
|
||||||
apiApp.use("/v1/*", apiAuthMiddleware);
|
apiApp.use("/v1/feeds", apiAuthMiddleware);
|
||||||
|
apiApp.use("/v1/feeds/*", apiAuthMiddleware);
|
||||||
|
|
||||||
|
// Public monitoring stats — readable from any origin (landing page, embeds).
|
||||||
|
apiApp.use("/v1/stats", cors({ origin: "*" }));
|
||||||
|
|
||||||
apiApp.openAPIRegistry.registerComponent("securitySchemes", "bearerAuth", {
|
apiApp.openAPIRegistry.registerComponent("securitySchemes", "bearerAuth", {
|
||||||
type: "http",
|
type: "http",
|
||||||
@@ -363,11 +368,9 @@ apiApp.openapi(
|
|||||||
method: "get",
|
method: "get",
|
||||||
path: "/v1/stats",
|
path: "/v1/stats",
|
||||||
tags: ["Stats"],
|
tags: ["Stats"],
|
||||||
summary: "Read monitoring counters",
|
summary: "Read monitoring counters (public)",
|
||||||
security: bearer,
|
|
||||||
responses: {
|
responses: {
|
||||||
200: jsonContent(StatsSchema, "Monitoring counters"),
|
200: jsonContent(StatsSchema, "Monitoring counters"),
|
||||||
401: jsonContent(ErrorSchema, "Unauthorized"),
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user