Exosphere is a set of small, modular, self-hostable community tools built on the AT Protocol. app.exosphere.site
at main 510 lines 15 kB view raw
1import { Hono } from "hono"; 2import { z } from "zod"; 3import { getDb } from "@exosphere/core/db"; 4import { eq, and, count, sql, inArray, desc, asc } from "@exosphere/core/db/drizzle"; 5import { requireAuth, type AuthEnv } from "@exosphere/core/auth"; 6import { getActiveMemberRole } from "@exosphere/core/sphere"; 7import { requirePermission, checkPermission } from "@exosphere/core/permissions"; 8import { putPdsRecord, deletePdsRecord, generateRkey, tidToDate } from "@exosphere/core/pds"; 9import { resolveDidHandles } from "@exosphere/core/identity"; 10import type { SphereEnv } from "@exosphere/core/types"; 11import { 12 featureRequests, 13 featureRequestComments, 14 featureRequestCommentVotes, 15 featureRequestStatuses, 16} from "../db/schema.ts"; 17import { createCommentSchema, updateCommentSchema } from "../schemas/comment.ts"; 18import { 19 insertComment, 20 updateComment, 21 deleteCommentCascade, 22 insertCommentVote, 23 deleteCommentVoteByAuthor, 24 hideComment, 25 unhideComment, 26} from "../db/operations.ts"; 27 28const COMMENT_COLLECTION = "site.exosphere.featureRequest.comment"; 29const COMMENT_VOTE_COLLECTION = "site.exosphere.featureRequest.commentVote"; 30const MODERATION_COLLECTION = "site.exosphere.moderation"; 31 32const app = new Hono<AuthEnv & SphereEnv>(); 33 34/** Verify a comment belongs to the current sphere by joining through its parent FR. */ 35function findCommentInSphere(commentId: string, sphereId: string) { 36 return getDb() 37 .select() 38 .from(featureRequestComments) 39 .innerJoin(featureRequests, eq(featureRequests.id, featureRequestComments.requestId)) 40 .where(and(eq(featureRequestComments.id, commentId), eq(featureRequests.sphereId, sphereId))) 41 .get(); 42} 43 44// ---- Comments ---- 45 46// List comments for a feature request 47app.get("/:id/comments", async (c) => { 48 const requestId = c.req.param("id"); 49 const sphereId = c.var.sphereId; 50 const sortParam = c.req.query("sort"); 51 const orderParam = c.req.query("order"); 52 const sortBy = sortParam === "votes" ? "votes" : "date"; 53 const orderDir = orderParam === "asc" ? asc : desc; 54 const db = getDb(); 55 56 // Verify the FR belongs to this sphere 57 const request = db 58 .select({ status: featureRequests.status }) 59 .from(featureRequests) 60 .where(and(eq(featureRequests.id, requestId), eq(featureRequests.sphereId, sphereId))) 61 .get(); 62 63 if (!request) { 64 return c.json({ comments: [] }); 65 } 66 67 // When the request is closed (done/not-planned), hide comments posted after the closing date 68 let cutoffId: string | null = null; 69 if ( 70 request.status === "done" || 71 request.status === "not-planned" || 72 request.status === "duplicate" 73 ) { 74 const closedStatus = db 75 .select({ id: featureRequestStatuses.id }) 76 .from(featureRequestStatuses) 77 .where( 78 and( 79 eq(featureRequestStatuses.requestId, requestId), 80 inArray(featureRequestStatuses.status, ["done", "not-planned", "duplicate"]), 81 ), 82 ) 83 .orderBy(sql`${featureRequestStatuses.id} desc`) 84 .limit(1) 85 .get(); 86 cutoffId = closedStatus?.id ?? null; 87 } 88 89 const notHidden = sql`${featureRequestComments.hiddenAt} is null`; 90 const whereCondition = cutoffId 91 ? and( 92 eq(featureRequestComments.requestId, requestId), 93 sql`${featureRequestComments.id} <= ${cutoffId}`, 94 notHidden, 95 ) 96 : and(eq(featureRequestComments.requestId, requestId), notHidden); 97 98 const voteCountCol = count(featureRequestCommentVotes.authorDid); 99 const rows = db 100 .select({ 101 id: featureRequestComments.id, 102 requestId: featureRequestComments.requestId, 103 authorDid: featureRequestComments.authorDid, 104 content: featureRequestComments.content, 105 pdsUri: featureRequestComments.pdsUri, 106 updatedAt: featureRequestComments.updatedAt, 107 voteCount: voteCountCol, 108 }) 109 .from(featureRequestComments) 110 .leftJoin( 111 featureRequestCommentVotes, 112 eq(featureRequestCommentVotes.commentId, featureRequestComments.id), 113 ) 114 .where(whereCondition) 115 .groupBy(featureRequestComments.id) 116 .orderBy(sortBy === "votes" ? orderDir(voteCountCol) : orderDir(featureRequestComments.id)) 117 .all(); 118 119 const handleMap = await resolveDidHandles(rows.map((r) => r.authorDid)); 120 const comments = rows.map((r) => ({ 121 ...r, 122 createdAt: tidToDate(r.id), 123 authorHandle: handleMap.get(r.authorDid) ?? null, 124 })); 125 126 return c.json({ comments }); 127}); 128 129// Create a comment (one top-level comment per user per request) 130app.post( 131 "/:id/comments", 132 requireAuth, 133 requirePermission("feature-requests", "comment"), 134 async (c) => { 135 const requestId = c.req.param("id"); 136 const body = await c.req.json(); 137 const result = createCommentSchema.safeParse(body); 138 if (!result.success) { 139 return c.json({ error: z.flattenError(result.error) }, 400); 140 } 141 142 const { content } = result.data; 143 const sphereId = c.var.sphereId; 144 const sphereVisibility = c.var.sphereVisibility; 145 const db = getDb(); 146 const did = c.var.did; 147 148 // Check feature request exists, belongs to this sphere, and is visible 149 const existing = db 150 .select({ id: featureRequests.id, pdsUri: featureRequests.pdsUri }) 151 .from(featureRequests) 152 .where( 153 and( 154 eq(featureRequests.id, requestId), 155 eq(featureRequests.sphereId, sphereId), 156 sql`${featureRequests.hiddenAt} is null`, 157 ), 158 ) 159 .get(); 160 if (!existing) { 161 return c.json({ error: "Feature request not found" }, 404); 162 } 163 164 // Enforce one top-level comment per user per request (ignore moderated comments) 165 const alreadyCommented = db 166 .select({ id: featureRequestComments.id }) 167 .from(featureRequestComments) 168 .where( 169 and( 170 eq(featureRequestComments.requestId, requestId), 171 eq(featureRequestComments.authorDid, did), 172 sql`${featureRequestComments.hiddenAt} is null`, 173 ), 174 ) 175 .get(); 176 if (alreadyCommented) { 177 return c.json({ error: "You have already commented on this request" }, 409); 178 } 179 180 const id = generateRkey(); 181 let pdsUri: string | null = null; 182 183 // Write to PDS for public spheres 184 if (sphereVisibility === "public") { 185 const session = c.var.session; 186 pdsUri = await putPdsRecord(session, COMMENT_COLLECTION, id, { 187 subject: existing.pdsUri!, 188 content, 189 }); 190 } 191 192 insertComment({ id, requestId, authorDid: did, content, pdsUri }); 193 194 const comment = db 195 .select() 196 .from(featureRequestComments) 197 .where(eq(featureRequestComments.id, id)) 198 .get(); 199 200 return c.json({ comment: comment ? { ...comment, createdAt: tidToDate(id) } : comment }, 201); 201 }, 202); 203 204// Update own comment (author only) 205app.put("/comments/:id", requireAuth, async (c) => { 206 const id = c.req.param("id"); 207 const body = await c.req.json(); 208 const result = updateCommentSchema.safeParse(body); 209 if (!result.success) { 210 return c.json({ error: z.flattenError(result.error) }, 400); 211 } 212 213 const { content } = result.data; 214 const sphereId = c.var.sphereId; 215 const db = getDb(); 216 const did = c.var.did; 217 218 const row = findCommentInSphere(id, sphereId); 219 if (!row) { 220 return c.json({ error: "Comment not found" }, 404); 221 } 222 const comment = row.feature_request_comments; 223 if (comment.authorDid !== did) { 224 return c.json({ error: "Forbidden" }, 403); 225 } 226 227 // Update on PDS if the comment was written there 228 if (comment.pdsUri) { 229 const session = c.var.session; 230 231 // Look up the parent feature request's pdsUri for the subject field 232 const parent = db 233 .select({ pdsUri: featureRequests.pdsUri }) 234 .from(featureRequests) 235 .where(eq(featureRequests.id, comment.requestId)) 236 .get(); 237 238 if (!parent?.pdsUri) { 239 return c.json({ error: "Parent feature request not found" }, 404); 240 } 241 242 await putPdsRecord(session, COMMENT_COLLECTION, id, { 243 subject: parent.pdsUri, 244 content, 245 updatedAt: new Date().toISOString(), 246 }); 247 } 248 249 updateComment(id, content); 250 251 const updated = db 252 .select() 253 .from(featureRequestComments) 254 .where(eq(featureRequestComments.id, id)) 255 .get(); 256 257 return c.json({ comment: updated ? { ...updated, createdAt: tidToDate(id) } : updated }); 258}); 259 260// Delete own comment or admin-moderate a comment 261app.delete("/comments/:id", requireAuth, async (c) => { 262 const id = c.req.param("id"); 263 const db = getDb(); 264 const did = c.var.did; 265 const sphereId = c.var.sphereId; 266 const sphereOwnerDid = c.var.sphereOwnerDid; 267 const spherePdsUri = c.var.spherePdsUri; 268 269 const row = findCommentInSphere(id, sphereId); 270 if (!row) { 271 return c.json({ error: "Comment not found" }, 404); 272 } 273 const comment = row.feature_request_comments; 274 275 const isAuthor = comment.authorDid === did; 276 277 if (!isAuthor) { 278 // Only users with moderate permission can hide other users' comments 279 const role = getActiveMemberRole(sphereId, did); 280 if (!checkPermission(sphereId, "feature-requests", "moderate", role)) { 281 return c.json({ error: "Forbidden" }, 403); 282 } 283 284 // Already moderated — no-op 285 if (comment.hiddenAt) { 286 return c.json({ ok: true }); 287 } 288 289 // Admin moderation — publish moderation record on admin's PDS, hide locally 290 if (comment.pdsUri) { 291 const sphereUri = spherePdsUri ?? `at://${sphereOwnerDid}/site.exosphere.sphere.profile/self`; 292 const session = c.var.session; 293 const pdsUri = await putPdsRecord(session, MODERATION_COLLECTION, id, { 294 sphere: sphereUri, 295 subject: comment.pdsUri, 296 action: "remove", 297 }); 298 if (!pdsUri) { 299 console.warn( 300 "[feature-requests] Moderation PDS record failed for comment %s — hiding locally only", 301 id, 302 ); 303 } 304 } 305 306 hideComment(id, did); 307 308 return c.json({ ok: true }); 309 } 310 311 // Author deleting their own comment — remove from their PDS + delete from DB 312 if (comment.pdsUri) { 313 const session = c.var.session; 314 const res = await session.fetchHandler("/xrpc/com.atproto.repo.deleteRecord", { 315 method: "POST", 316 headers: { "Content-Type": "application/json" }, 317 body: JSON.stringify({ 318 repo: session.did, 319 collection: COMMENT_COLLECTION, 320 rkey: id, 321 }), 322 }); 323 324 if (!res.ok) { 325 console.error( 326 "[feature-requests] PDS comment delete failed:", 327 res.status, 328 await res.text().catch(() => ""), 329 ); 330 } 331 } 332 333 deleteCommentCascade(id); 334 335 return c.json({ ok: true }); 336}); 337 338// Admin/owner-only: unhide a moderated comment 339app.post( 340 "/comments/:id/unhide", 341 requireAuth, 342 requirePermission("feature-requests", "moderate"), 343 async (c) => { 344 const id = c.req.param("id"); 345 const sphereId = c.var.sphereId; 346 const did = c.var.did; 347 348 const row = findCommentInSphere(id, sphereId); 349 if (!row) { 350 return c.json({ error: "Comment not found" }, 404); 351 } 352 const comment = row.feature_request_comments; 353 if (!comment.hiddenAt) { 354 return c.json({ ok: true }); 355 } 356 357 // Delete the moderation record from PDS if the current admin is the one who hid it 358 if (comment.moderatedBy === did) { 359 const session = c.var.session; 360 await deletePdsRecord(session, MODERATION_COLLECTION, id); 361 } 362 363 unhideComment(id); 364 365 return c.json({ ok: true }); 366 }, 367); 368 369// ---- Comment Votes ---- 370 371// Get current user's comment votes for this sphere (list of comment IDs) 372app.get("/comments/votes", requireAuth, (c) => { 373 const db = getDb(); 374 const sphereId = c.var.sphereId; 375 const rows = db 376 .select({ commentId: featureRequestCommentVotes.commentId }) 377 .from(featureRequestCommentVotes) 378 .innerJoin( 379 featureRequestComments, 380 eq(featureRequestComments.id, featureRequestCommentVotes.commentId), 381 ) 382 .innerJoin(featureRequests, eq(featureRequests.id, featureRequestComments.requestId)) 383 .where( 384 and( 385 eq(featureRequestCommentVotes.authorDid, c.var.did), 386 eq(featureRequests.sphereId, sphereId), 387 ), 388 ) 389 .all(); 390 return c.json({ votes: rows.map((r) => r.commentId) }); 391}); 392 393// Cast a vote on a comment 394app.post( 395 "/comments/:id/vote", 396 requireAuth, 397 requirePermission("feature-requests", "vote"), 398 async (c) => { 399 const id = c.req.param("id"); 400 const db = getDb(); 401 const did = c.var.did; 402 const sphereId = c.var.sphereId; 403 const sphereVisibility = c.var.sphereVisibility; 404 405 const row = findCommentInSphere(id, sphereId); 406 if (!row) { 407 return c.json({ error: "Comment not found" }, 404); 408 } 409 const comment = row.feature_request_comments; 410 411 const alreadyVoted = db 412 .select({ commentId: featureRequestCommentVotes.commentId }) 413 .from(featureRequestCommentVotes) 414 .where( 415 and( 416 eq(featureRequestCommentVotes.commentId, id), 417 eq(featureRequestCommentVotes.authorDid, did), 418 ), 419 ) 420 .get(); 421 if (alreadyVoted) { 422 return c.json({ error: "Already voted" }, 409); 423 } 424 425 let votePdsUri: string | null = null; 426 427 if (sphereVisibility === "public" && comment.pdsUri) { 428 const session = c.var.session; 429 votePdsUri = await putPdsRecord(session, COMMENT_VOTE_COLLECTION, id, { 430 subject: comment.pdsUri, 431 }); 432 } 433 434 insertCommentVote(id, did, votePdsUri); 435 436 const result = db 437 .select({ voteCount: count() }) 438 .from(featureRequestCommentVotes) 439 .where(eq(featureRequestCommentVotes.commentId, id)) 440 .get(); 441 442 return c.json({ voteCount: result?.voteCount ?? 0 }, 201); 443 }, 444); 445 446// Remove a vote from a comment 447app.delete( 448 "/comments/:id/vote", 449 requireAuth, 450 requirePermission("feature-requests", "vote"), 451 async (c) => { 452 const id = c.req.param("id"); 453 const db = getDb(); 454 const did = c.var.did; 455 const sphereId = c.var.sphereId; 456 457 // Verify the comment's FR belongs to this sphere 458 const row = findCommentInSphere(id, sphereId); 459 if (!row) { 460 return c.json({ error: "Comment not found" }, 404); 461 } 462 463 const vote = db 464 .select({ pdsUri: featureRequestCommentVotes.pdsUri }) 465 .from(featureRequestCommentVotes) 466 .where( 467 and( 468 eq(featureRequestCommentVotes.commentId, id), 469 eq(featureRequestCommentVotes.authorDid, did), 470 ), 471 ) 472 .get(); 473 if (!vote) { 474 return c.json({ error: "Vote not found" }, 404); 475 } 476 477 if (vote.pdsUri) { 478 const session = c.var.session; 479 const res = await session.fetchHandler("/xrpc/com.atproto.repo.deleteRecord", { 480 method: "POST", 481 headers: { "Content-Type": "application/json" }, 482 body: JSON.stringify({ 483 repo: session.did, 484 collection: COMMENT_VOTE_COLLECTION, 485 rkey: id, 486 }), 487 }); 488 489 if (!res.ok) { 490 console.error( 491 "[feature-requests] PDS comment vote delete failed:", 492 res.status, 493 await res.text().catch(() => ""), 494 ); 495 } 496 } 497 498 deleteCommentVoteByAuthor(id, did); 499 500 const result = db 501 .select({ voteCount: count() }) 502 .from(featureRequestCommentVotes) 503 .where(eq(featureRequestCommentVotes.commentId, id)) 504 .get(); 505 506 return c.json({ voteCount: result?.voteCount ?? 0 }); 507 }, 508); 509 510export { app as commentsApi };