diff --git a/src/lib/reddit/bot.ts b/src/lib/reddit/bot.ts index 200075c..8420187 100644 --- a/src/lib/reddit/bot.ts +++ b/src/lib/reddit/bot.ts @@ -72,7 +72,7 @@ const DEFAULT_COMMUNITY_CONFIG: CommunityConfig = { * UI-relevant fields. Falls back to sensible defaults on any failure so the * community still works without a record. */ -async function fetchCommunityConfig(pds: string, did: string): Promise { +export async function fetchCommunityConfig(pds: string, did: string): Promise { try { const rec = await getRecord(pds, did, 'garden.atmo.community', 'self'); const value = (rec?.value ?? {}) as { @@ -312,6 +312,96 @@ async function loadClient( return WelcomeMatClient.forAccount(account); } +/** + * Partial update of a community's on-network presence. Fields that are + * omitted are left untouched (both on the profile record and on + * garden.atmo.community/self). The D1 cache picks up display-name / + * avatar / description / accent from the next cron tick; accent_color + * can be updated inline since we know the new value. + */ +export type UpdateCommunityOptions = { + avatar?: { bytes: Uint8Array; mimeType: string }; + /** User-facing description (we prepend the `https://` link). */ + description?: string; + accentColor?: string; +}; + +export async function updateCommunity( + env: App.Platform['env'], + row: CommunityRow, + opts: UpdateCommunityOptions +): Promise { + const client = await loadClient(env, row); + + // Upload new avatar blob if provided. + let avatarBlob: + | { $type: 'blob'; ref: { $link: string }; mimeType: string; size: number } + | null = null; + if (opts.avatar) { + avatarBlob = await client.uploadBlob(opts.avatar.bytes, opts.avatar.mimeType); + } + + // Merge into the existing profile record. We read it first so we + // don't clobber fields we don't touch (displayName etc.). + if (avatarBlob || opts.description !== undefined) { + try { + const existing = await getRecord( + row.pds, + row.did, + 'app.bsky.actor.profile', + 'self' + ); + const baseValue = + existing?.value ?? ({ $type: 'app.bsky.actor.profile' } as Record); + + const next: Record = { ...baseValue, $type: 'app.bsky.actor.profile' }; + + if (avatarBlob) { + next.avatar = avatarBlob; + } + if (opts.description !== undefined) { + const trimmed = opts.description.trim(); + const full = trimmed + ? `https://${row.handle}\n\n${trimmed}` + : `https://${row.handle}`; + if (countGraphemes(full) > PROFILE_DESCRIPTION_MAX_GRAPHEMES) { + throw new Error( + `Description too long: ${PROFILE_DESCRIPTION_MAX_GRAPHEMES} graphemes max (including the https://${row.handle} prefix)` + ); + } + next.description = full; + } + + await client.putRecord('app.bsky.actor.profile', 'self', next); + } catch (e) { + console.error('[updateCommunity] failed to update profile', e); + throw e; + } + } + + // Merge into garden.atmo.community/self for accent color changes. + if (opts.accentColor !== undefined) { + try { + const existing = await getRecord( + row.pds, + row.did, + 'garden.atmo.community', + 'self' + ); + const baseValue = + existing?.value ?? ({ $type: 'garden.atmo.community' } as Record); + await client.putRecord('garden.atmo.community', 'self', { + ...baseValue, + $type: 'garden.atmo.community', + accentColor: opts.accentColor + }); + } catch (e) { + console.error('[updateCommunity] failed to update community record', e); + throw e; + } + } +} + // ------------------------------------------------------------------------- // Registration // ------------------------------------------------------------------------- @@ -901,12 +991,15 @@ async function createSubmissionPost( * hiding the button from signed-out visitors who would otherwise be * allowed once they log in. */ -export async function canUserSubmit( - pds: string, - communityDid: string, +/** + * Pure check: given an already-fetched config, is `viewerDid` allowed to + * submit? Lets callers that already have the config (e.g. the community + * page remote, which also needs `creator` from it) skip a second fetch. + */ +export async function checkCanSubmit( + config: CommunityConfig, viewerDid: string | null ): Promise { - const config = await fetchCommunityConfig(pds, communityDid); if (config.whoCanSubmit !== 'list' || !config.listUri) return true; if (!viewerDid) return true; if (config.creator && config.creator === viewerDid) return true; @@ -914,6 +1007,15 @@ export async function canUserSubmit( return members.has(viewerDid); } +export async function canUserSubmit( + pds: string, + communityDid: string, + viewerDid: string | null +): Promise { + const config = await fetchCommunityConfig(pds, communityDid); + return checkCanSubmit(config, viewerDid); +} + /** * Reason a web submission was dropped. Surfaced for logging; callers don't * branch on these today. diff --git a/src/lib/reddit/server/communities.remote.ts b/src/lib/reddit/server/communities.remote.ts index 6c87cdd..179fe61 100644 --- a/src/lib/reddit/server/communities.remote.ts +++ b/src/lib/reddit/server/communities.remote.ts @@ -11,7 +11,12 @@ import { type PostWithCommunity, type PostSort } from '../db'; -import { canUserSubmit, registerCommunity } from '../bot'; +import { + registerCommunity, + updateCommunity, + fetchCommunityConfig, + checkCanSubmit +} from '../bot'; import { parseListUri } from '../list-uri'; import { ACCENT_COLORS, @@ -44,9 +49,11 @@ type PublicCommunity = Omit< postCount: number; }; -/** PublicCommunity + viewer-specific access state. */ +/** PublicCommunity + viewer-specific access state + creator (from the + * on-network garden.atmo.community/self record). */ type CommunityWithAccess = PublicCommunity & { canSubmit: boolean; + creator: string | null; }; function sanitize(row: CommunityRow & { post_count?: number }): PublicCommunity { @@ -180,15 +187,24 @@ export const getCommunity = command( const row = await getCommunityByHandle(env.DB, fullHandle(input.handle)); if (!row) return null; - // Viewer-specific: can this viewer submit to the community? Reads the - // allowlist off the community's PDS and (if gated) checks membership. - // Fails open — if the config fetch blows up, we don't want to hide the - // submit button from everyone. - const canSubmit = await canUserSubmit(row.pds, row.did, locals.did ?? null).catch( - () => true - ); + // One-shot fetch of the community's garden.atmo.community/self record. + // We need both `creator` (to gate the Edit UI) and `canSubmit` (to + // gate the Submit UI) — both derive from this same record + list + // membership, so fetch once and reuse. Fails open if the record is + // missing or the fetch fails: creator=null (no edit permission, + // which is the safe default) and canSubmit=true (don't hide submit + // from everyone). + let creator: string | null = null; + let canSubmit = true; + try { + const config = await fetchCommunityConfig(row.pds, row.did); + creator = config.creator; + canSubmit = await checkCanSubmit(config, locals.did ?? null); + } catch (e) { + console.error('[getCommunity] config fetch failed', e); + } - return { ...sanitize(row), canSubmit }; + return { ...sanitize(row), canSubmit, creator }; } ); @@ -247,3 +263,69 @@ export const getHomeFeed = command( ); } ); + +export const editCommunity = command( + v.object({ + handle: v.string(), + description: v.optional(v.pipe(v.string(), v.maxLength(2048))), + accentColor: v.optional(v.picklist(ACCENT_COLORS)), + avatar: v.optional( + v.object({ + base64: v.pipe(v.string(), v.maxLength(2 * 1024 * 1024)), + mimeType: v.picklist(ALLOWED_AVATAR_MIMES) + }) + ) + }), + async (input) => { + const { platform, locals } = getRequestEvent(); + const env = platform?.env; + if (!env || !env.DB) error(500, 'DB binding unavailable'); + if (!locals.did) error(401, 'You must be signed in to edit a community'); + + const row = await getCommunityByHandle(env.DB, fullHandle(input.handle)); + if (!row) error(404, 'Community not found'); + + // Creator-only gate: fetch the community record and verify the + // caller is the recorded creator. If the record has no creator + // (legacy rows from before we started writing it), no one can + // edit via this endpoint — they'd need the admin secret route. + const config = await fetchCommunityConfig(row.pds, row.did); + if (!config.creator) { + error(403, 'This community has no recorded creator — edit via admin tooling'); + } + if (config.creator !== locals.did) { + error(403, 'Only the community creator can edit'); + } + + let avatarPayload: { bytes: Uint8Array; mimeType: string } | undefined; + if (input.avatar) { + const bytes = decodeBase64(input.avatar.base64); + if (bytes.byteLength > MAX_AVATAR_BYTES) { + error(400, `Avatar too large: max ${MAX_AVATAR_BYTES} bytes`); + } + avatarPayload = { bytes, mimeType: input.avatar.mimeType }; + } + + if ( + avatarPayload === undefined && + input.description === undefined && + input.accentColor === undefined + ) { + error(400, 'No fields to update'); + } + + try { + await updateCommunity(env, row, { + avatar: avatarPayload, + description: input.description, + accentColor: input.accentColor + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error('[editCommunity]', e); + error(400, `Update failed: ${msg}`); + } + + return { ok: true, did: row.did, handle: row.handle }; + } +); diff --git a/src/routes/c/[handle]/+page.svelte b/src/routes/c/[handle]/+page.svelte index cbce59c..01fc204 100644 --- a/src/routes/c/[handle]/+page.svelte +++ b/src/routes/c/[handle]/+page.svelte @@ -1,8 +1,10 @@ + +
+ + +

Edit community

+ + {#if loading} +
+ +
+ {:else if loadError} +

{loadError}

+ {:else if !user.did} +
+

You need to be signed in to edit a community.

+ +
+ {:else if !community?.creator} +
+ This community was created before edits were supported. Ask an admin to update it. +
+ {:else if !isCreator} +
+ Only the community creator can edit this. +
+ {:else if community} +
+
+
+ Avatar + + {#if avatarPreview} + + {/if} +
+ +
+ Community +
+ + c/{communityShort} +
+ + Handle and name can't be changed after creation. + +
+
+ +