🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add wiki settings with deletion

juprodh ea632848 666050c6

+308 -15
+64 -2
src/lib/orchestrators/wiki.ts
··· 1 1 import { 2 + deleteRecord, 2 3 writeMembershipRecord, 3 4 writeNoteRecord, 4 5 writeRevisionRecord, ··· 7 8 import { createAgent } from "../../atproto/session.ts"; 8 9 import { 9 10 createNote, 11 + deleteWikiByAtUri, 10 12 getWiki, 13 + listMembers, 11 14 upsertMembership, 12 15 upsertWiki, 13 16 } from "../../server/db/queries/index.ts"; 14 - import type { RequestContext } from "../access.ts"; 17 + import type { RequestContext, WikiRequestContext } from "../access.ts"; 18 + import { parseAtUri } from "../at-uri.ts"; 19 + import { COLLECTIONS } from "../constants.ts"; 15 20 import { createDiff } from "../diff.ts"; 16 - import { formatError, PdsWriteError, ValidationError } from "../errors.ts"; 21 + import { 22 + ForbiddenError, 23 + formatError, 24 + PdsWriteError, 25 + ValidationError, 26 + } from "../errors.ts"; 17 27 import type { Messages } from "../i18n/index.ts"; 18 28 import { fmt } from "../i18n/index.ts"; 19 29 import { isValidSlug, slugify } from "../slug.ts"; ··· 148 158 149 159 return { wikiSlug: slug }; 150 160 } 161 + 162 + /** 163 + * Delete a wiki. Owner only. 164 + * PDS cleanup (wiki record + membership records) → DB cascade delete. 165 + * Throws ForbiddenError if caller is not the wiki owner. 166 + */ 167 + export async function deleteWikiAction(ctx: WikiRequestContext): Promise<void> { 168 + if (ctx.effectiveDid !== ctx.wiki.did) { 169 + throw new ForbiddenError("Only the wiki owner can delete this wiki"); 170 + } 171 + 172 + // PDS cleanup (skip in dev mode) 173 + if (ctx.session) { 174 + const agent = createAgent(ctx.session); 175 + 176 + // Delete wiki record 177 + const wikiParsed = parseAtUri(ctx.wiki.at_uri); 178 + if (wikiParsed) { 179 + try { 180 + await deleteRecord( 181 + agent, 182 + wikiParsed.did, 183 + COLLECTIONS.wiki, 184 + wikiParsed.rkey, 185 + ); 186 + } catch { 187 + // Orphaned PDS records are acceptable on deletion 188 + } 189 + } 190 + 191 + // Delete membership records owned by this user 192 + const members = listMembers(ctx.wiki.slug); 193 + for (const m of members) { 194 + const parsed = parseAtUri(m.at_uri); 195 + if (parsed && parsed.did === ctx.effectiveDid) { 196 + try { 197 + await deleteRecord( 198 + agent, 199 + parsed.did, 200 + COLLECTIONS.membership, 201 + parsed.rkey, 202 + ); 203 + } catch { 204 + // Orphaned PDS records are acceptable on deletion 205 + } 206 + } 207 + } 208 + } 209 + 210 + // DB cascade delete 211 + deleteWikiByAtUri(ctx.wiki.at_uri); 212 + }
+40 -2
src/server/routes/wiki.ts
··· 1 1 import { Elysia } from "elysia"; 2 - import { canRead, resolveRequestContext } from "../../lib/access.ts"; 2 + import { 3 + canRead, 4 + resolveRequestContext, 5 + resolveWikiContext, 6 + } from "../../lib/access.ts"; 3 7 import { VIZ_SCRIPTS } from "../../lib/constants.ts"; 4 8 import { ValidationError } from "../../lib/errors.ts"; 5 9 import { t } from "../../lib/i18n/index.ts"; 6 10 import { renderMarkdown } from "../../lib/markdown.ts"; 7 - import { createWikiAction } from "../../lib/orchestrators/wiki.ts"; 11 + import { 12 + createWikiAction, 13 + deleteWikiAction, 14 + } from "../../lib/orchestrators/wiki.ts"; 8 15 import { htmlResponse } from "../../lib/response.ts"; 9 16 import { redirect, wikiUrl } from "../../lib/urls.ts"; 10 17 import { accessDeniedPage } from "../../views/access-denied.ts"; 11 18 import { newWikiPage } from "../../views/new-wiki.ts"; 12 19 import { notePage } from "../../views/note.ts"; 20 + import { settingsPage } from "../../views/settings.ts"; 13 21 import { wikiPage } from "../../views/wiki.ts"; 14 22 import { 15 23 getCurrentNote, ··· 56 64 } 57 65 throw err; 58 66 } 67 + }) 68 + .get("/:wikiSlug/-/settings", async ({ params, request }) => { 69 + const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); 70 + const isOwner = ctx.effectiveDid === ctx.wiki.did; 71 + return htmlResponse( 72 + settingsPage(ctx.wiki.name, params.wikiSlug, isOwner, { 73 + session: ctx.session, 74 + locale: ctx.locale, 75 + accessLevel: ctx.access, 76 + }), 77 + ); 78 + }) 79 + .post("/:wikiSlug/-/delete", async ({ params, request }) => { 80 + const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); 81 + const formData = await request.formData(); 82 + const confirm = (formData.get("confirm") as string | null) ?? ""; 83 + if (confirm !== ctx.wiki.name) { 84 + const isOwner = ctx.effectiveDid === ctx.wiki.did; 85 + return htmlResponse( 86 + settingsPage(ctx.wiki.name, params.wikiSlug, isOwner, { 87 + session: ctx.session, 88 + locale: ctx.locale, 89 + accessLevel: ctx.access, 90 + error: "Wiki name does not match.", 91 + }), 92 + 400, 93 + ); 94 + } 95 + await deleteWikiAction(ctx); 96 + return redirect("/"); 59 97 }) 60 98 .get("/:wikiSlug", async ({ params, request }) => { 61 99 const ctx = await resolveRequestContext(request, params.wikiSlug);
+9
src/views/layout.ts
··· 47 47 48 48 const canEdit = 49 49 options.accessLevel === "edit" || options.accessLevel === "admin"; 50 + const isAdmin = options.accessLevel === "admin"; 50 51 51 52 const editLink = 52 53 options.currentNoteSlug && canEdit ··· 57 58 ? `<a href="/wiki/${options.wikiSlug}/new" class="block mb-4 px-3 py-1.5 border border-${THEME.accent} text-${THEME.accent} text-sm font-medium rounded hover:bg-${THEME.accentLight} text-center">${msg.wiki.newNote}</a>` 58 59 : ""; 59 60 61 + const adminLinks = isAdmin 62 + ? `<div class="mt-4 pt-4 border-t border-${THEME.borderSubtle} space-y-1"> 63 + <a href="/wiki/${options.wikiSlug}/-/members" class="block text-xs text-${THEME.textMuted} hover:text-${THEME.accent}">${msg.access.members}</a> 64 + <a href="/wiki/${options.wikiSlug}/-/settings" class="block text-xs text-${THEME.textMuted} hover:text-${THEME.accent}">Settings</a> 65 + </div>` 66 + : ""; 67 + 60 68 return `<div class="max-w-6xl mx-auto pl-6 pr-16 py-8 flex flex-col md:flex-row gap-8"> 61 69 <aside class="order-2 md:order-first w-full md:w-44 md:shrink-0"> 62 70 ${renderSearchBar(options.wikiSlug, locale)} ··· 66 74 <h3 class="text-xs font-semibold text-${THEME.textMuted} uppercase tracking-wide mb-2">${msg.wiki.pages}</h3> 67 75 <ul class="space-y-1">${noteLinks}</ul> 68 76 </nav> 77 + ${adminLinks} 69 78 </aside> 70 79 <main class="order-1 md:order-last flex-1 min-w-0 flex justify-center"><div class="w-full max-w-3xl">${body}</div></main> 71 80 </div>`;
+49
src/views/settings.ts
··· 1 + import { escapeHtml } from "../lib/html.ts"; 2 + import { type LayoutOptions, layout } from "./layout.ts"; 3 + import { THEME } from "./theme.ts"; 4 + 5 + interface SettingsPageOptions extends LayoutOptions { 6 + error?: string; 7 + } 8 + 9 + export function settingsPage( 10 + wikiName: string, 11 + wikiSlug: string, 12 + isOwner: boolean, 13 + options: SettingsPageOptions, 14 + ): string { 15 + const errorBanner = options.error 16 + ? `<p class="mb-4 text-sm text-${THEME.errorText} bg-${THEME.errorBg} border border-${THEME.errorBorder} rounded px-3 py-2">${escapeHtml(options.error)}</p>` 17 + : ""; 18 + const dangerZone = isOwner 19 + ? `<section class="mt-10 border border-${THEME.errorBorder} rounded-lg p-6 bg-${THEME.errorBg}"> 20 + <h2 class="text-lg font-semibold text-${THEME.errorText} mb-4">Danger zone</h2> 21 + <p class="text-sm text-${THEME.textSecondary} mb-4"> 22 + Permanently delete this wiki and all its notes. This cannot be undone. 23 + </p> 24 + <form method="POST" action="/wiki/${wikiSlug}/-/delete" 25 + onsubmit="return document.getElementById('confirm-name').value === '${escapeHtml(wikiName)}' 26 + || (alert('Wiki name does not match.'), false)"> 27 + <label class="block text-sm font-medium text-${THEME.textSecondary} mb-1"> 28 + Type <strong>${escapeHtml(wikiName)}</strong> to confirm 29 + </label> 30 + <input id="confirm-name" type="text" name="confirm" autocomplete="off" 31 + class="block w-full max-w-sm px-3 py-1.5 text-sm border border-${THEME.borderInput} rounded mb-3 focus:outline-none focus:border-${THEME.accentBorder}" 32 + placeholder="${escapeHtml(wikiName)}" /> 33 + <button type="submit" 34 + class="px-4 py-2 bg-${THEME.errorText} text-white text-sm font-medium rounded hover:opacity-90"> 35 + Delete wiki 36 + </button> 37 + </form> 38 + </section>` 39 + : `<section class="mt-10 border border-${THEME.borderDefault} rounded-lg p-6"> 40 + <h2 class="text-lg font-semibold text-${THEME.textMuted} mb-2">Danger zone</h2> 41 + <p class="text-sm text-${THEME.textMuted}">Only the wiki owner can delete this wiki.</p> 42 + </section>`; 43 + 44 + return layout( 45 + `Settings — ${wikiName}`, 46 + `<h1 class="text-2xl font-bold mb-6">Settings</h1>${errorBanner}${dangerZone}`, 47 + { ...options, wikiName, wikiSlug }, 48 + ); 49 + }
+146 -11
tests/lib/orchestrators/wiki.test.ts
··· 1 1 import { afterAll, describe, expect, mock, test } from "bun:test"; 2 - import type { RequestContext } from "../../../src/lib/access.ts"; 2 + import type { 3 + RequestContext, 4 + WikiRequestContext, 5 + } from "../../../src/lib/access.ts"; 3 6 import { getDb } from "../../../src/server/db/index.ts"; 7 + import type { WikiRow } from "../../../src/server/db/types.ts"; 4 8 5 9 // Only mock PDS + session (no other test depends on these being real) 6 10 const realPds = await import("../../../src/atproto/pds.ts"); ··· 14 18 uri: "at://did:plc:wikitest/pub.coral.membership/abc", 15 19 cid: "bafyrei456", 16 20 })); 21 + const mockWriteNoteRecord = mock(async () => ({ 22 + uri: "at://did:plc:wikitest/pub.coral.note/note1", 23 + cid: "bafyrei789", 24 + })); 25 + const mockWriteRevisionRecord = mock(async () => ({ 26 + uri: "at://did:plc:wikitest/pub.coral.noteRevision/rev1", 27 + cid: "bafyreirev", 28 + })); 29 + const mockDeleteRecord = mock(async () => {}); 17 30 const mockCreateAgent = mock(() => ({}) as never); 18 31 19 32 mock.module("../../../src/atproto/pds.ts", () => ({ 20 33 ...realPds, 21 34 writeWikiRecord: mockWriteWikiRecord, 22 35 writeMembershipRecord: mockWriteMembershipRecord, 36 + writeNoteRecord: mockWriteNoteRecord, 37 + writeRevisionRecord: mockWriteRevisionRecord, 38 + deleteRecord: mockDeleteRecord, 23 39 })); 24 40 mock.module("../../../src/atproto/session.ts", () => ({ 25 41 ...realSession, 26 42 createAgent: mockCreateAgent, 27 43 })); 28 44 29 - const { createWikiAction } = await import( 45 + const { createWikiAction, deleteWikiAction } = await import( 30 46 "../../../src/lib/orchestrators/wiki.ts" 31 47 ); 32 48 33 - const { PdsWriteError, ValidationError } = await import( 49 + const { ForbiddenError, PdsWriteError, ValidationError } = await import( 34 50 "../../../src/lib/errors.ts" 35 51 ); 36 52 ··· 44 60 } as never; 45 61 46 62 const TEST_DID = "did:plc:orchwikitest"; 63 + const OTHER_DID = "did:plc:otherwikitest"; 47 64 48 65 function makeCtx(overrides: Partial<RequestContext> = {}): RequestContext { 49 66 return { ··· 56 73 }; 57 74 } 58 75 76 + function makeWikiCtx( 77 + wiki: WikiRow, 78 + overrides: Partial<WikiRequestContext> = {}, 79 + ): WikiRequestContext { 80 + return { 81 + session: { did: TEST_DID, handle: "test.bsky.social" }, 82 + wiki, 83 + effectiveDid: TEST_DID, 84 + access: "admin", 85 + locale: "en", 86 + ...overrides, 87 + }; 88 + } 89 + 59 90 // Track created wikis for cleanup 60 91 const createdSlugs: string[] = []; 61 92 62 93 afterAll(() => { 63 94 const db = getDb(); 64 95 for (const slug of createdSlugs) { 96 + db.run("DELETE FROM current_note WHERE note_at_uri LIKE ?", [ 97 + `at://${TEST_DID}/pub.coral.note/%`, 98 + ]); 99 + db.run("DELETE FROM revisions WHERE note_at_uri LIKE ?", [ 100 + `at://${TEST_DID}/pub.coral.note/%`, 101 + ]); 102 + db.run("DELETE FROM notes WHERE wiki_slug = ?", [slug]); 65 103 db.run("DELETE FROM memberships WHERE wiki_slug = ?", [slug]); 66 104 db.run("DELETE FROM wikis WHERE slug = ?", [slug]); 67 105 } ··· 71 109 }); 72 110 73 111 describe("createWikiAction", () => { 74 - test("creates wiki with PDS + DB + auto-admin", async () => { 112 + test("creates wiki with PDS + DB + auto-admin + home note", async () => { 75 113 mockWriteWikiRecord.mockClear(); 76 114 mockWriteMembershipRecord.mockClear(); 115 + mockWriteNoteRecord.mockClear(); 116 + mockWriteRevisionRecord.mockClear(); 77 117 78 118 const result = await createWikiAction( 79 119 makeCtx(), ··· 85 125 expect(result.wikiSlug).toBe("orch-wiki-test"); 86 126 expect(mockWriteWikiRecord).toHaveBeenCalledTimes(1); 87 127 expect(mockWriteMembershipRecord).toHaveBeenCalledTimes(1); 128 + expect(mockWriteNoteRecord).toHaveBeenCalledTimes(1); 129 + expect(mockWriteRevisionRecord).toHaveBeenCalledTimes(1); 130 + 131 + // Home note exists in DB 132 + const db = getDb(); 133 + const note = db 134 + .query("SELECT * FROM notes WHERE wiki_slug = ? AND slug = 'home'") 135 + .get(result.wikiSlug); 136 + expect(note).not.toBeNull(); 88 137 }); 89 138 90 139 test("skips PDS when no session", async () => { 91 140 mockWriteWikiRecord.mockClear(); 92 141 mockWriteMembershipRecord.mockClear(); 142 + mockWriteNoteRecord.mockClear(); 93 143 94 144 const result = await createWikiAction( 95 145 makeCtx({ session: null }), ··· 100 150 createdSlugs.push(result.wikiSlug); 101 151 expect(mockWriteWikiRecord).not.toHaveBeenCalled(); 102 152 expect(mockWriteMembershipRecord).not.toHaveBeenCalled(); 153 + expect(mockWriteNoteRecord).not.toHaveBeenCalled(); 103 154 }); 104 155 105 156 test("throws ValidationError for empty name", async () => { ··· 123 174 }); 124 175 125 176 test("throws ValidationError for duplicate slug", async () => { 126 - // First create a wiki 127 177 const result = await createWikiAction( 128 178 makeCtx({ session: null }), 129 179 { name: "Orch Duplicate Wiki", language: "en", visibility: "public" }, ··· 131 181 ); 132 182 createdSlugs.push(result.wikiSlug); 133 183 134 - // Then try to create one with the same name 135 184 expect( 136 185 createWikiAction( 137 186 makeCtx({ session: null }), ··· 149 198 expect( 150 199 createWikiAction( 151 200 makeCtx(), 152 - { 153 - name: "Orch PDS Fail Wiki", 154 - language: "en", 155 - visibility: "public", 156 - }, 201 + { name: "Orch PDS Fail Wiki", language: "en", visibility: "public" }, 157 202 dummyMsg, 158 203 ), 159 204 ).rejects.toBeInstanceOf(PdsWriteError); 160 205 }); 161 206 }); 207 + 208 + describe("deleteWikiAction", () => { 209 + async function createTestWiki(name: string): Promise<WikiRow> { 210 + const result = await createWikiAction( 211 + makeCtx({ session: null }), 212 + { name, language: "en", visibility: "public" }, 213 + dummyMsg, 214 + ); 215 + createdSlugs.push(result.wikiSlug); 216 + const db = getDb(); 217 + return db 218 + .query("SELECT * FROM wikis WHERE slug = ?") 219 + .get(result.wikiSlug) as WikiRow; 220 + } 221 + 222 + test("throws ForbiddenError if caller is not the owner", async () => { 223 + const wiki = await createTestWiki("Orch Delete Forbidden"); 224 + const ctx = makeWikiCtx(wiki, { effectiveDid: OTHER_DID }); 225 + 226 + expect(deleteWikiAction(ctx)).rejects.toBeInstanceOf(ForbiddenError); 227 + }); 228 + 229 + test("deletes wiki and cascades to notes in DB", async () => { 230 + const wiki = await createTestWiki("Orch Delete DB Test"); 231 + const ctx = makeWikiCtx(wiki, { session: null }); 232 + const db = getDb(); 233 + 234 + await deleteWikiAction(ctx); 235 + 236 + expect( 237 + db.query("SELECT * FROM wikis WHERE slug = ?").get(wiki.slug), 238 + ).toBeNull(); 239 + expect( 240 + db.query("SELECT * FROM notes WHERE wiki_slug = ?").all(wiki.slug), 241 + ).toHaveLength(0); 242 + expect( 243 + db.query("SELECT * FROM memberships WHERE wiki_slug = ?").all(wiki.slug), 244 + ).toHaveLength(0); 245 + 246 + // Remove from cleanup list since already deleted 247 + const idx = createdSlugs.indexOf(wiki.slug); 248 + if (idx !== -1) createdSlugs.splice(idx, 1); 249 + }); 250 + 251 + test("calls deleteRecord for wiki + membership records when session present", async () => { 252 + const wiki = await createTestWiki("Orch Delete PDS Test"); 253 + mockDeleteRecord.mockClear(); 254 + const ctx = makeWikiCtx(wiki); 255 + 256 + await deleteWikiAction(ctx); 257 + 258 + // At minimum: wiki record + owner membership record 259 + expect(mockDeleteRecord).toHaveBeenCalled(); 260 + 261 + const idx = createdSlugs.indexOf(wiki.slug); 262 + if (idx !== -1) createdSlugs.splice(idx, 1); 263 + }); 264 + 265 + test("skips PDS cleanup in dev mode (no session)", async () => { 266 + const wiki = await createTestWiki("Orch Delete No Session"); 267 + mockDeleteRecord.mockClear(); 268 + const ctx = makeWikiCtx(wiki, { session: null }); 269 + 270 + await deleteWikiAction(ctx); 271 + 272 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 273 + 274 + const idx = createdSlugs.indexOf(wiki.slug); 275 + if (idx !== -1) createdSlugs.splice(idx, 1); 276 + }); 277 + 278 + test("DB delete still runs even if PDS delete throws", async () => { 279 + const wiki = await createTestWiki("Orch Delete PDS Error"); 280 + mockDeleteRecord.mockImplementation(async () => { 281 + throw new Error("PDS unreachable"); 282 + }); 283 + const ctx = makeWikiCtx(wiki); 284 + const db = getDb(); 285 + 286 + await deleteWikiAction(ctx); 287 + 288 + expect( 289 + db.query("SELECT * FROM wikis WHERE slug = ?").get(wiki.slug), 290 + ).toBeNull(); 291 + mockDeleteRecord.mockReset(); 292 + 293 + const idx = createdSlugs.indexOf(wiki.slug); 294 + if (idx !== -1) createdSlugs.splice(idx, 1); 295 + }); 296 + });