Skip to content

Commit 686cc6b

Browse files
committed
feat: restrict job posting with user roles
1 parent 33d5ef9 commit 686cc6b

37 files changed

Lines changed: 1137 additions & 65 deletions

File tree

‎api/openapi.json‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@
385385
}
386386
},
387387
"403": {
388-
"description": "禁言或人机验证失败或新用户限制",
388+
"description": "禁言、人机验证失败、新用户限制或招聘发布未授权",
389389
"content": {
390390
"application/json": {
391391
"schema": {
@@ -6172,6 +6172,14 @@
61726172
},
61736173
"sort_order": {
61746174
"type": "number"
6175+
},
6176+
"scope": {
6177+
"type": "string",
6178+
"enum": [
6179+
"public",
6180+
"admin"
6181+
],
6182+
"default": "public"
61756183
}
61766184
},
61776185
"required": [

‎apps/api/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"dev": "tsx watch src/index.ts",
88
"build": "tsc",
99
"start": "node dist/index.js",
10-
"test": "tsx --test test/mail-template.test.ts test/health.test.ts test/admin-pagination.test.ts test/ip-ban-rules.test.ts test/moderation-helpers.test.ts",
10+
"test": "tsx --test test/mail-template.test.ts test/health.test.ts test/admin-pagination.test.ts test/ip-ban-rules.test.ts test/moderation-helpers.test.ts test/roles-permissions.test.ts",
1111
"worker:moderation": "tsx src/worker/moderation-scan.ts",
1212
"typecheck": "tsc --noEmit",
1313
"gen:openapi": "tsx scripts/gen-openapi.ts"

‎apps/api/src/lib/db.ts‎

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ import {
1313
jobMeta,
1414
tabs,
1515
zones,
16+
userRoles,
1617
} from "@cnode/db";
17-
import { eq, and, desc, inArray, sql, count } from "drizzle-orm";
18+
import { eq, and, desc, inArray, sql, count, isNull } from "drizzle-orm";
1819
import { v4 as uuidv4 } from "uuid";
1920
import { boolEq, boolValue } from "./db-compat";
2021

@@ -179,6 +180,62 @@ export const userQueries = {
179180
},
180181
};
181182

183+
export const roleQueries = {
184+
async listByUserId(userId: number): Promise<string[]> {
185+
const db = getDb();
186+
const rows = await db
187+
.select({ role: userRoles.role })
188+
.from(userRoles)
189+
.where(and(eq(userRoles.userId, userId), isNull(userRoles.revokedAt)));
190+
return rows.map((row) => row.role);
191+
},
192+
193+
async listByUserIds(userIds: number[]): Promise<Map<number, string[]>> {
194+
const result = new Map<number, string[]>();
195+
if (userIds.length === 0) return result;
196+
197+
const db = getDb();
198+
const rows = await db
199+
.select({ userId: userRoles.userId, role: userRoles.role })
200+
.from(userRoles)
201+
.where(and(inArray(userRoles.userId, userIds), isNull(userRoles.revokedAt)));
202+
for (const row of rows) {
203+
const roles = result.get(row.userId) || [];
204+
roles.push(row.role);
205+
result.set(row.userId, roles);
206+
}
207+
return result;
208+
},
209+
210+
async hasRole(userId: number, role: string): Promise<boolean> {
211+
const db = getDb();
212+
const rows = await db
213+
.select({ id: userRoles.id })
214+
.from(userRoles)
215+
.where(and(eq(userRoles.userId, userId), eq(userRoles.role, role), isNull(userRoles.revokedAt)))
216+
.limit(1);
217+
return rows.length > 0;
218+
},
219+
220+
async grant(userId: number, role: string, grantedBy: number, reason?: string | null) {
221+
const db = getDb();
222+
await db
223+
.insert(userRoles)
224+
.values({ userId, role, grantedBy, reason: reason || null, createAt: new Date(), updateAt: new Date() })
225+
.onConflictDoNothing();
226+
return roleQueries.listByUserId(userId);
227+
},
228+
229+
async revoke(userId: number, role: string) {
230+
const db = getDb();
231+
await db
232+
.update(userRoles)
233+
.set({ revokedAt: new Date(), updateAt: new Date() })
234+
.where(and(eq(userRoles.userId, userId), eq(userRoles.role, role), isNull(userRoles.revokedAt)));
235+
return roleQueries.listByUserId(userId);
236+
},
237+
};
238+
182239
function topicConditions(where: any) {
183240
const conditions: any[] = [];
184241
if (where.deleted !== undefined) {
@@ -204,12 +261,14 @@ function topicConditions(where: any) {
204261
if (where.publicVisible) {
205262
conditions.push(boolEq(topics.deleted, false));
206263
conditions.push(sql`coalesce(${topics.status}, 'published') <> 'deleted'`);
207-
conditions.push(
208-
sql`(${topics.tab} is null or ${topics.tab} not in (${sql.join(
209-
INTERNAL_TABS.map((tab) => sql`${tab}`),
210-
sql`, `,
211-
)}))`,
212-
);
264+
if (!where.includeInternalTabs) {
265+
conditions.push(
266+
sql`(${topics.tab} is null or ${topics.tab} not in (${sql.join(
267+
INTERNAL_TABS.map((tab) => sql`${tab}`),
268+
sql`, `,
269+
)}))`,
270+
);
271+
}
213272
conditions.push(
214273
sql`exists (select 1 from ${users} where ${users.id} = ${topics.authorId} and ${boolEq(users.isBlock, false)})`,
215274
);

‎apps/api/src/middleware/auth.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { createMiddleware } from "hono/factory";
22
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
3-
import { userQueries } from "../lib/db";
3+
import { roleQueries, userQueries } from "../lib/db";
44

55
export interface AuthVars {
66
user: Awaited<ReturnType<typeof userQueries.getById>> | null;
77
isLogin: boolean;
88
isAdmin: boolean;
99
isMod: boolean;
10+
roles: string[];
1011
}
1112

1213
export function setSessionCookie(
@@ -58,13 +59,15 @@ export const authMiddleware = () =>
5859
const admins = (process.env.APP_ADMINS || "").split(",").filter(Boolean);
5960
const moderators = (process.env.APP_MODERATORS || "").split(",").filter(Boolean);
6061

62+
const roles = user ? await roleQueries.listByUserId(user.id) : [];
6163
const isAdmin = user ? admins.includes(user.loginname) : false;
62-
const isMod = user ? moderators.includes(user.loginname) || isAdmin : false;
64+
const isMod = user ? moderators.includes(user.loginname) || roles.includes("moderator") || isAdmin : false;
6365

6466
c.set("user", user);
6567
c.set("isLogin", !!user);
6668
c.set("isAdmin", isAdmin);
6769
c.set("isMod", isMod);
70+
c.set("roles", roles);
6871

6972
await next();
7073
});

‎apps/api/src/routes/admin.ts‎

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
settingQueries,
1313
zoneQueries,
1414
tabQueries,
15+
roleQueries,
1516
} from "../lib/db";
1617
import {
1718
auditLogs,
@@ -42,7 +43,7 @@ import { decrementScoreAndReplyCount } from "../lib/score";
4243
import { isValidIpRule } from "../middleware/ip-ban";
4344
import { applyProgressivePenalty } from "../lib/penalty";
4445
import { userSummary } from "../lib/format";
45-
import { createReportBodySchema, errorResponseSchema } from "@cnode/shared";
46+
import { createReportBodySchema, errorResponseSchema, roleAssignmentSchema, userRoleSchema } from "@cnode/shared";
4647

4748
const admin = new OpenAPIHono<{
4849
Variables: AuthVars;
@@ -202,7 +203,7 @@ admin.post("/topic/:tid/good", modRequired(), async (c) => {
202203
return c.json({ success: true, message: topic.good ? "已取消加精" : "已加精" });
203204
});
204205

205-
admin.post("/topic/:tid/lock", adminRequired(), async (c) => {
206+
admin.post("/topic/:tid/lock", modRequired(), async (c) => {
206207
const tid = Number(c.req.param("tid"));
207208
const topic = await topicQueries.getById(tid);
208209
if (!topic) return c.json({ success: false, error_msg: "话题不存在" }, 404);
@@ -252,7 +253,42 @@ admin.get("/admin/users", adminRequired(), async (c) => {
252253
let totalQuery = db.select({ c: count() }).from(users).$dynamic();
253254
if (where) { listQuery = listQuery.where(where) as any; totalQuery = totalQuery.where(where) as any; }
254255
const [list, totalResult] = await Promise.all([listQuery.orderBy(desc(users.createAt)).limit(pagination.limit).offset(pagination.offset), totalQuery]);
255-
return c.json(paginated(list.map((u: any) => ({ id: u.id, loginname: u.loginname, email: u.email, avatar_url: u.avatar, score: u.score, topic_count: u.topicCount, reply_count: u.replyCount, is_block: !!u.isBlock, is_muted: !!u.isMuted || !!u.isBlock, active: !!u.active, create_at: u.createAt })), Number(totalResult[0]?.c || 0), pagination));
256+
const rolesByUserId = await roleQueries.listByUserIds(list.map((u: any) => u.id));
257+
return c.json(paginated(list.map((u: any) => ({ id: u.id, loginname: u.loginname, email: u.email, avatar_url: u.avatar, score: u.score, topic_count: u.topicCount, reply_count: u.replyCount, roles: rolesByUserId.get(u.id) || [], is_block: !!u.isBlock, is_muted: !!u.isMuted || !!u.isBlock, active: !!u.active, create_at: u.createAt })), Number(totalResult[0]?.c || 0), pagination));
258+
});
259+
260+
admin.get("/admin/users/:id/roles", adminRequired(), async (c) => {
261+
const id = Number(c.req.param("id"));
262+
if (!id || Number.isNaN(id)) return c.json({ success: false, error_msg: "用户不存在" }, 404);
263+
const target = await userQueries.getById(id);
264+
if (!target) return c.json({ success: false, error_msg: "用户不存在" }, 404);
265+
const roles = await roleQueries.listByUserId(id);
266+
return c.json({ success: true, data: { user_id: id, roles } });
267+
});
268+
269+
admin.post("/admin/users/:id/roles", adminRequired(), async (c) => {
270+
const id = Number(c.req.param("id"));
271+
const target = id > 0 && !Number.isNaN(id) ? await userQueries.getById(id) : null;
272+
if (!target) return c.json({ success: false, error_msg: "用户不存在" }, 404);
273+
const parsed = roleAssignmentSchema.safeParse(await c.req.json().catch(() => ({})));
274+
if (!parsed.success) return c.json({ success: false, error_msg: "角色无效" }, 422);
275+
const user = c.get("user")!;
276+
const roles = await roleQueries.grant(id, parsed.data.role, user.id, parsed.data.reason);
277+
await auditQueries.log(user.id, user.loginname, "grant_role", { type: "user", id: String(id), name: target.loginname }, "success", JSON.stringify({ role: parsed.data.role, reason: parsed.data.reason || null }));
278+
return c.json({ success: true, data: { user_id: id, roles } });
279+
});
280+
281+
admin.delete("/admin/users/:id/roles/:role", adminRequired(), async (c) => {
282+
const id = Number(c.req.param("id"));
283+
const target = id > 0 && !Number.isNaN(id) ? await userQueries.getById(id) : null;
284+
if (!target) return c.json({ success: false, error_msg: "用户不存在" }, 404);
285+
const role = c.req.param("role");
286+
const parsed = userRoleSchema.safeParse(role);
287+
if (!parsed.success) return c.json({ success: false, error_msg: "角色无效" }, 422);
288+
const user = c.get("user")!;
289+
const roles = await roleQueries.revoke(id, parsed.data);
290+
await auditQueries.log(user.id, user.loginname, "revoke_role", { type: "user", id: String(id), name: target.loginname }, "success", JSON.stringify({ role: parsed.data }));
291+
return c.json({ success: true, data: { user_id: id, roles } });
256292
});
257293

258294
admin.post("/user/:name/block", adminRequired(), async (c) => {
@@ -740,6 +776,7 @@ admin.get("/admin/tabs", adminRequired(), async (c) => {
740776
label: r.label,
741777
visible: !!r.visible,
742778
sort_order: r.sortOrder || 0,
779+
scope: r.scope || "public",
743780
}));
744781
return c.json({ success: true, data });
745782
});
@@ -765,6 +802,7 @@ admin.patch("/admin/tabs/:id", adminRequired(), async (c) => {
765802
label: updated.label,
766803
visible: !!updated.visible,
767804
sort_order: updated.sortOrder || 0,
805+
scope: updated.scope || "public",
768806
},
769807
});
770808
});

‎apps/api/src/routes/auth.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
githubUnbindSchema,
1515
errorResponseSchema,
1616
} from "@cnode/shared";
17-
import { auditQueries, settingQueries, userQueries } from "../lib/db";
17+
import { auditQueries, roleQueries, settingQueries, userQueries } from "../lib/db";
1818
import { sendActiveMail, sendResetPassMail } from "../lib/mail";
1919
import { setSessionCookie, clearSessionCookie, authMiddleware, type AuthVars } from "../middleware/auth";
2020
import { perIpPerDay, perUserPerDay } from "../middleware/rate-limit";
@@ -332,9 +332,10 @@ auth.openapi(meRoute, async (c) => {
332332
if (!user) return c.json({ success: false, data: null }, 200);
333333
const admins = (process.env.APP_ADMINS || "").split(",").filter(Boolean);
334334
const moderators = (process.env.APP_MODERATORS || "").split(",").filter(Boolean);
335+
const roles = await roleQueries.listByUserId(user.id);
335336
const isAdmin = admins.includes(user.loginname);
336-
const isMod = moderators.includes(user.loginname) || isAdmin;
337-
return c.json({ success: true, data: { loginname: user.loginname, email: user.email, github_username: user.githubUsername, github_bound: !!user.githubId, url: user.url, location: user.location, signature: user.signature, weibo: user.weibo, receive_reply_mail: !!user.receiveReplyMail, receive_at_mail: !!user.receiveAtMail, is_admin: isAdmin, is_mod: isMod, is_muted: !!user.isMuted || !!user.isBlock, is_block: !!user.isBlock } }, 200);
337+
const isMod = moderators.includes(user.loginname) || roles.includes("moderator") || isAdmin;
338+
return c.json({ success: true, data: { loginname: user.loginname, email: user.email, github_username: user.githubUsername, github_bound: !!user.githubId, url: user.url, location: user.location, signature: user.signature, weibo: user.weibo, receive_reply_mail: !!user.receiveReplyMail, receive_at_mail: !!user.receiveAtMail, is_admin: isAdmin, is_mod: isMod, roles, is_muted: !!user.isMuted || !!user.isBlock, is_block: !!user.isBlock } }, 200);
338339
});
339340

340341
// --- POST /auth/local/setting ---

‎apps/api/src/routes/topic.ts‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
22
import _ from "lodash";
3-
import { settingQueries, topicQueries, userQueries, replyQueries, jobMetaQueries } from "../lib/db";
3+
import { settingQueries, topicQueries, userQueries, replyQueries, jobMetaQueries, roleQueries } from "../lib/db";
44
import { incrementScoreAndTopicCount } from "../lib/score";
55
import { sendMessageToMentionUsers } from "../lib/at";
66
import { checkContent } from "../lib/moderation";
@@ -49,6 +49,18 @@ async function isNewUserForTopicGate(user: any) {
4949
return accountAgeHours < minHours || Number(user.replyCount || 0) < minReplies;
5050
}
5151

52+
export function canPostJobFromRoles(isAdmin: boolean, roles: string[]) {
53+
return isAdmin || roles.includes("recruiter");
54+
}
55+
56+
function isAdminUser(user: any) {
57+
return (process.env.APP_ADMINS || "").split(",").filter(Boolean).includes(user.loginname);
58+
}
59+
60+
async function canPostJob(user: any) {
61+
return canPostJobFromRoles(isAdminUser(user), await roleQueries.listByUserId(user.id));
62+
}
63+
5264
const listTopicsRoute = createRoute({
5365
method: "get",
5466
path: "/topics",
@@ -78,6 +90,10 @@ topic.openapi(listTopicsRoute, async (c) => {
7890
const { page, limit, tab, mdrender } = c.req.valid("query");
7991

8092
const query: any = {};
93+
const isAdmin = c.get("isAdmin");
94+
if (tab && INTERNAL_TABS.has(tab) && !isAdmin) {
95+
return c.json({ success: true as const, data: [], total: 0 }, 200);
96+
}
8197
if (!tab || tab === "all") {
8298
query.excludeTabs = ["job"];
8399
} else if (tab === "good") {
@@ -86,6 +102,7 @@ topic.openapi(listTopicsRoute, async (c) => {
86102
query.tab = tab;
87103
}
88104
query.publicVisible = true;
105+
query.includeInternalTabs = isAdmin;
89106

90107
const topicsList = await topicQueries.getByQuery(query, {
91108
limit,
@@ -283,7 +300,7 @@ const createTopicRoute = createRoute({
283300
content: { "application/json": { schema: errorResponseSchema } },
284301
},
285302
403: {
286-
description: "禁言或人机验证失败或新用户限制",
303+
description: "禁言、人机验证失败、新用户限制或招聘发布未授权",
287304
content: { "application/json": { schema: errorResponseSchema } },
288305
},
289306
422: {
@@ -313,6 +330,10 @@ topic.openapi(createTopicRoute, async (c) => {
313330

314331
const { title, tab, content } = body;
315332

333+
if (tab === "job" && !(await canPostJob(user))) {
334+
return c.json({ success: false as const, error_msg: "招聘发布需要授权" }, 403);
335+
}
336+
316337
const titleCheck = await checkContent(title);
317338
if (titleCheck.hit) {
318339
return c.json(
@@ -431,6 +452,14 @@ topic.openapi(updateTopicRoute, async (c) => {
431452
return c.json({ success: false as const, error_msg: "话题已锁定" }, 403);
432453
}
433454

455+
if (topicData.tab !== "job" && tab === "job" && !(await canPostJob(user))) {
456+
return c.json({ success: false as const, error_msg: "招聘发布需要授权" }, 403);
457+
}
458+
459+
if (topicData.tab === "job" && tab !== "job" && !c.get("isAdmin")) {
460+
return c.json({ success: false as const, error_msg: "招聘话题不能改为普通分类" }, 403);
461+
}
462+
434463
await topicQueries.updateTopic(tid, { title, tab, content });
435464

436465
if (tab === "job" && body.job_meta) {

‎apps/api/src/routes/zone.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ zone.openapi(listTabsRoute, async (c) => {
193193
label: r.label,
194194
visible: !!r.visible,
195195
sort_order: r.sortOrder || 0,
196+
scope: r.scope || "public",
196197
}));
197198
return c.json({ success: true as const, data }, 200);
198199
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
import { userRoleSchema } from "@cnode/shared";
4+
import { canRunTopicAction } from "../src/routes/admin";
5+
import { canPostJobFromRoles } from "../src/routes/topic";
6+
7+
test("job posting requires admin or recruiter", () => {
8+
assert.equal(canPostJobFromRoles(false, []), false);
9+
assert.equal(canPostJobFromRoles(false, ["moderator"]), false);
10+
assert.equal(canPostJobFromRoles(false, ["recruiter"]), true);
11+
assert.equal(canPostJobFromRoles(true, []), true);
12+
});
13+
14+
test("role schema rejects unknown roles", () => {
15+
assert.equal(userRoleSchema.safeParse("moderator").success, true);
16+
assert.equal(userRoleSchema.safeParse("recruiter").success, true);
17+
assert.equal(userRoleSchema.safeParse("admin").success, false);
18+
assert.equal(userRoleSchema.safeParse("owner").success, false);
19+
});
20+
21+
test("moderators can run only known topic governance actions", () => {
22+
assert.equal(canRunTopicAction("delete", false, true), true);
23+
assert.equal(canRunTopicAction("mute", false, true), true);
24+
assert.equal(canRunTopicAction("top", false, true), true);
25+
assert.equal(canRunTopicAction("unknown", false, true), false);
26+
assert.equal(canRunTopicAction("delete", false, false), false);
27+
});

0 commit comments

Comments
 (0)