diff --git a/docs/api.md b/docs/api.md
index b216a4b..8b151c7 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -70,6 +70,7 @@
| | `GET`、`POST /admin/colleges`,`GET`、`PATCH /admin/colleges/{id}` | 管理员查询、新增、修改学院 |
| | `GET`、`POST /admin/sources`,`GET`、`PATCH /admin/sources/{id}` | 管理员查询、接入、配置来源,暂停或恢复抓取 |
| | `POST /admin/users/{id}/revoke-edits` | 撤销某人的全部修改,收回这个人在鼠钟的修改权限 |
+| | `GET`、`POST /admin/crawler-tokens`,`POST /admin/crawler-tokens/{id}/revoke` | 站长查询、生成、作废抓取节点令牌:生成时返回的令牌只出现这一次,作废马上生效;生成和作废记进操作日志 |
| | `/admin/tags`、`/admin/users`、`/admin/settings` | 标签预设、修改权限、系统设置 |
## 4. MCP
diff --git a/web/app/components/site-header.tsx b/web/app/components/site-header.tsx
index d2ef129..f911838 100644
--- a/web/app/components/site-header.tsx
+++ b/web/app/components/site-header.tsx
@@ -18,7 +18,7 @@ type Me = {
display_name: string;
avatar_url: string | null;
frozen: boolean;
- can: { edit: boolean; admin: boolean };
+ can: { edit: boolean; admin: boolean; owner: boolean };
};
function navLink({ isActive }: { isActive: boolean }) {
@@ -67,6 +67,11 @@ export function SiteHeader({ me }: { me: Me | null }) {
来源管理
)}
+ {me?.can.owner && (
+
+ 系统设置
+
+ )}
{me ? (
diff --git a/web/app/routes.ts b/web/app/routes.ts
index 46359d8..5183ac0 100644
--- a/web/app/routes.ts
+++ b/web/app/routes.ts
@@ -11,4 +11,5 @@ export default [
route("logout", "routes/logout.ts"),
route("admin/colleges", "routes/admin-colleges.tsx"),
route("admin/sources", "routes/admin-sources.tsx"),
+ route("admin/settings", "routes/admin-settings.tsx"),
] satisfies RouteConfig;
diff --git a/web/app/routes/admin-settings.tsx b/web/app/routes/admin-settings.tsx
new file mode 100644
index 0000000..682d8f2
--- /dev/null
+++ b/web/app/routes/admin-settings.tsx
@@ -0,0 +1,156 @@
+import type { InferResponseType } from "hono/client";
+import { data, Form, Link, redirect, useNavigation } from "react-router";
+import { formText } from "../admin.server.ts";
+import { apiContext, readApi, type createApi } from "../api.server.ts";
+import { CrawlTime, Field, FormMessage, inputClass, panelClass } from "../components/admin.tsx";
+import { Tag } from "../components/tags.tsx";
+import { Button, buttonVariants } from "../components/ui/button.tsx";
+import type { Route } from "./+types/admin-settings.ts";
+
+type Api = ReturnType
;
+type Tokens = InferResponseType;
+
+export function meta() {
+ return [{ title: "系统设置 · 鼠钟" }];
+}
+
+export async function loader({ request, context }: Route.LoaderArgs) {
+ const params = new URL(request.url).searchParams;
+ const tokens = await context
+ .get(apiContext)
+ .admin["crawler-tokens"].$get()
+ .then((res) => readApi(res));
+ return { tokens, revoking: Number(params.get("revoke")), saved: params.has("saved") };
+}
+
+export async function action({ request, context }: Route.ActionArgs) {
+ const api = context.get(apiContext).admin["crawler-tokens"];
+ const form = await request.formData();
+ const intent = formText(form, "intent");
+ if (intent === "create") {
+ const name = formText(form, "name");
+ const res = await api.$post({ json: { name } });
+ if (res.status !== 201) {
+ return data({ error: (await res.json()).error.message, name }, { status: res.status });
+ }
+ return { token: (await res.json()).token, name };
+ }
+ if (intent === "revoke") {
+ const res = await api[":id"].revoke.$post({ param: { id: formText(form, "id") } });
+ if (res.status !== 204) {
+ return data({ error: (await res.json()).error.message, name: "" }, { status: res.status });
+ }
+ return redirect("/admin/settings?saved=1");
+ }
+ throw data("未知操作", { status: 400 });
+}
+
+export default function Settings({ loaderData, actionData }: Route.ComponentProps) {
+ const { tokens, revoking, saved } = loaderData;
+ const busy = useNavigation().state !== "idle";
+ const made = actionData && "token" in actionData ? actionData : null;
+ const error = actionData && "error" in actionData ? actionData.error : undefined;
+ return (
+
+
+
+
+
+
抓取节点令牌
+
+ 抓取节点用它调网站的接口(接口
+ §6)。每个抓取节点一个,换机器或者泄露了就作废,重新生成。
+
+
+
+ {made && (
+
+
+ 「{made.name}
+ 」的令牌生成了。它只显示这一次,现在复制下来填进抓取节点的配置;忘了就只能作废、重新生成。
+
+
event.currentTarget.select()}
+ className={`${inputClass} font-mono`}
+ />
+
+ )}
+ {/* A new key once a token is made, so the name typed for it is cleared. */}
+
+
+ {tokens.length === 0 ? (
+ 还没有令牌。
+ ) : (
+
+ {tokens.map((token) => (
+ -
+ {token.name}
+ {token.revoked_at !== null && 已作废}
+
+ 生成于 · 上次来领活{" "}
+ {token.last_seen_at === null ? (
+ "还没有"
+ ) : (
+
+ )}
+ {token.revoked_at !== null && (
+ <>
+ {" "}
+ · 作废于
+ >
+ )}
+
+ {token.revoked_at === null &&
+ (revoking === token.id ? (
+
+ ) : (
+
+ 作废
+
+ ))}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/web/scripts/crawler-token.ts b/web/scripts/crawler-token.ts
index 00f0ac9..6541984 100644
--- a/web/scripts/crawler-token.ts
+++ b/web/scripts/crawler-token.ts
@@ -2,7 +2,7 @@ import { createCrawlerToken } from "../server/modules/ingest/service.ts";
import { db } from "../server/platform/db.ts";
// For running a crawler against a local site; on the server the owner makes tokens in the
-// settings (design §6.2). The token is shown once: only its SHA-256 is kept.
+// settings (design §6.2).
const name = process.argv[2];
if (!name) throw new Error("Usage: pnpm crawler-token ");
console.log(await createCrawlerToken(name));
diff --git a/web/server/app.ts b/web/server/app.ts
index c8aa506..28bd66e 100644
--- a/web/server/app.ts
+++ b/web/server/app.ts
@@ -4,6 +4,7 @@ import { sql } from "kysely";
import { accountsRoutes, authenticate, autoLogin, loginRoutes } from "./modules/accounts/routes.ts";
import { dashboardRoutes } from "./modules/ingest/dashboard-routes.ts";
import { reviewItemsRoutes } from "./modules/ingest/review-routes.ts";
+import { tokenRoutes } from "./modules/ingest/token-routes.ts";
import { ingestRoutes } from "./modules/ingest/routes.ts";
import { programsRoutes } from "./modules/programs/routes.ts";
import { reviewRoutes } from "./modules/review/routes.ts";
@@ -18,6 +19,7 @@ const api = createRouter()
.route("/", ingestRoutes)
.route("/", dashboardRoutes)
.route("/", reviewItemsRoutes)
+ .route("/", tokenRoutes)
.route("/", programsRoutes)
.route("/", reviewRoutes)
.route("/", sourcesRoutes);
diff --git a/web/server/modules/ingest/service.ts b/web/server/modules/ingest/service.ts
index 194cc25..e82ed37 100644
--- a/web/server/modules/ingest/service.ts
+++ b/web/server/modules/ingest/service.ts
@@ -2,6 +2,7 @@ import { sql, type Kysely, type Transaction } from "kysely";
import type { DB, Json } from "../../../db/types.ts";
import { db } from "../../platform/db.ts";
import { AppError } from "../../platform/errors.ts";
+import { unix } from "../../platform/http.ts";
import { hashToken, newToken } from "../../platform/tokens.ts";
import type { ExtractedFields, Kind, Role } from "../programs/schemas.ts";
import {
@@ -13,6 +14,7 @@ import {
} from "../programs/service.ts";
import { deadlineOf, startOf } from "../programs/time.ts";
import { finishProbe, finishSourceCrawl, leaseProbes, leaseSources } from "../sources/service.ts";
+import { recordAudit } from "../review/service.ts";
// How long the crawler has to report on work before it is handed out again (api.md §6).
const LEASE_MS = 2 * 3600_000;
@@ -27,6 +29,8 @@ const UNDATED_RECHECK_MS = 90 * 24 * 3600_000;
const MAX_SOURCES = 50;
const MAX_PROBES = 5;
+type Owner = { user_id: number; via: "web" };
+
export type Failure =
"timeout" | "unreachable" | "too_many_redirects" | "http_status" | "no_items" | "error";
export type Usage = { model: string; input_tokens: number; output_tokens: number };
@@ -34,15 +38,70 @@ export const PURPOSES = ["screening", "extraction", "probe"] as const;
export type Purpose = (typeof PURPOSES)[number];
export type ListedItem = { url: string; title: string; published_on?: string };
-export async function createCrawlerToken(name: string) {
+// Made by the owner in the system settings (design §6.2), or for a local crawler with
+// `pnpm crawler-token`, which no one logs in for. The token is shown once: only its SHA-256 is
+// kept.
+export async function createCrawlerToken(name: string, owner?: Owner) {
const token = newToken();
- await db
- .insertInto("crawler_tokens")
- .values({ name, token_hash: hashToken(token) })
- .execute();
+ await db.transaction().execute(async (trx) => {
+ const { id } = await trx
+ .insertInto("crawler_tokens")
+ .values({ name, token_hash: hashToken(token) })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+ if (owner) {
+ await recordAudit(trx, {
+ user_id: owner.user_id,
+ action: "create",
+ target_type: "crawler_token",
+ target_id: id,
+ detail: { via: owner.via, name },
+ });
+ }
+ });
return token;
}
+export async function listCrawlerTokens() {
+ const rows = await db
+ .selectFrom("crawler_tokens")
+ .select(["id", "name", "created_at", "last_seen_at", "revoked_at"])
+ .orderBy("id", "desc")
+ .execute();
+ return rows.map((row) => ({
+ ...row,
+ created_at: unix(row.created_at),
+ last_seen_at: row.last_seen_at && unix(row.last_seen_at),
+ revoked_at: row.revoked_at && unix(row.revoked_at),
+ }));
+}
+
+// A revoked token stops working at once: every request looks it up (crawlerTokenId).
+export async function revokeCrawlerToken(owner: Owner, id: number) {
+ await db.transaction().execute(async (trx) => {
+ const token = await trx
+ .selectFrom("crawler_tokens")
+ .select(["name", "revoked_at"])
+ .where("id", "=", id)
+ .forUpdate()
+ .executeTakeFirst();
+ if (!token) throw new AppError(404, "not_found", "令牌不存在");
+ if (token.revoked_at) throw new AppError(409, "already_revoked", "这个令牌已经作废了");
+ await trx
+ .updateTable("crawler_tokens")
+ .set({ revoked_at: new Date() })
+ .where("id", "=", id)
+ .execute();
+ await recordAudit(trx, {
+ user_id: owner.user_id,
+ action: "revoke",
+ target_type: "crawler_token",
+ target_id: id,
+ detail: { via: owner.via, name: token.name },
+ });
+ });
+}
+
export async function crawlerTokenId(token: string) {
const row = await db
.selectFrom("crawler_tokens")
diff --git a/web/server/modules/ingest/token-routes.ts b/web/server/modules/ingest/token-routes.ts
new file mode 100644
index 0000000..d30b243
--- /dev/null
+++ b/web/server/modules/ingest/token-routes.ts
@@ -0,0 +1,103 @@
+import { createRoute, z } from "@hono/zod-openapi";
+import {
+ authorize,
+ createRouter,
+ errorResponse,
+ IdParam,
+ Int,
+ named,
+ UnixSeconds,
+} from "../../platform/http.ts";
+import { createCrawlerToken, listCrawlerTokens, revokeCrawlerToken } from "./service.ts";
+
+// The owner's crawler tokens, in the system settings (design §6.2).
+
+function json(schema: T) {
+ return { "application/json": { schema } };
+}
+
+const CrawlerToken = z
+ .object({
+ id: Int,
+ name: z.string(),
+ created_at: UnixSeconds,
+ last_seen_at: UnixSeconds.nullable().openapi({ description: "上次来领活;为空是还没来过" }),
+ revoked_at: UnixSeconds.nullable().openapi({ description: "为空是还在用" }),
+ })
+ .openapi("CrawlerToken");
+
+const errors = {
+ 401: errorResponse("没有登录"),
+ 403: errorResponse("只有站长能操作"),
+};
+
+export const tokenRoutes = createRouter()
+ .openapi(
+ createRoute({
+ method: "get",
+ path: "/admin/crawler-tokens",
+ summary: "抓取节点令牌",
+ responses: {
+ 200: { description: "令牌,新的在前", content: json(z.array(CrawlerToken)) },
+ ...errors,
+ },
+ }),
+ async (c) => {
+ authorize(c, "owner");
+ return c.json(await listCrawlerTokens(), 200);
+ },
+ )
+ .openapi(
+ createRoute({
+ method: "post",
+ path: "/admin/crawler-tokens",
+ summary: "生成抓取节点令牌",
+ description: "返回的令牌只出现这一次,库里只存 SHA-256。",
+ request: {
+ body: {
+ required: true,
+ content: json(
+ z.strictObject({
+ name: z
+ .string(named("名字"))
+ .trim()
+ .min(1)
+ .max(100)
+ .openapi({ description: "用在哪个抓取节点,比如「上海」" }),
+ }),
+ ),
+ },
+ },
+ responses: {
+ 201: { description: "新令牌", content: json(z.object({ token: z.string() })) },
+ 400: errorResponse("请求格式有误"),
+ ...errors,
+ },
+ }),
+ async (c) => {
+ const { id } = authorize(c, "owner");
+ const token = await createCrawlerToken(c.req.valid("json").name, { user_id: id, via: "web" });
+ return c.json({ token }, 201);
+ },
+ )
+ .openapi(
+ createRoute({
+ method: "post",
+ path: "/admin/crawler-tokens/{id}/revoke",
+ summary: "作废抓取节点令牌",
+ description: "马上生效:用它的抓取节点之后的请求都返回 401。",
+ request: { params: IdParam },
+ responses: {
+ 204: { description: "已作废" },
+ 400: errorResponse("编号不对"),
+ ...errors,
+ 404: errorResponse("令牌不存在"),
+ 409: errorResponse("已经作废了"),
+ },
+ }),
+ async (c) => {
+ const { id } = authorize(c, "owner");
+ await revokeCrawlerToken({ user_id: id, via: "web" }, c.req.valid("param").id);
+ return c.body(null, 204);
+ },
+ );
diff --git a/web/server/modules/ingest/tokens.test.ts b/web/server/modules/ingest/tokens.test.ts
new file mode 100644
index 0000000..da1df84
--- /dev/null
+++ b/web/server/modules/ingest/tokens.test.ts
@@ -0,0 +1,60 @@
+import { testClient } from "hono/testing";
+import { beforeAll, expect, test } from "vitest";
+import { fakeUsers, type FakeUser } from "../../../test/fake-mousehole.ts";
+import { app } from "../../app.ts";
+import { db } from "../../platform/db.ts";
+import { env } from "../../platform/env.ts";
+import { login } from "../accounts/service.ts";
+
+const v1 = testClient(app).api.v1;
+const tokens = v1.admin["crawler-tokens"];
+
+let admin = { headers: {} };
+let owner = admin;
+
+async function session(profile: FakeUser) {
+ const token = await login(profile, "fake", { ip: null, userAgent: "test" });
+ return { headers: { cookie: `mouseclock_session=${token}`, origin: env.PUBLIC_URL } };
+}
+
+beforeAll(async () => {
+ [admin, owner] = await Promise.all([session(fakeUsers[2]), session(fakeUsers[3])]);
+});
+
+test("the owner makes a token the crawler can use, and revoking it stops it at once", async () => {
+ expect((await tokens.$post({ json: { name: " " } }, owner)).status).toBe(400);
+ const made = await tokens.$post({ json: { name: "上海" } }, owner);
+ if (made.status !== 201) throw new Error(`status ${made.status}`);
+ const { token } = await made.json();
+ const crawler = { headers: { authorization: `Bearer ${token}` } };
+ expect((await v1.crawler.work.$get({}, crawler)).status).toBe(200);
+
+ const list = await tokens.$get({}, owner);
+ if (list.status !== 200) throw new Error(`status ${list.status}`);
+ const [listed] = await list.json();
+ expect(listed).toMatchObject({ name: "上海", revoked_at: null });
+ expect(listed.last_seen_at).not.toBeNull();
+
+ const revoke = (as: typeof owner) =>
+ tokens[":id"].revoke.$post({ param: { id: String(listed.id) } }, as);
+ expect((await revoke(owner)).status).toBe(204);
+ expect((await v1.crawler.work.$get({}, crawler)).status).toBe(401);
+ expect((await revoke(owner)).status).toBe(409);
+
+ const audit = await db
+ .selectFrom("audit_log")
+ .select("action")
+ .where("target_type", "=", "crawler_token")
+ .where("target_id", "=", listed.id)
+ .orderBy("id")
+ .execute();
+ expect(audit.map((row) => row.action)).toEqual(["create", "revoke"]);
+});
+
+test("only the owner sees, makes and revokes tokens", async () => {
+ expect((await tokens.$get({}, admin)).status).toBe(403);
+ expect((await tokens.$post({ json: { name: "x" } }, admin)).status).toBe(403);
+ const revoke = tokens[":id"].revoke.$post({ param: { id: "1" } }, admin);
+ expect((await revoke).status).toBe(403);
+ expect((await tokens.$get({})).status).toBe(401);
+});
diff --git a/web/server/modules/review/service.ts b/web/server/modules/review/service.ts
index 7fe201a..8896237 100644
--- a/web/server/modules/review/service.ts
+++ b/web/server/modules/review/service.ts
@@ -22,7 +22,7 @@ export async function recordAudit(
entry: {
user_id: number;
action: string;
- target_type: "school" | "college" | "source" | "user" | "review_item";
+ target_type: "school" | "college" | "source" | "user" | "review_item" | "crawler_token";
target_id: number;
detail: Record;
},