From db0134cafb3514a6b3cb58eb8635cfd3a70062f6 Mon Sep 17 00:00:00 2001 From: Damon Outlaw Date: Fri, 8 May 2026 19:02:25 -0400 Subject: [PATCH 01/14] Harden feed/thread pagination and fix provider validation --- api/src/routes/activityPodsAppPublic.ts | 25 ++- api/src/routes/atBridge.ts | 188 +++++++++++-------- api/src/services/ApRemoteIngestionService.ts | 54 +++++- api/src/services/LinkPreviewService.ts | 38 +++- docker-compose.local.yml | 2 - frontend/src/components/SignupForm.vue | 37 +++- frontend/src/stores/authStore.ts | 4 +- frontend/src/types/api.ts | 9 +- 8 files changed, 255 insertions(+), 102 deletions(-) diff --git a/api/src/routes/activityPodsAppPublic.ts b/api/src/routes/activityPodsAppPublic.ts index f9b9ba009..77a83e6fb 100644 --- a/api/src/routes/activityPodsAppPublic.ts +++ b/api/src/routes/activityPodsAppPublic.ts @@ -2,6 +2,8 @@ import Elysia, { t } from 'elysia' import { applyLocaleHeaders, localeFromHeaders, translate } from '../i18n' import { getMemoryApplicationDocument, getRequiredAccessNeedGroupDocument, getUserById, maybePersistDirectMessage, recordNotificationDelivery, verifyWebhookTarget } from '../services/ActivityPodsNotifications' +const MAX_WEBHOOK_BODY_BYTES = 200_000 + const activityPodsAppPublicPlugin = new Elysia({ name: 'activitypods-app-public' }) .get('/activitypods/app', ({ set }) => { set.headers['Content-Type'] = 'application/ld+json' @@ -36,9 +38,19 @@ const activityPodsAppPublicPlugin = new Elysia({ name: 'activitypods-app-public' return translate(locale, 'activitypods.webhooks.unauthorized') } + const contentLengthHeader = request.headers.get('content-length') + if (contentLengthHeader) { + const contentLength = Number.parseInt(contentLengthHeader, 10) + if (Number.isFinite(contentLength) && contentLength > MAX_WEBHOOK_BODY_BYTES) { + set.status = 413 + return translate(locale, 'activitypods.webhooks.payloadTooLarge') + } + } + let parsedBody = typeof body === 'string' ? (() => { + if (body.length > MAX_WEBHOOK_BODY_BYTES) return null try { return JSON.parse(body) as Record } catch { @@ -49,19 +61,6 @@ const activityPodsAppPublicPlugin = new Elysia({ name: 'activitypods-app-public' ? (body as Record) : null - if (!parsedBody) { - try { - const rawText = await request.text() - if (rawText.length > 200_000) { - set.status = 413 - return translate(locale, 'activitypods.webhooks.payloadTooLarge') - } - parsedBody = rawText ? (JSON.parse(rawText) as Record) : null - } catch { - parsedBody = null - } - } - if (!parsedBody) { set.status = 400 return translate(locale, 'activitypods.webhooks.invalidPayload') diff --git a/api/src/routes/atBridge.ts b/api/src/routes/atBridge.ts index 75bf0215f..e840515f6 100644 --- a/api/src/routes/atBridge.ts +++ b/api/src/routes/atBridge.ts @@ -23,6 +23,7 @@ import { signedIn, signedInGuard } from './elysiaCompat' import { db } from '../db/client' import { atPosts, atIdentities, atFirehoseCursors, unifiedFeedView, atRecords, unifiedFeedCandidatesView, apRemotePosts, apActorCache } from '../db/atBridgeSchema' import { desc, eq, and, sql, ilike, or, gt, inArray, type SQL } from 'drizzle-orm' +import { isPublicHttpUrl } from '../utils/urlGuards' import setupPlugin from './setup' import { extractHashtagsFromFacets, normalizeHashtag } from '../utils/hashtags' import { mapFeedMetricCounts } from '../utils/feedMetrics' @@ -31,10 +32,8 @@ import crypto from 'crypto' import { applyViewerThreadMetrics, applyViewerWarningFlags, - appendVisibleThreadWindow, buildViewerThreadMetrics, filterViewerModeratedRows as filterViewerModeratedRowsBase, - finalizeVisibleThreadWindow, getThreadRootUri, isRowHiddenForViewer, type ModerationVisibilityAction, @@ -218,22 +217,6 @@ function normalizeThreadUri(value: string): string | null { return trimmed } -function encodeCursor(offset: number): string { - return Buffer.from(String(offset), 'utf8').toString('base64url') -} - -function decodeCursor(cursor: string | undefined): number { - if (!cursor) return 0 - try { - const decoded = Buffer.from(cursor, 'base64url').toString('utf8') - const parsed = Number.parseInt(decoded, 10) - if (!Number.isFinite(parsed) || parsed < 0) return 0 - return parsed - } catch { - return 0 - } -} - function encodeFeedCursor(row: UnifiedFeedRow): string | null { const createdAt = row.feedSortAt ?? row.createdAt if (!(createdAt instanceof Date) || Number.isNaN(createdAt.getTime())) return null @@ -733,14 +716,20 @@ async function queryFeedCandidates(params: { hashtag?: string | null sinceDate?: Date | null viewerIds?: ReadonlySet + followedAuthorIds?: readonly string[] + keysetCursor?: FeedCursorPayload | null }): Promise { - const { fetchLimit, source, hashtag, sinceDate, viewerIds } = params + const { fetchLimit, source, hashtag, sinceDate, viewerIds, followedAuthorIds, keysetCursor } = params const includeAt = !source || source === 'all' || source === 'atproto' const includeAp = !source || source === 'all' || source === 'activitypods' const normalizedTag = hashtag ? normalizeHashtag(hashtag) : null + const normalizedFollowed = followedAuthorIds + ? [...new Set(followedAuthorIds.map(id => id.trim()).filter(id => id.length > 0))] + : [] const atFilters: SQL[] = [sql`ap.is_public = true`] const apFilters: SQL[] = [sql`p.is_public = true`] + const apRemoteFilters: SQL[] = [sql`apr.is_public = true`] if (normalizedTag) { const tagPattern = `%${normalizedTag}%` @@ -750,10 +739,35 @@ async function queryFeedCandidates(params: { if (sinceDate) { atFilters.push(sql`ap.created_at > ${sinceDate}`) apFilters.push(sql`p.created_at > ${sinceDate}`) + apRemoteFilters.push(sql`apr.created_at > ${sinceDate}`) + } + + if (normalizedFollowed.length > 0) { + atFilters.push(sql`ap.author_did = ANY(${normalizedFollowed}::text[])`) + apFilters.push(sql`u.web_id = ANY(${normalizedFollowed}::text[])`) + apRemoteFilters.push(sql`apr.author_web_id = ANY(${normalizedFollowed}::text[])`) + } + + if (keysetCursor) { + const cursorTs = keysetCursor.createdAt + const cursorId = keysetCursor.id + atFilters.push(sql`( + LEAST(ap.created_at, NOW()) < ${cursorTs}::timestamptz + OR (LEAST(ap.created_at, NOW()) = ${cursorTs}::timestamptz AND ap.id < ${cursorId}) + )`) + apFilters.push(sql`( + p.created_at < ${cursorTs}::timestamptz + OR (p.created_at = ${cursorTs}::timestamptz AND p.id < ${cursorId}) + )`) + apRemoteFilters.push(sql`( + LEAST(apr.created_at, NOW()) < ${cursorTs}::timestamptz + OR (LEAST(apr.created_at, NOW()) = ${cursorTs}::timestamptz AND apr.id < ${cursorId}) + )`) } const atWhere = sql.join(atFilters, sql` AND `) const apWhere = sql.join(apFilters, sql` AND `) + const apRemoteWhere = sql.join(apRemoteFilters, sql` AND `) const cteFragments: SQL[] = [] const unionArms: SQL[] = [] @@ -824,7 +838,7 @@ async function queryFeedCandidates(params: { apr.reply_parent_uri, apr.reply_root_uri, apr.object_uri AS candidate_uri FROM ap_remote_posts apr - WHERE apr.is_public = true + WHERE ${apRemoteWhere} ORDER BY apr.created_at DESC LIMIT ${fetchLimit} )`) @@ -1120,12 +1134,7 @@ function feedItemObjectId(row: UnifiedFeedRow): string | null { function isRepostableTargetUri(value: string): boolean { if (value.startsWith('at://')) return parseAtUri(value) !== null - try { - const parsed = new URL(value) - return parsed.protocol === 'https:' || parsed.protocol === 'http:' - } catch { - return false - } + return isPublicHttpUrl(value) } function normalizeRepostTarget(body: { objectUri?: string | null; atUri?: string | null }): string | null { @@ -1397,6 +1406,7 @@ function extractCurrentUserIds(user: { atprotoDid?: string | null; getWebId?: (( async function resolveFollowedAuthorIds(user: { atprotoDid?: string | null; getWebId?: (() => string) | undefined }): Promise> { const currentUserIds = extractCurrentUserIds(user) if (currentUserIds.size === 0) return new Set() + const currentUserIdList = [...currentUserIds] const followRecords = await db .select({ @@ -1408,6 +1418,7 @@ async function resolveFollowedAuthorIds(user: { atprotoDid?: string | null; getW .where( and( eq(atRecords.isActive, true), + inArray(atRecords.authorDid, currentUserIdList), or( eq(atRecords.collection, 'app.bsky.graph.follow'), eq(atRecords.collection, 'canonical.follow'), @@ -1667,12 +1678,23 @@ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) ? (() => { const d = new Date(since); return isNaN(d.getTime()) ? null : d })() : null + const followedIds = timelineMode === 'following' + ? await resolveFollowedAuthorIds(user) + : null + if (timelineMode === 'following' && (!followedIds || followedIds.size === 0)) { + return useCursorPaging + ? ({ items: [], nextCursor: null, hasMore: false } as FeedPageResponse) + : [] + } + const candidateRows = dedupeFeedRows(await queryFeedCandidates({ fetchLimit, source: source && source !== 'all' ? source : null, hashtag: hashtag?.trim().length ? hashtag : null, sinceDate, viewerIds: extractCurrentUserIds(user), + followedAuthorIds: followedIds ? [...followedIds] : undefined, + keysetCursor, })) const queryDuration = Date.now() - queryStartTime console.log('[AT Bridge /feed] Query executed', { duration: queryDuration, rows: candidateRows.length }) @@ -1701,27 +1723,31 @@ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) const bumpedResults = await applyReplyThreadBumps(candidateRows, user) console.log('[AT Bridge /feed] Thread bumps applied', { duration: Date.now() - bumpStartTime, results: bumpedResults.length }) - const mediaFlaggedResults = await applyAtprotoMediaFlags(bumpedResults) - const warningResults = applyViewerWarningFlags(mediaFlaggedResults, moderationState) + const warningResults = applyViewerWarningFlags(bumpedResults, moderationState) const { visible: moderatedResults } = filterViewerModeratedRows(warningResults, moderationState) console.log('[AT Bridge /feed] Moderation filtered', { visible: moderatedResults.length, hidden: warningResults.length - moderatedResults.length }) - // For the following mode, filter down to only posts from followed authors. - let results = moderatedResults - if (timelineMode === 'following') { - const followedIds = await resolveFollowedAuthorIds(user) - if (followedIds.size > 0) { - results = moderatedResults.filter(row => { - // Match ATProto author DID or ActivityPub webId against followed set. - return followedIds.has(row.authorWebId) || - (row.atUri != null && followedIds.has(row.authorWebId)) - }) + const results = moderatedResults + + let postViewershipResults = results + if (excludeViewed && isViewershipIntegrationEnabled()) { + const objectIds = [...new Set(results.map(feedItemObjectId).filter((value): value is string => !!value))] + if (objectIds.length > 0) { + try { + const viewed = await resolveViewedObjectIds(user.getWebId(), objectIds) + postViewershipResults = results.filter(item => { + const objectId = feedItemObjectId(item) + return !objectId || !viewed.has(objectId) + }) + } catch (error) { + console.warn('[AT Bridge] Viewership filtering unavailable:', error) + } } } const cursorScopedResults = useCursorPaging - ? results.filter(row => isBeforeFeedCursor(row, keysetCursor!)) - : results + ? postViewershipResults.filter(row => isBeforeFeedCursor(row, keysetCursor!)) + : postViewershipResults const rawPage = timelineMode === 'chronological' || timelineMode === 'following' || source === 'activitypods' || source === 'atproto' ? (useCursorPaging @@ -1732,22 +1758,7 @@ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) : buildBalancedFeed(cursorScopedResults, limit, offset, normalizedApWeight, normalizedAtWeight)) const hasMore = useCursorPaging ? rawPage.length > limit : false - let output = useCursorPaging ? rawPage.slice(0, limit) : rawPage - - if (excludeViewed && isViewershipIntegrationEnabled()) { - const objectIds = [...new Set(output.map(feedItemObjectId).filter((value): value is string => !!value))] - if (objectIds.length > 0) { - try { - const viewed = await resolveViewedObjectIds(user.getWebId(), objectIds) - output = output.filter(item => { - const objectId = feedItemObjectId(item) - return !objectId || !viewed.has(objectId) - }) - } catch (error) { - console.warn('[AT Bridge] Viewership filtering unavailable:', error) - } - } - } + const output = useCursorPaging ? rawPage.slice(0, limit) : rawPage let metricsByRootUri = new Map() try { @@ -1812,7 +1823,7 @@ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) return status(400, 'Invalid rootUri') } - const offset = decodeCursor(cursor) + const threadCursor = decodeFeedCursor(cursor) try { const [rootRow] = await db @@ -1828,35 +1839,59 @@ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) const moderationState = await safeResolveViewerModerationState(user, 'thread') - let window = { page: [] as UnifiedFeedRow[], nextOffset: offset } - let hasMore = false + const visibleRows: UnifiedFeedRow[] = [] let exhausted = false + let hasMore = false + let queryCursor = threadCursor const batchSize = Math.min(100, Math.max(limit * 3, 25)) const maxScannedRows = Math.min(500, Math.max(limit * 12, 100)) + let scannedRows = 0 + + while (visibleRows.length < limit + 1 && scannedRows < maxScannedRows) { + const threadFilters: SQL[] = [ + or( + eq(unifiedFeedCandidatesView.replyRootUri, normalizedRootUri), + eq(unifiedFeedCandidatesView.replyParentUri, normalizedRootUri), + )!, + eq(unifiedFeedCandidatesView.isPublic, true), + ] + + if (queryCursor) { + threadFilters.push(sql`( + ${unifiedFeedCandidatesView.createdAt} < ${queryCursor.createdAt}::timestamptz + OR ( + ${unifiedFeedCandidatesView.createdAt} = ${queryCursor.createdAt}::timestamptz + AND ${unifiedFeedCandidatesView.id} < ${queryCursor.id} + ) + )`) + } - while (window.page.length < limit + 1 && window.nextOffset - offset < maxScannedRows) { const batch = (await db .select() .from(unifiedFeedCandidatesView) - .where( - and( - or( - eq(unifiedFeedCandidatesView.replyRootUri, normalizedRootUri), - eq(unifiedFeedCandidatesView.replyParentUri, normalizedRootUri), - ), - eq(unifiedFeedCandidatesView.isPublic, true), - ), - ) + .where(and(...threadFilters)) .orderBy(desc(unifiedFeedCandidatesView.createdAt), desc(unifiedFeedCandidatesView.id)) - .limit(batchSize) - .offset(window.nextOffset)) as UnifiedFeedRow[] + .limit(batchSize)) as UnifiedFeedRow[] if (batch.length === 0) { exhausted = true break } - window = appendVisibleThreadWindow(window, batch, limit, moderationState) + scannedRows += batch.length + + const lastBatchCursor = encodeFeedCursor(batch[batch.length - 1]) + queryCursor = lastBatchCursor ? decodeFeedCursor(lastBatchCursor) : null + + for (const row of batch) { + if (moderationState && isRowHiddenForViewer(row, moderationState)) { + continue + } + visibleRows.push(row) + if (visibleRows.length >= limit + 1) { + break + } + } if (batch.length < batchSize) { exhausted = true @@ -1864,10 +1899,11 @@ const atBridgePlugin = new Elysia({ name: 'at-bridge', prefix: '/at' }) } } - const finalizedWindow = finalizeVisibleThreadWindow(window.page, window.nextOffset, limit, exhausted) - hasMore = finalizedWindow.hasMore - const visiblePage = finalizedWindow.visiblePage - const nextCursor = finalizedWindow.nextCursorOffset !== null ? encodeCursor(finalizedWindow.nextCursorOffset) : null + const visiblePage = visibleRows.slice(0, limit) + hasMore = visibleRows.length > limit || (!exhausted && visiblePage.length > 0) + const nextCursor = hasMore && visiblePage.length > 0 + ? encodeFeedCursor(visiblePage[visiblePage.length - 1]) + : null const visibleRoot = rootRow && !isRowHiddenForViewer(rootRow as UnifiedFeedRow, moderationState) ? mapFeedItemForResponse(rootRow as UnifiedFeedRow) diff --git a/api/src/services/ApRemoteIngestionService.ts b/api/src/services/ApRemoteIngestionService.ts index 09cd004f2..6ea60e197 100644 --- a/api/src/services/ApRemoteIngestionService.ts +++ b/api/src/services/ApRemoteIngestionService.ts @@ -33,6 +33,7 @@ const AS_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public' const MAX_CONTENT_LENGTH = 100_000 const MAX_URI_LENGTH = 3072 const MAX_ACTIVITIES_PER_BATCH = 100 +const MAX_ACTOR_DOC_BYTES = 512_000 const AP_ACTOR_CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours /** In-flight fetch guard — prevents concurrent duplicate fetches of the same actor. */ @@ -115,6 +116,56 @@ function parsePublished(value: unknown): Date { return clampToNow(parsed) } +async function readJsonBodyCapped(response: Response, maxBytes: number): Promise | null> { + const contentLengthHeader = response.headers.get('content-length') + if (contentLengthHeader) { + const contentLength = Number.parseInt(contentLengthHeader, 10) + if (Number.isFinite(contentLength) && contentLength > maxBytes) { + return null + } + } + + const body = response.body + if (!body) return null + + const reader = body.getReader() + const chunks: Uint8Array[] = [] + let bytesRead = 0 + + try { + while (true) { + const { done, value } = await reader.read() + if (done || !value) break + + bytesRead += value.byteLength + if (bytesRead > maxBytes) { + return null + } + + chunks.push(value) + } + } finally { + try { await reader.cancel() } catch { /* ignore */ } + } + + const merged = new Uint8Array(bytesRead) + let offset = 0 + for (const chunk of chunks) { + merged.set(chunk, offset) + offset += chunk.byteLength + } + + try { + const raw = new TextDecoder().decode(merged) + const parsed = JSON.parse(raw) + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? parsed as Record + : null + } catch { + return null + } +} + function stableRepostId(actorUri: string, objectUri: string): string { return crypto .createHash('sha256') @@ -291,7 +342,8 @@ async function cacheApActorIfStale(actorUri: string): Promise { })() if (!resp || !resp.ok) return - const actor = await resp.json() as Record + const actor = await readJsonBodyCapped(resp, MAX_ACTOR_DOC_BYTES) + if (!actor) return const preferredUsername = typeof actor['preferredUsername'] === 'string' ? actor['preferredUsername'].slice(0, 512) diff --git a/api/src/services/LinkPreviewService.ts b/api/src/services/LinkPreviewService.ts index 5ffaa5b73..ec6d6fb8e 100644 --- a/api/src/services/LinkPreviewService.ts +++ b/api/src/services/LinkPreviewService.ts @@ -89,6 +89,41 @@ function fallbackPreview(url: string): LinkPreviewResult { } } +async function readCappedTextBody(response: Response, maxBytes: number): Promise { + const body = response.body + if (!body) return '' + + const reader = body.getReader() + const decoder = new TextDecoder() + let bytesRead = 0 + let text = '' + + try { + while (true) { + const { done, value } = await reader.read() + if (done || !value) break + + if (bytesRead >= maxBytes) { + break + } + + const remaining = maxBytes - bytesRead + const chunk = value.byteLength > remaining ? value.subarray(0, remaining) : value + text += decoder.decode(chunk, { stream: true }) + bytesRead += chunk.byteLength + + if (chunk.byteLength < value.byteLength) { + break + } + } + } finally { + try { await reader.cancel() } catch { /* ignore */ } + } + + text += decoder.decode() + return text +} + export async function fetchLinkPreview(inputUrl: string): Promise { const url = sanitizeHttpUrl(inputUrl) const cached = cache.get(url) @@ -120,8 +155,7 @@ export async function fetchLinkPreview(inputUrl: string): Promise MAX_HTML_BYTES ? fullHtml.slice(0, MAX_HTML_BYTES) : fullHtml + const html = await readCappedTextBody(response, MAX_HTML_BYTES) const title = readMeta(html, 'og:title') || readMeta(html, 'twitter:title', 'name') diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 2ef5927a4..c3c7dd580 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -92,8 +92,6 @@ services: # Redis connection REDIS_HOST: redis REDIS_PORT: 6379 - # ActivityPods uses host networking so it can reach the app backend at localhost:8794 - network_mode: host healthcheck: test: ['CMD-SHELL', 'curl -sf http://localhost:3000/.well-known/openid-configuration || exit 1'] interval: 15s diff --git a/frontend/src/components/SignupForm.vue b/frontend/src/components/SignupForm.vue index 2b5226240..30669ce10 100644 --- a/frontend/src/components/SignupForm.vue +++ b/frontend/src/components/SignupForm.vue @@ -8,6 +8,35 @@ import { ProviderSignUpErrors } from '@/types' import type { ProviderEndpoints } from '@/types' import { useI18n } from '@/i18n' +function normalizeProviderEndpoint(value: string): string | null { + const trimmed = value.trim() + if (!trimmed) return null + + try { + const parsed = new URL(trimmed) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null + } + parsed.hash = '' + return parsed.toString().replace(/\/$/, '') + } catch { + return null + } +} + +const configuredProviderEndpoints = [ + import.meta.env.VITE_POD_PROVIDER_BASE_URL, + import.meta.env.VITE_ACTIVITYPUB_PROXY_BASE_URL, + ...(String(import.meta.env.VITE_MEMORY_POD_PROVIDER_ENDPOINTS ?? '').split(',')), +] + .map(value => normalizeProviderEndpoint(String(value ?? ''))) + .filter((value): value is string => value !== null) + +const allowedProviderEndpoints = new Set([ + 'http://localhost:3000', + ...configuredProviderEndpoints, +]) + // Store const authStore = useAuthStore() const { t } = useI18n() @@ -96,9 +125,15 @@ function validateForm(): void { } // endpoint check - if (!endpoint.value.startsWith('http')) { + const normalizedEndpoint = normalizeProviderEndpoint(endpoint.value) + if (!normalizedEndpoint) { errorMessages.value.endpoint = 'Must be a valid http:// or https:// URL' valid = false + } else if (!allowedProviderEndpoints.has(normalizedEndpoint)) { + errorMessages.value.endpoint = `Provider must be one of: ${Array.from(allowedProviderEndpoints).join(', ')}` + valid = false + } else { + endpoint.value = normalizedEndpoint } formIsValid.value = valid diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts index caad45cce..8a0b49d25 100644 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -51,7 +51,7 @@ export const useAuthStore = defineStore('auth', () => { // API // Eden treaty requires the Elysia App type parameter for full inference; // without it the client cannot be statically typed on the frontend. - + const client = treaty(getApiBaseUrl(), { onRequest() { return { @@ -354,7 +354,7 @@ export const useAuthStore = defineStore('auth', () => { setUser(signupResponse.user) markSessionStartedNow() router.push({ name: 'home' }) - } else if (status === 500) { + } else { return response as ApiErrors } } diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 10c4adc52..77e114472 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -1,12 +1,11 @@ import type { App } from '#api/index.ts' -import type { _createUser, _selectUsers, viablePodProviders } from '#api/types' -import type { Static } from '@sinclair/typebox' +import type { CreateUser as ApiCreateUser, SelectUsers, SignUpBody } from '#api/types' // Enums -export type ProviderEndpoints = Static +export type ProviderEndpoints = SignUpBody['providerEndpoint'] // Table entries -type DbUser = Static +type DbUser = SelectUsers export type User = Omit /** FEP-9967: poll options for a new Question post. */ @@ -90,7 +89,7 @@ export interface CreatePost { /** When set, the post is published as a Question (FEP-9967 poll). */ poll?: CreatePoll | null } -export type CreateUser = Static +export type CreateUser = ApiCreateUser // Route Responses From 1db4ad3cfc46c4fd63a6d510a1753548f75b7936 Mon Sep 17 00:00:00 2001 From: Damon Outlaw Date: Fri, 8 May 2026 20:05:09 -0400 Subject: [PATCH 02/14] Disable memory dashboard UI by default --- docker-compose.local.yml | 10 ++++++---- frontend/src/App.vue | 3 ++- frontend/src/components/BottomNav.vue | 5 ++++- frontend/src/router/index.ts | 6 ++++++ 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/docker-compose.local.yml b/docker-compose.local.yml index c3c7dd580..eff26eb7f 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -135,11 +135,12 @@ services: DB_URL: postgres://postgres:localpassword@pg:5432/memory JWT_SECRET: local-dev-jwt-secret-change-in-prod API_PORT: 8794 - FRONTEND_URL: http://localhost:5174 + FRONTEND_URL: http://localhost:5173 FIREHOSE_BRIDGE_SECRET: local-bridge-secret-123 AP_BRIDGE_SECRET: ${AP_BRIDGE_SECRET:-change-me-ap-secret} - POD_PROVIDER_BASE_URL: ${POD_PROVIDER_BASE_URL:-http://localhost:3000} - ACTIVITYPUB_PROXY_BASE_URL: ${ACTIVITYPUB_PROXY_BASE_URL:-http://localhost:3000} + # Inside the API container, reach ActivityPods via Compose service DNS. + POD_PROVIDER_BASE_URL: ${POD_PROVIDER_BASE_URL:-http://activitypods:3000} + ACTIVITYPUB_PROXY_BASE_URL: ${ACTIVITYPUB_PROXY_BASE_URL:-http://activitypods:3000} networks: - default - activitypods-network @@ -182,7 +183,8 @@ services: api: condition: service_healthy environment: - MEMORY_WEBHOOK_URL: http://localhost:8794/at/webhook/ingress + # Sidecar runs in Docker and should call the API container over service DNS. + MEMORY_WEBHOOK_URL: http://api:8794/at/webhook/ingress FIREHOSE_BRIDGE_SECRET: local-bridge-secret-123 # Preferred mode selector: mock | relay | jetstream FIREHOSE_MODE: ${FIREHOSE_MODE:-jetstream} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b4a8fabb2..182bf1fb9 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -8,8 +8,9 @@ import { useI18n } from '@/i18n' const route = useRoute() const { t } = useI18n() +const dashboardUiEnabled = import.meta.env.VITE_ENABLE_PROVIDER_DASHBOARD === 'true' -const isDashboard = computed(() => route.path.startsWith('/dashboard')) +const isDashboard = computed(() => dashboardUiEnabled && route.path.startsWith('/dashboard')) const documentTitle = computed(() => { const titleKey = typeof route.meta.titleKey === 'string' ? route.meta.titleKey : 'app.name' diff --git a/frontend/src/components/BottomNav.vue b/frontend/src/components/BottomNav.vue index e3277f2a7..ca858f51e 100644 --- a/frontend/src/components/BottomNav.vue +++ b/frontend/src/components/BottomNav.vue @@ -10,6 +10,7 @@ const route = useRoute() const router = useRouter() const { t } = useI18n() const notificationsStore = useNotificationsStore() +const dashboardUiEnabled = import.meta.env.VITE_ENABLE_PROVIDER_DASHBOARD === 'true' const hiddenRoutes = new Set(['signin', 'signup', 'welcome', 'experience']) const show = computed(() => !hiddenRoutes.has(String(route.name)) && !route.path.startsWith('/dashboard')) @@ -21,7 +22,9 @@ const items = computed(() => [ { name: 'explore', route: '/explore', label: t('nav.explore'), icon: 'explore' }, { name: 'messages', route: '/messages', label: t('nav.messages'), icon: 'messages' }, { name: 'notifications', route: '/notifications', label: t('nav.notifications'), icon: 'notifications' }, - { name: 'dashboard', route: '/dashboard', label: 'Dashboard', icon: 'dashboard' }, + ...(dashboardUiEnabled + ? [{ name: 'dashboard', route: '/dashboard', label: 'Dashboard', icon: 'dashboard' as IconName }] + : []), ]) function isActive(item: NavItem): boolean { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 687a83de7..974e3559d 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -2,6 +2,8 @@ import { createRouter, createWebHistory } from 'vue-router' import HomeView from '../views/HomeView.vue' import { useAuthStore } from '@/stores/authStore' +const dashboardUiEnabled = import.meta.env.VITE_ENABLE_PROVIDER_DASHBOARD === 'true' + const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ @@ -172,6 +174,10 @@ const PUBLIC_ROUTES = new Set(['signin', 'signup', 'welcome', 'auth-callback']) router.beforeEach((to, _, next) => { const authStore = useAuthStore() + if (!dashboardUiEnabled && to.path.startsWith('/dashboard')) { + return next({ name: 'settings' }) + } + // Fail closed: when secure logout cleanup failed, keep user on sign-in // until local cache wipe succeeds via retry. if (authStore.logoutBlocked && to.name !== 'signin') { From 19b2a1c10c92d0eff6a9aad5af725cebfd855393 Mon Sep 17 00:00:00 2001 From: Damon Outlaw Date: Fri, 8 May 2026 22:41:21 -0400 Subject: [PATCH 03/14] feat(wall): add wall post feature with ATProto bridge ingestion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB: migration 0016_wall_posts.sql adds wallTargetUserId FK + index on posts table - API schema: wallTargetUserId column on posts, with covering index - API routes: GET /wall/:targetWebId, GET /wall/:targetWebId/posts, POST /wall/:targetWebId, DELETE /wall/posts/:postId - Hardened with isValidWebId() regex on all handlers (injection prevention) - Single-trim of content, eq(posts.isPublic, true) on public fetch - API webhook: MAX_BATCH=500 cap, wallTarget field in body schema, 400 response type - AtBridgeIngestionService: WallPostCreate/WallPostDelete canonical intent kinds - shouldProjectCanonicalToWall() routing helper - handleCanonicalWallCreate() + handleCanonicalWallDelete() handlers - isSupportedLexiconCollection() accepts org.activitypods.* - Wall posts excluded from feed projection (shouldProjectCanonicalToFeed) - Cross-pod (remote author) posts fall back to atRecords only, no FK violation - Idempotent insert via objectUri check - Frontend: WallComposer.vue (500-char limit, Ctrl/Cmd+Enter submit) - Frontend: wallStore.ts (fetchWallPosts, postOnWall optimistic prepend, deleteWallPost) - Frontend: UserProfileView.vue — wall on user profiles, @posted handler reloads store - Frontend: ProfileView.vue — Posts tab loads own wall posts via Eden client - i18n: EN + ES keys for wall feature (wall.post, wall.empty, wall.targetNotFound, etc.) - Router: /profile/:webId route for UserProfileView --- api/drizzle/0016_wall_posts.sql | 2 + api/src/db/schema.ts | 8 + api/src/i18n.ts | 14 + api/src/index.ts | 3 +- api/src/routes/atBridgeWebhook.ts | 10 + api/src/routes/index.ts | 1 + api/src/routes/wall.ts | 382 ++++++++++++++++ api/src/services/AtBridgeIngestionService.ts | 181 +++++++- api/src/types/index.ts | 8 + frontend/src/components/WallComposer.vue | 108 +++++ frontend/src/i18n/messages.ts | 62 ++- frontend/src/router/index.ts | 91 +--- frontend/src/stores/wallStore.ts | 172 ++++++++ frontend/src/views/ProfileView.vue | 437 +++++++++++++++++++ frontend/src/views/UserProfileView.vue | 240 ++++++++++ 15 files changed, 1642 insertions(+), 77 deletions(-) create mode 100644 api/drizzle/0016_wall_posts.sql create mode 100644 api/src/routes/wall.ts create mode 100644 frontend/src/components/WallComposer.vue create mode 100644 frontend/src/stores/wallStore.ts create mode 100644 frontend/src/views/ProfileView.vue create mode 100644 frontend/src/views/UserProfileView.vue diff --git a/api/drizzle/0016_wall_posts.sql b/api/drizzle/0016_wall_posts.sql new file mode 100644 index 000000000..7ee48022e --- /dev/null +++ b/api/drizzle/0016_wall_posts.sql @@ -0,0 +1,2 @@ +ALTER TABLE "posts" ADD COLUMN "wall_target_user_id" integer REFERENCES "users"("id"); +CREATE INDEX "posts_wall_target_user_id_idx" ON "posts" ("wall_target_user_id"); diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index 7e3168eaf..277940c02 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -48,8 +48,16 @@ export const posts = table('posts', { summary: text('summary'), clientPostKey: varchar('client_post_key', { length: 128 }), clientPostRequestHash: varchar('client_post_request_hash', { length: 64 }), + /** + * When set, this post was written on another user's wall (Facebook-style). + * The value is the ID of the profile owner whose wall received the post. + * Wall posts use the ActivityStreams `target` property (on the Create activity) + * to federate the wall context, following Friendica's implementation pattern. + */ + wallTargetUserId: integer('wall_target_user_id').references(() => users.id), }, t => [ uniqueIndex('posts_author_client_key_unique_idx').on(t.authorId, t.clientPostKey).where(sql`${t.clientPostKey} IS NOT NULL`), + index('posts_wall_target_user_id_idx').on(t.wallTargetUserId), ]) export const mediaAttachments = table('media_attachments', { diff --git a/api/src/i18n.ts b/api/src/i18n.ts index 210c8171b..500775585 100644 --- a/api/src/i18n.ts +++ b/api/src/i18n.ts @@ -80,6 +80,13 @@ const messages: Record> = { 'conversations.createFailed': 'Failed to create conversation', 'conversations.notMember': 'Not a member of this conversation', 'conversations.sendFailed': 'Failed to send message', + 'wall.postEmpty': 'Write something before posting on this wall', + 'wall.postTooLong': 'Wall posts may be at most 500 characters', + 'wall.targetNotFound': 'That profile was not found', + 'wall.postNotFound': 'That wall post was not found', + 'wall.forbidden': 'You do not have permission to do that', + 'wall.invalidPostId': 'Invalid post ID', + 'wall.postFailed': 'Could not publish the wall post to your pod', }, es: { 'common.mustBeSignedIn': 'Debes iniciar sesión para hacer eso', @@ -155,6 +162,13 @@ const messages: Record> = { 'conversations.createFailed': 'No se pudo crear la conversación', 'conversations.notMember': 'No eres miembro de esta conversación', 'conversations.sendFailed': 'No se pudo enviar el mensaje', + 'wall.postEmpty': 'Escribe algo antes de publicar en este muro', + 'wall.postTooLong': 'Las publicaciones del muro pueden tener como máximo 500 caracteres', + 'wall.targetNotFound': 'No se encontró ese perfil', + 'wall.postNotFound': 'No se encontró esa publicación del muro', + 'wall.forbidden': 'No tienes permiso para hacer eso', + 'wall.invalidPostId': 'ID de publicación no válido', + 'wall.postFailed': 'No se pudo publicar en el muro de tu pod', }, } diff --git a/api/src/index.ts b/api/src/index.ts index 5bd13d380..43886cb1e 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,6 +1,6 @@ import { Elysia } from 'elysia' import { _createPost, _selectUsers } from './types' -import { postsPlugin, authPlugin, oidcAuthPlugin, oidcClientPlugin, setupPlugin, atBridgePlugin, followPlugin, replyPlugin, actorMetadataPlugin, profilePlugin, mastodonApiPlugin, conversationsPlugin, activityPodsAppPublicPlugin, activityPodsNotificationsPlugin, chatPlugin, linkPreviewPlugin, bookmarksPlugin, mediaUploadsPlugin, mediaSidecarCallbackPlugin } from './routes' +import { postsPlugin, authPlugin, oidcAuthPlugin, oidcClientPlugin, setupPlugin, atBridgePlugin, followPlugin, replyPlugin, actorMetadataPlugin, profilePlugin, mastodonApiPlugin, conversationsPlugin, activityPodsAppPublicPlugin, activityPodsNotificationsPlugin, chatPlugin, linkPreviewPlugin, bookmarksPlugin, mediaUploadsPlugin, mediaSidecarCallbackPlugin, wallPlugin } from './routes' import atBridgeWebhookPlugin from './routes/atBridgeWebhook' import { xrpcFeedPlugin } from './routes/atBridge' import apBridgeWebhookPlugin from './routes/apBridgeWebhook' @@ -76,6 +76,7 @@ const protectedRoutes = new Elysia({ aot: false }) .use(linkPreviewPlugin) .use(mediaUploadsPlugin) .use(chatPlugin) + .use(wallPlugin) export const app = new Elysia({ aot: false }) // Top-level health check — used by Docker healthcheck and the Mastopod harness diff --git a/api/src/routes/atBridgeWebhook.ts b/api/src/routes/atBridgeWebhook.ts index a381d32d2..c46ad4bcd 100644 --- a/api/src/routes/atBridgeWebhook.ts +++ b/api/src/routes/atBridgeWebhook.ts @@ -65,6 +65,13 @@ const atBridgeWebhookPlugin = new Elysia({ name: 'at-bridge-webhook', prefix: '/ // Process events const events = Array.isArray(body) ? body : [body] + + // Enforce a batch-size cap to prevent resource exhaustion. + const MAX_BATCH = 500 + if (events.length > MAX_BATCH) { + set.status = 400 + return `Batch too large: max ${MAX_BATCH} events per request` + } const results = { processed: 0, failed: 0, @@ -119,6 +126,7 @@ const atBridgeWebhookPlugin = new Elysia({ name: 'at-bridge-webhook', prefix: '/ content: t.Optional(t.Any()), inReplyTo: t.Optional(t.Any()), subject: t.Optional(t.Any()), + wallTarget: t.Optional(t.Any()), reactionType: t.Optional(t.String()), state: t.Optional(t.String()), }), @@ -137,6 +145,7 @@ const atBridgeWebhookPlugin = new Elysia({ name: 'at-bridge-webhook', prefix: '/ content: t.Optional(t.Any()), inReplyTo: t.Optional(t.Any()), subject: t.Optional(t.Any()), + wallTarget: t.Optional(t.Any()), reactionType: t.Optional(t.String()), state: t.Optional(t.String()), })), @@ -148,6 +157,7 @@ const atBridgeWebhookPlugin = new Elysia({ name: 'at-bridge-webhook', prefix: '/ failed: t.Number(), total: t.Number(), }), + 400: t.String(), 401: t.String(), 503: t.String(), }, diff --git a/api/src/routes/index.ts b/api/src/routes/index.ts index 6766b4ec0..2c2a2fcd9 100644 --- a/api/src/routes/index.ts +++ b/api/src/routes/index.ts @@ -18,3 +18,4 @@ export { default as linkPreviewPlugin } from './linkPreview' export { default as bookmarksPlugin } from './bookmarks' export { default as mediaUploadsPlugin } from './mediaUploads' export { default as mediaSidecarCallbackPlugin } from './mediaSidecarCallback' +export { default as wallPlugin } from './wall' diff --git a/api/src/routes/wall.ts b/api/src/routes/wall.ts new file mode 100644 index 000000000..201ca904e --- /dev/null +++ b/api/src/routes/wall.ts @@ -0,0 +1,382 @@ +/** + * Wall Route — Facebook-style profile wall for Memory + * + * Implements wall posts using the ActivityStreams 2.0 `target` property on + * `Create` activities, following Friendica's AP wall implementation pattern. + * + * Routes: + * GET /wall/:targetWebId — fetch wall posts for a profile (paginated) + * POST /wall/:targetWebId — write on someone's wall (auth required) + * DELETE /wall/posts/:postId — delete a wall post (author or wall owner) + * + * AP federation: wall posts include `target` pointing to the wall owner's actor + * URI so federated servers can correctly attribute the post to the recipient's + * wall context (identical to Friendica's approach). + */ + +import Elysia, { t } from 'elysia' +import { signedInGuard } from './elysiaCompat' +import setupPlugin from './setup' +import { db } from '../db/client' +import { posts, users } from '../db/schema' +import ActivityPod from '../services/ActivityPod' +import { buildOutboxPost } from '../postPayload' +import { localeFromHeaders, translate } from '../i18n' +import { and, desc, eq, isNotNull, isNull } from 'drizzle-orm' +import { mergeHashtags } from '../utils/hashtags' + +const WALL_POST_CHAR_LIMIT = 500 +const WALL_PAGE_DEFAULT = 20 +const WALL_PAGE_MAX = 50 +/** WebIDs must be absolute HTTP(S) URIs — reject anything else before it reaches the DB. */ +const WEBID_URL_RE = /^https?:\/\/.{2,}/i +const WEBID_MAX_LENGTH = 2048 + +/** Decode a webId that may be URL-encoded in the route path. */ +function decodeWebId(raw: string): string { + try { + return decodeURIComponent(raw) + } catch { + return raw + } +} + +/** + * Validate that a decoded webId is a well-formed HTTP(S) URL. + * Prevents arbitrary strings from reaching DB query parameters. + */ +function isValidWebId(webId: string): boolean { + return ( + webId.length >= 10 && + webId.length <= WEBID_MAX_LENGTH && + WEBID_URL_RE.test(webId) + ) +} + +const wallPlugin = new Elysia({ name: 'wall' }) + .use(setupPlugin) + // ─── Public: fetch the user's own public posts for their profile ────────── + .get( + '/wall/:targetWebId/posts', + async ({ params, query, set, headers }: any) => { + const locale = localeFromHeaders(headers) + const targetWebId = decodeWebId(params.targetWebId) + + if (!isValidWebId(targetWebId)) { + set.status = 404 + return translate(locale, 'wall.targetNotFound') + } + + const limit = Math.min(Number(query.limit) || WALL_PAGE_DEFAULT, WALL_PAGE_MAX) + const offset = Math.max(Number(query.offset) || 0, 0) + + const [targetUser] = await db + .select({ id: users.id, name: users.name, webId: users.webId }) + .from(users) + .where(eq(users.webId, targetWebId)) + .limit(1) + + if (!targetUser) { + set.status = 404 + return translate(locale, 'wall.targetNotFound') + } + + // Only return the user's own posts (not wall posts written on others' walls) + const userPosts = await db + .select({ + id: posts.id, + content: posts.content, + hashtags: posts.hashtags, + postType: posts.postType, + createdAt: posts.createdAt, + objectUri: posts.objectUri, + name: posts.name, + summary: posts.summary, + }) + .from(posts) + .where( + and( + eq(posts.authorId, targetUser.id), + eq(posts.isPublic, true), + isNull(posts.wallTargetUserId), + ) + ) + .orderBy(desc(posts.createdAt)) + .limit(limit) + .offset(offset) + + return { + targetUser: { id: targetUser.id, name: targetUser.name, webId: targetUser.webId }, + posts: userPosts.map(p => ({ + id: p.id, + content: p.content, + hashtags: p.hashtags, + postType: p.postType, + createdAt: p.createdAt?.toISOString() ?? null, + objectUri: p.objectUri, + name: p.name, + summary: p.summary, + author: { + id: targetUser.id, + name: targetUser.name, + webId: targetUser.webId, + }, + })), + pagination: { limit, offset }, + } + }, + { + params: t.Object({ targetWebId: t.String() }), + query: t.Object({ + limit: t.Optional(t.Numeric()), + offset: t.Optional(t.Numeric()), + }), + response: { + 200: t.Any(), + 404: t.String(), + }, + detail: { description: "Fetch a user's own public posts for their profile page" }, + } + ) + // ─── Public: fetch wall posts for a profile ─────────────────────────────── + .get( + '/wall/:targetWebId', + async ({ params, query, set, headers }: any) => { + const locale = localeFromHeaders(headers) + const targetWebId = decodeWebId(params.targetWebId) + + if (!isValidWebId(targetWebId)) { + set.status = 404 + return translate(locale, 'wall.targetNotFound') + } + + const limit = Math.min(Number(query.limit) || WALL_PAGE_DEFAULT, WALL_PAGE_MAX) + const offset = Math.max(Number(query.offset) || 0, 0) + + // Resolve target user + const [targetUser] = await db + .select({ id: users.id, name: users.name, webId: users.webId }) + .from(users) + .where(eq(users.webId, targetWebId)) + .limit(1) + + if (!targetUser) { + set.status = 404 + return translate(locale, 'wall.targetNotFound') + } + + // Fetch wall posts for this profile (where wallTargetUserId = targetUser.id) + const wallPosts = await db + .select({ + id: posts.id, + content: posts.content, + hashtags: posts.hashtags, + postType: posts.postType, + createdAt: posts.createdAt, + objectUri: posts.objectUri, + authorId: posts.authorId, + authorName: users.name, + authorWebId: users.webId, + }) + .from(posts) + .innerJoin(users, eq(posts.authorId, users.id)) + .where( + and( + eq(posts.wallTargetUserId, targetUser.id), + isNotNull(posts.content), + eq(posts.isPublic, true), + ) + ) + .orderBy(desc(posts.createdAt)) + .limit(limit) + .offset(offset) + + return { + targetUser: { id: targetUser.id, name: targetUser.name, webId: targetUser.webId }, + posts: wallPosts.map(p => ({ + id: p.id, + content: p.content, + hashtags: p.hashtags, + postType: p.postType, + createdAt: p.createdAt?.toISOString() ?? null, + objectUri: p.objectUri, + author: { + id: p.authorId, + name: p.authorName, + webId: p.authorWebId, + }, + })), + pagination: { limit, offset }, + } + }, + { + params: t.Object({ targetWebId: t.String() }), + query: t.Object({ + limit: t.Optional(t.Numeric()), + offset: t.Optional(t.Numeric()), + }), + response: { + 200: t.Any(), + 404: t.String(), + }, + detail: { description: "Fetch wall posts for a user's profile" }, + } + ) + // ─── Protected: post on someone's wall ─────────────────────────────────── + .guard(signedInGuard) + .post( + '/wall/:targetWebId', + async ({ params, body, set, headers, user }: any) => { + const locale = localeFromHeaders(headers) + const targetWebId = decodeWebId(params.targetWebId) + + if (!isValidWebId(targetWebId)) { + set.status = 404 + return translate(locale, 'wall.targetNotFound') + } + + // Trim once; all subsequent checks and inserts use this value. + const { content: rawContent } = body as { content: string } + const content = typeof rawContent === 'string' ? rawContent.trim() : '' + + if (content.length === 0) { + set.status = 400 + return translate(locale, 'wall.postEmpty') + } + if (content.length > WALL_POST_CHAR_LIMIT) { + set.status = 400 + return translate(locale, 'wall.postTooLong') + } + + // Resolve target profile owner + const [targetUser] = await db + .select({ id: users.id, name: users.name, webId: users.webId }) + .from(users) + .where(eq(users.webId, targetWebId)) + .limit(1) + + if (!targetUser) { + set.status = 404 + return translate(locale, 'wall.targetNotFound') + } + + // Build the ActivityPub note with `target` pointing at the wall owner's actor + const hashtags = mergeHashtags(content, []) + const note = buildOutboxPost({ + user, + content, + hashtags, + isPublic: true, + postType: 'note', + }) + + // Attach the AS2 `target` for wall post federation (Friendica-style) + note.target = targetUser.webId + + let objectUri: string | null = null + try { + const created = await ActivityPod.createPost(user, note) + objectUri = created.objectUri + } catch (err) { + console.error('[wall] pod-post-failed', { userId: user.userId, targetWebId, err }) + set.status = 502 + return translate(locale, 'wall.postFailed') + } + + const [inserted] = await db + .insert(posts) + .values({ + authorId: user.userId, + content, + hashtags, + isPublic: true, + postType: 'note', + objectUri, + wallTargetUserId: targetUser.id, + }) + .returning() + + return { + id: inserted.id, + content: inserted.content, + hashtags: inserted.hashtags, + postType: inserted.postType, + createdAt: inserted.createdAt?.toISOString() ?? null, + objectUri: inserted.objectUri, + author: { + id: user.userId, + name: user.userName, + webId: user.getWebId(), + }, + targetUser: { + id: targetUser.id, + name: targetUser.name, + webId: targetUser.webId, + }, + } + }, + { + params: t.Object({ targetWebId: t.String() }), + body: t.Object({ content: t.String() }), + response: { + 200: t.Any(), + 400: t.String(), + 404: t.String(), + 502: t.String(), + }, + detail: { description: "Write a post on a user's wall" }, + } + ) + // ─── Protected: delete a wall post ──────────────────────────────────────── + .delete( + '/wall/posts/:postId', + async ({ params, set, headers, user }: any) => { + const locale = localeFromHeaders(headers) + const postId = Number(params.postId) + if (!Number.isInteger(postId) || postId <= 0) { + set.status = 400 + return translate(locale, 'wall.invalidPostId') + } + + // Fetch the wall post with its target user info + const [wallPost] = await db + .select({ + id: posts.id, + authorId: posts.authorId, + wallTargetUserId: posts.wallTargetUserId, + }) + .from(posts) + .where(and(eq(posts.id, postId), isNotNull(posts.wallTargetUserId))) + .limit(1) + + if (!wallPost) { + set.status = 404 + return translate(locale, 'wall.postNotFound') + } + + // Only the post author or the wall owner may delete + const isAuthor = wallPost.authorId === user.userId + const isWallOwner = wallPost.wallTargetUserId === user.userId + if (!isAuthor && !isWallOwner) { + set.status = 403 + return translate(locale, 'wall.forbidden') + } + + await db.delete(posts).where(eq(posts.id, postId)) + + set.status = 204 + return null + }, + { + params: t.Object({ postId: t.String() }), + response: { + 204: t.Null(), + 400: t.String(), + 403: t.String(), + 404: t.String(), + }, + detail: { description: 'Delete a wall post (author or wall owner only)' }, + } + ) + +export default wallPlugin diff --git a/api/src/services/AtBridgeIngestionService.ts b/api/src/services/AtBridgeIngestionService.ts index d440e9c14..b43135579 100644 --- a/api/src/services/AtBridgeIngestionService.ts +++ b/api/src/services/AtBridgeIngestionService.ts @@ -29,7 +29,8 @@ import { db } from '../db/client' import { atPosts, atIdentities, atFirehoseCursors, atRecords } from '../db/atBridgeSchema' -import { eq } from 'drizzle-orm' +import { posts, users } from '../db/schema' +import { and, eq, isNotNull } from 'drizzle-orm' // --------------------------------------------------------------------------- // Types (mirrors at.ingress.v1 event schema) @@ -142,10 +143,20 @@ export type CanonicalIntentEvent = CanonicalIntentBase & { | 'FollowRemove' | 'ProfileUpdate' | 'AccountState' + /** A note written on another actor's profile wall (org.activitypods.wall.post). */ + | 'WallPostCreate' + /** Deletion of a wall post. */ + | 'WallPostDelete' object?: CanonicalObjectRef content?: CanonicalContent inReplyTo?: CanonicalObjectRef | null subject?: CanonicalActorRef + /** + * Present on WallPostCreate / WallPostDelete intents. + * Identifies the actor whose profile wall the post was written on. + * Corresponds to the AS2 `target` property on the ActivityPub Create activity. + */ + wallTarget?: CanonicalActorRef | null reactionType?: 'like' state?: 'active' | 'suspended' | 'deactivated' } @@ -182,7 +193,12 @@ function parseAtUri(uri: string): { did: string; collection: string; rkey: strin } function isSupportedLexiconCollection(collection: string): boolean { - return collection.startsWith('app.bsky.') || collection.startsWith('standard.site.') + return ( + collection.startsWith('app.bsky.') || + collection.startsWith('standard.site.') || + // ActivityPods-specific lexicons (e.g. org.activitypods.wall.post) + collection.startsWith('org.activitypods.') + ) } function extractRecordCreatedAt(record: any): Date { @@ -272,6 +288,7 @@ function canonicalOperation(kind: CanonicalIntentEvent['kind']): 'create' | 'upd case 'ReactionRemove': case 'ShareRemove': case 'FollowRemove': + case 'WallPostDelete': return 'delete' default: return 'create' @@ -299,6 +316,9 @@ function canonicalCollection(intent: CanonicalIntentEvent): string { case 'FollowAdd': case 'FollowRemove': return 'canonical.follow' + case 'WallPostCreate': + case 'WallPostDelete': + return 'org.activitypods.wall.post' case 'ProfileUpdate': return 'canonical.profile' case 'AccountState': @@ -353,10 +373,24 @@ function canonicalUrlFromIntent(intent: CanonicalIntentEvent): string | null { } function shouldProjectCanonicalToFeed(intent: CanonicalIntentEvent): boolean { + // Wall posts go to the wall, not the main feed. + if (intent.kind === 'WallPostCreate' || intent.kind === 'WallPostDelete') return false if (intent.kind !== 'PostCreate' && intent.kind !== 'PostEdit') return false return typeof contentFromCanonical(intent) === 'string' } +/** + * Returns true when a CanonicalIntentEvent represents a wall post that should + * be projected into the `posts` table (with `wallTargetUserId` set). + * Wall posts must NOT appear in the main feed — they are excluded there by + * the `isNull(posts.wallTargetUserId)` filter on feed queries. + */ +function shouldProjectCanonicalToWall(intent: CanonicalIntentEvent): boolean { + if (intent.kind !== 'WallPostCreate') return false + if (!intent.wallTarget) return false + return typeof contentFromCanonical(intent) === 'string' +} + // --------------------------------------------------------------------------- // Service // --------------------------------------------------------------------------- @@ -657,6 +691,12 @@ export class AtBridgeIngestionService { .update(atPosts) .set({ isPublic: false }) .where(eq(atPosts.atUri, uri)) + // Also hard-delete from the wall projection when this is a wall post removal. + if (intent.kind === 'WallPostDelete') { + await this.handleCanonicalWallDelete(intent) + } + } else if (shouldProjectCanonicalToWall(intent)) { + await this.handleCanonicalWallCreate(intent, createdAt) } else if (shouldProjectCanonicalToFeed(intent)) { const content = contentFromCanonical(intent) if (content) { @@ -714,6 +754,143 @@ export class AtBridgeIngestionService { await this.upsertCanonicalIdentity(intent, authorIdentity, true) } + /** + * Project a WallPostCreate canonical intent into the `posts` table. + * + * Both the author AND the wall target must be known local users (resolvable + * via their webId in the `users` table). If either is unknown — which is + * expected for fully federated cross-pod wall posts — we skip the projection + * and rely on the `atRecords` entry written earlier for audit. + */ + private async handleCanonicalWallCreate( + intent: CanonicalIntentEvent, + createdAt: Date, + ): Promise { + const content = contentFromCanonical(intent) + if (!content) return + + // Enforce app-level wall post character limit at ingestion time. + const WALL_LIMIT = 500 + const safeContent = content.length > WALL_LIMIT ? content.slice(0, WALL_LIMIT) : content + + // Resolve wall target (the profile whose wall was written on). + const wallTargetWebId = + intent.wallTarget?.webId ?? + intent.wallTarget?.activityPubActorUri ?? + null + + if (!wallTargetWebId) { + console.warn('[AtBridgeIngestion] WallPostCreate missing wallTarget webId — skipping wall projection', { + intentId: intent.canonicalIntentId, + }) + return + } + + const [wallTargetUser] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.webId, wallTargetWebId)) + .limit(1) + + if (!wallTargetUser) { + console.warn('[AtBridgeIngestion] WallPostCreate wallTarget not a local user — storing in atRecords only', { + wallTargetWebId, + intentId: intent.canonicalIntentId, + }) + return + } + + // Resolve author (the actor who wrote on the wall). + const authorWebId = + intent.sourceAccountRef.webId ?? + intent.sourceAccountRef.activityPubActorUri ?? + null + + if (!authorWebId) { + console.warn('[AtBridgeIngestion] WallPostCreate missing author webId — skipping wall projection', { + intentId: intent.canonicalIntentId, + }) + return + } + + const [authorUser] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.webId, authorWebId)) + .limit(1) + + if (!authorUser) { + console.warn('[AtBridgeIngestion] WallPostCreate author not a local user — storing in atRecords only', { + authorWebId, + intentId: intent.canonicalIntentId, + }) + return + } + + // Determine the canonical AP object URI for idempotency checks. + const objectUri = + intent.object?.activityPubObjectId ?? + intent.object?.atUri ?? + null + + // Check for an existing wall post with this objectUri to stay idempotent. + if (objectUri) { + const [existing] = await db + .select({ id: posts.id }) + .from(posts) + .where(and(eq(posts.objectUri, objectUri), isNotNull(posts.wallTargetUserId))) + .limit(1) + + if (existing) return + } + + await db.insert(posts).values({ + authorId: authorUser.id, + content: safeContent, + hashtags: [], + isPublic: true, + postType: 'note', + objectUri: objectUri ?? null, + wallTargetUserId: wallTargetUser.id, + createdAt, + }) + + console.info('[AtBridgeIngestion] Projected WallPostCreate to posts table', { + authorId: authorUser.id, + wallTargetId: wallTargetUser.id, + objectUri, + }) + } + + /** + * Hard-delete a wall post from the `posts` table when a WallPostDelete + * canonical intent is received. Matches by `objectUri` (the AP Note URI). + * If no matching post is found, the deletion is treated as a no-op. + */ + private async handleCanonicalWallDelete(intent: CanonicalIntentEvent): Promise { + const objectUri = + intent.object?.activityPubObjectId ?? + intent.object?.atUri ?? + null + + if (!objectUri) { + console.warn('[AtBridgeIngestion] WallPostDelete missing object URI — cannot delete from posts', { + intentId: intent.canonicalIntentId, + }) + return + } + + const result = await db + .delete(posts) + .where(and(eq(posts.objectUri, objectUri), isNotNull(posts.wallTargetUserId))) + + console.info('[AtBridgeIngestion] WallPostDelete from posts table', { + objectUri, + intentId: intent.canonicalIntentId, + deleted: Array.isArray(result) ? result.length : 'unknown', + }) + } + private async upsertCanonicalIdentity( intent: CanonicalIntentEvent, actorIdentity: string, diff --git a/api/src/types/index.ts b/api/src/types/index.ts index 33728af48..6642b55ed 100644 --- a/api/src/types/index.ts +++ b/api/src/types/index.ts @@ -65,6 +65,14 @@ export interface NoteCreateRequest { htmlMfm?: boolean /** FEP-c16b: raw source before MFM rendering */ source?: { content: string; mediaType: string } + /** + * ActivityStreams 2.0 `target` property — used for wall posts. + * When present, identifies the actor URI of the profile whose wall this post + * is being written on (following Friendica's AP wall implementation pattern). + * The outer `Create` activity will carry this as the `target` when sent to + * the ActivityPods outbox. + */ + target?: string } // Export all Types from diffrent files diff --git a/frontend/src/components/WallComposer.vue b/frontend/src/components/WallComposer.vue new file mode 100644 index 000000000..1c192fbbe --- /dev/null +++ b/frontend/src/components/WallComposer.vue @@ -0,0 +1,108 @@ + + +