Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

comment-moderation.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

comment-moderation.tsxBlame469 lines · 1 contributor
cb5a796Claude1/**
2 * Comment moderation queue UI.
3 *
4 * Owner-only page at `/:owner/:repo/comments/pending` that lists every
5 * comment from a non-collaborator awaiting approval, with per-row
6 * Approve / Reject / Mark spam buttons plus a "trust this user" tickbox
7 * (alongside Approve) that promotes the commenter to the per-repo
8 * trust list so future comments auto-approve.
9 *
10 * Bulk-action toolbar: "Reject all" / "Mark all as spam" applied to
11 * every checked row. The single-row buttons are POST forms; the
12 * bulk submitter is a single form whose checkboxes carry the comment
13 * ids and `kind:<commentId>` mapping so the handler knows which table
14 * each id lives in.
15 *
16 * All styling is scoped under `.modq-*` per the build bible's "no
17 * touching shared components" rule.
18 */
19
20import { Hono } from "hono";
21import { eq } from "drizzle-orm";
22import { db } from "../db";
23import { repositories, users } from "../db/schema";
24import { Layout } from "../views/layout";
25import { RepoHeader } from "../views/components";
26import { softAuth, requireAuth } from "../middleware/auth";
27import type { AuthEnv } from "../middleware/auth";
28import { requireRepoAccess } from "../middleware/repo-access";
29import {
30 listPendingComments,
31 approveComment,
32 rejectComment,
33 markAsSpam,
34 type CommentKind,
35} from "../lib/comment-moderation";
36
37const moderationRoutes = new Hono<AuthEnv>();
38
39const QUEUE_STYLES = `
40 .modq-shell {
41 max-width: 920px;
42 margin: 18px auto 60px;
43 padding: 0 16px;
44 }
45 .modq-head {
46 margin: 12px 0 18px;
47 padding: 18px 22px;
48 border-radius: 14px;
49 background: linear-gradient(135deg, rgba(245, 191, 79, 0.10), rgba(140, 109, 255, 0.06));
50 border: 1px solid var(--border, #2a2f3a);
51 }
52 .modq-head h1 {
53 margin: 0 0 6px 0;
54 font-size: 22px;
55 font-weight: 700;
56 color: var(--text-strong, #fff);
57 letter-spacing: -0.01em;
58 }
59 .modq-head p {
60 margin: 0;
61 color: var(--text-muted, #9aa4b2);
62 font-size: 14px;
63 line-height: 1.5;
64 }
65 .modq-empty {
66 margin: 32px 0;
67 padding: 40px 22px;
68 text-align: center;
69 border-radius: 12px;
70 border: 1px dashed var(--border, #2a2f3a);
71 color: var(--text-muted, #9aa4b2);
72 }
73 .modq-empty strong { color: var(--text-strong, #fff); display: block; margin-bottom: 6px; }
74 .modq-bulkbar {
75 display: flex;
76 align-items: center;
77 gap: 10px;
78 margin: 12px 0;
79 padding: 10px 14px;
80 border-radius: 10px;
81 background: var(--bg-elevated, #161b22);
82 border: 1px solid var(--border, #2a2f3a);
83 font-size: 13px;
84 }
85 .modq-bulkbar-left { flex: 1 1 auto; color: var(--text-muted, #9aa4b2); }
86 .modq-list { display: flex; flex-direction: column; gap: 12px; }
87 .modq-row {
88 padding: 14px 16px;
89 border-radius: 12px;
90 background: var(--bg-elevated, #161b22);
91 border: 1px solid var(--border, #2a2f3a);
92 }
93 .modq-row-head {
94 display: flex;
95 align-items: center;
96 gap: 10px;
97 margin-bottom: 10px;
98 font-size: 13px;
99 color: var(--text-muted, #9aa4b2);
100 }
101 .modq-avatar {
102 width: 24px; height: 24px;
103 border-radius: 50%;
104 background: var(--bg, #0d1117);
105 border: 1px solid var(--border, #2a2f3a);
106 display: inline-flex; align-items: center; justify-content: center;
107 font-weight: 700; font-size: 11px;
108 color: var(--text-strong, #fff);
109 overflow: hidden;
110 }
111 .modq-avatar img { width: 100%; height: 100%; object-fit: cover; }
112 .modq-username { color: var(--text-strong, #fff); font-weight: 600; }
113 .modq-target { color: var(--accent, #8c6dff); text-decoration: none; }
114 .modq-target:hover { text-decoration: underline; }
115 .modq-body {
116 padding: 10px 12px;
117 background: var(--bg, #0d1117);
118 border-radius: 8px;
119 margin-bottom: 12px;
120 font-size: 13.5px;
121 white-space: pre-wrap;
122 word-break: break-word;
123 color: var(--text, #e6edf3);
124 max-height: 220px;
125 overflow: auto;
126 }
127 .modq-actions {
128 display: flex;
129 align-items: center;
130 flex-wrap: wrap;
131 gap: 10px;
132 }
133 .modq-btn {
134 appearance: none;
135 border: 1px solid var(--border, #2a2f3a);
136 background: var(--bg, #0d1117);
137 color: var(--text, #e6edf3);
138 border-radius: 6px;
139 padding: 6px 12px;
140 font-size: 13px;
141 font-weight: 600;
142 cursor: pointer;
143 text-decoration: none;
144 display: inline-flex;
145 align-items: center;
146 gap: 6px;
147 }
148 .modq-btn:hover { border-color: var(--text-muted, #9aa4b2); }
149 .modq-btn-approve {
150 color: #56d364;
151 border-color: rgba(86, 211, 100, 0.45);
152 background: rgba(86, 211, 100, 0.08);
153 }
154 .modq-btn-approve:hover { background: rgba(86, 211, 100, 0.16); }
155 .modq-btn-reject {
156 color: #f5bf4f;
157 border-color: rgba(245, 191, 79, 0.45);
158 background: rgba(245, 191, 79, 0.08);
159 }
160 .modq-btn-reject:hover { background: rgba(245, 191, 79, 0.16); }
161 .modq-btn-spam {
162 color: #f85149;
163 border-color: rgba(248, 81, 73, 0.45);
164 background: rgba(248, 81, 73, 0.08);
165 }
166 .modq-btn-spam:hover { background: rgba(248, 81, 73, 0.16); }
167 .modq-trust {
168 margin-left: auto;
169 display: inline-flex;
170 align-items: center;
171 gap: 6px;
172 font-size: 12.5px;
173 color: var(--text-muted, #9aa4b2);
174 }
175 .modq-trust input { transform: translateY(1px); }
176 .modq-rowcheck {
177 margin-right: 6px;
178 transform: translateY(2px);
179 }
180 .modq-inlineform { display: inline; }
181`;
182
183// ---------------------------------------------------------------------------
184// GET /:owner/:repo/comments/pending — render the queue
185// ---------------------------------------------------------------------------
186
187moderationRoutes.get(
188 "/:owner/:repo/comments/pending",
189 softAuth,
190 requireAuth,
191 requireRepoAccess("read"),
192 async (c) => {
193 const { owner: ownerName, repo: repoName } = c.req.param();
194 const user = c.get("user")!;
195 const repository = c.get("repository") as {
196 id: string;
197 ownerId: string;
198 };
199
200 // Owner-only. Collaborators with write access can comment freely
201 // but the moderation decision is the OWNER's call — see the platform
202 // owner's verbatim brief in `comment-moderation.ts`.
203 if (repository.ownerId !== user.id) {
204 return c.html(
205 <Layout title="Forbidden" user={user}>
206 <div style="max-width: 600px; margin: 80px auto; padding: 24px; text-align: center;">
207 <h1>403 — Moderator only</h1>
208 <p>Only the repository owner can review pending comments.</p>
209 </div>
210 </Layout>,
211 403
212 );
213 }
214
215 const pending = await listPendingComments(repository.id);
216
217 return c.html(
218 <Layout title={`Pending comments — ${ownerName}/${repoName}`} user={user}>
219 <RepoHeader owner={ownerName} repo={repoName} />
220 <style dangerouslySetInnerHTML={{ __html: QUEUE_STYLES }} />
221 <div class="modq-shell">
222 <div class="modq-head">
223 <h1>Comment moderation</h1>
224 <p>
225 Non-collaborators on public repositories can leave a comment,
226 but it stays hidden until you approve it. This stops drive-by
227 comments that exist only to make someone look like a contributor.
228 </p>
229 </div>
230
231 {pending.length === 0 ? (
232 <div class="modq-empty">
233 <strong>Inbox zero.</strong>
234 No pending comments for {ownerName}/{repoName}.
235 </div>
236 ) : (
237 <form
238 method="post"
239 action={`/${ownerName}/${repoName}/comments/pending/bulk`}
240 >
241 <div class="modq-bulkbar">
242 <div class="modq-bulkbar-left">
243 {pending.length} comment{pending.length === 1 ? "" : "s"}{" "}
244 awaiting your decision. Tick rows to bulk-action.
245 </div>
246 <button
247 type="submit"
248 name="bulk_action"
249 value="reject"
250 class="modq-btn modq-btn-reject"
251 >
252 Reject checked
253 </button>
254 <button
255 type="submit"
256 name="bulk_action"
257 value="spam"
258 class="modq-btn modq-btn-spam"
259 >
260 Mark checked as spam
261 </button>
262 </div>
263
264 <div class="modq-list">
265 {pending.map((row) => (
266 <article class="modq-row">
267 <header class="modq-row-head">
268 <input
269 type="checkbox"
270 name="comment_ids"
271 value={`${row.kind}:${row.commentId}`}
272 class="modq-rowcheck"
273 aria-label={`Select comment by ${row.commenter.username}`}
274 />
275 <span class="modq-avatar" aria-hidden="true">
276 {row.commenter.avatarUrl ? (
277 <img src={row.commenter.avatarUrl} alt="" />
278 ) : (
279 row.commenter.username.slice(0, 1).toUpperCase()
280 )}
281 </span>
282 <span class="modq-username">{row.commenter.username}</span>
283 <span>commented on</span>
284 <a class="modq-target" href={row.threadUrl}>
285 {row.kind === "issue" ? "issue" : "PR"} #
286 {row.threadNumber} — {row.threadTitle}
287 </a>
288 </header>
289 <div class="modq-body">{row.body}</div>
290 <div class="modq-actions">
291 <form
292 method="post"
293 action={`/${ownerName}/${repoName}/comments/${row.kind}/${row.commentId}/approve`}
294 class="modq-inlineform"
295 >
296 <button type="submit" class="modq-btn modq-btn-approve">
297 Approve
298 </button>
299 <label class="modq-trust" title="Future comments from this user on this repo will be auto-approved.">
300 <input
301 type="checkbox"
302 name="trust"
303 value="1"
304 />
305 Trust this user
306 </label>
307 </form>
308 <form
309 method="post"
310 action={`/${ownerName}/${repoName}/comments/${row.kind}/${row.commentId}/reject`}
311 class="modq-inlineform"
312 >
313 <button type="submit" class="modq-btn modq-btn-reject">
314 Reject
315 </button>
316 </form>
317 <form
318 method="post"
319 action={`/${ownerName}/${repoName}/comments/${row.kind}/${row.commentId}/spam`}
320 class="modq-inlineform"
321 >
322 <button type="submit" class="modq-btn modq-btn-spam">
323 Mark spam
324 </button>
325 </form>
326 </div>
327 </article>
328 ))}
329 </div>
330 </form>
331 )}
332 </div>
333 </Layout>
334 );
335 }
336);
337
338// ---------------------------------------------------------------------------
339// Per-row actions: approve / reject / spam
340// ---------------------------------------------------------------------------
341
342function ownerGate(
343 c: any
344): { user: { id: string }; repository: { id: string; ownerId: string } } | Response {
345 const user = c.get("user")! as { id: string };
346 const repository = c.get("repository") as { id: string; ownerId: string };
347 if (repository.ownerId !== user.id) {
348 return c.text("Forbidden", 403);
349 }
350 return { user, repository };
351}
352
353moderationRoutes.post(
354 "/:owner/:repo/comments/:kind/:commentId/approve",
355 softAuth,
356 requireAuth,
357 requireRepoAccess("read"),
358 async (c) => {
359 const gate = ownerGate(c);
360 if (gate instanceof Response) return gate;
361 const kind = c.req.param("kind") as CommentKind;
362 if (kind !== "issue" && kind !== "pr") return c.notFound();
363 const commentId = c.req.param("commentId");
364 const body = await c.req.parseBody().catch(() => ({}) as any);
365 const alsoTrust = String(body.trust || "") === "1";
366 await approveComment({
367 commentId,
368 kind,
369 moderatorUserId: gate.user.id,
370 alsoTrust,
371 });
372 const { owner, repo } = c.req.param();
373 return c.redirect(`/${owner}/${repo}/comments/pending`);
374 }
375);
376
377moderationRoutes.post(
378 "/:owner/:repo/comments/:kind/:commentId/reject",
379 softAuth,
380 requireAuth,
381 requireRepoAccess("read"),
382 async (c) => {
383 const gate = ownerGate(c);
384 if (gate instanceof Response) return gate;
385 const kind = c.req.param("kind") as CommentKind;
386 if (kind !== "issue" && kind !== "pr") return c.notFound();
387 const commentId = c.req.param("commentId");
388 const body = await c.req.parseBody().catch(() => ({}) as any);
389 const reason = String(body.reason || "").trim() || undefined;
390 await rejectComment({
391 commentId,
392 kind,
393 moderatorUserId: gate.user.id,
394 reason,
395 });
396 const { owner, repo } = c.req.param();
397 return c.redirect(`/${owner}/${repo}/comments/pending`);
398 }
399);
400
401moderationRoutes.post(
402 "/:owner/:repo/comments/:kind/:commentId/spam",
403 softAuth,
404 requireAuth,
405 requireRepoAccess("read"),
406 async (c) => {
407 const gate = ownerGate(c);
408 if (gate instanceof Response) return gate;
409 const kind = c.req.param("kind") as CommentKind;
410 if (kind !== "issue" && kind !== "pr") return c.notFound();
411 const commentId = c.req.param("commentId");
412 await markAsSpam({
413 commentId,
414 kind,
415 moderatorUserId: gate.user.id,
416 });
417 const { owner, repo } = c.req.param();
418 return c.redirect(`/${owner}/${repo}/comments/pending`);
419 }
420);
421
422// Bulk action — reject or spam every checked row.
423moderationRoutes.post(
424 "/:owner/:repo/comments/pending/bulk",
425 softAuth,
426 requireAuth,
427 requireRepoAccess("read"),
428 async (c) => {
429 const gate = ownerGate(c);
430 if (gate instanceof Response) return gate;
431 const { owner, repo } = c.req.param();
432 const body = await c.req.parseBody({ all: true });
433 const action = String(body.bulk_action || "");
434 const idsRaw = body.comment_ids;
435 const ids = Array.isArray(idsRaw)
436 ? idsRaw.map(String)
437 : idsRaw
438 ? [String(idsRaw)]
439 : [];
440
441 for (const compound of ids) {
442 const [kind, commentId] = compound.split(":");
443 if (kind !== "issue" && kind !== "pr") continue;
444 if (!commentId) continue;
445 if (action === "spam") {
446 await markAsSpam({
447 commentId,
448 kind: kind as CommentKind,
449 moderatorUserId: gate.user.id,
450 });
451 } else if (action === "reject") {
452 await rejectComment({
453 commentId,
454 kind: kind as CommentKind,
455 moderatorUserId: gate.user.id,
456 });
457 }
458 }
459 return c.redirect(`/${owner}/${repo}/comments/pending`);
460 }
461);
462
463// Silence unused-import noise from drizzle helpers we may not call here.
464void eq;
465void db;
466void repositories;
467void users;
468
469export default moderationRoutes;