diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3483ca15e..e9e6aaefa 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -23,3 +23,41 @@ introduced. The package boundary tests in *test/package-boundaries.test.ts* compare core package manifests with their production module graphs and reject workspace dependency cycles. + + +Post feature boundaries +----------------------- + +The models package exposes post behavior through focused public subpaths. New +code should import the narrowest applicable module instead of the compatibility +facade at *@hackerspub/models/post*: + +| Subpath | Responsibility | +| ------------------------------------ | --------------------------------------------------- | +| *@hackerspub/models/post/core* | Post object guards and persisted post lookup | +| *@hackerspub/models/post/remote* | Remote ActivityPub ingestion and deletion | +| *@hackerspub/models/post/source* | Local article, note, and question synchronization | +| *@hackerspub/models/post/visibility* | Visibility, moderation, and interaction policies | +| *@hackerspub/models/post/sharing* | Local share and unshare operations | +| *@hackerspub/models/post/engagement* | Reply, share, and quote counters and quote revoking | +| *@hackerspub/models/post/lifecycle* | Local post deletion | +| *@hackerspub/models/link-preview* | Link scraping, persistence, and repair | + +*models/post.ts* remains an explicit re-export facade for existing consumers. +It contains no post behavior. Article source rendering helpers live in +*models/article-source.ts* so local post synchronization does not form a cycle +with the article model. + +The GraphQL post schema follows the same registration boundaries under +*graphql/post/*. *core.ts* owns the `Post` interface, shared fields, media, and +link types; *article.ts* owns article and draft types; *note.ts* owns note and +question types; and *mutations.ts* owns post mutations and queries. Actor post +fields and `Account.articleDrafts` are registered by *actor-fields.ts* and +*article-fields.ts* from the GraphQL composition root after their base types are +available. This avoids `actor` or `account` importing the post facade. + +*graphql/post.ts* is a compatibility facade that preserves the historical +public type references. Adding a post feature means registering it in a +focused module and importing that registration from *graphql/mod.ts*; schema +generation must remain deterministic and must not change merely because a +registration moved between modules. diff --git a/federation/collections.ts b/federation/collections.ts index 4c5f3803c..318a8b5bc 100644 --- a/federation/collections.ts +++ b/federation/collections.ts @@ -8,7 +8,7 @@ import { removeHeaderAnchorLinks } from "@hackerspub/models/html"; import { getSanctionHiddenActorFilter, isActorSanctionHidden, -} from "@hackerspub/models/post"; +} from "@hackerspub/models/post/visibility"; import { actorTable, followingTable, diff --git a/federation/inbox/quote.ts b/federation/inbox/quote.ts index d49cedaeb..c1833f542 100644 --- a/federation/inbox/quote.ts +++ b/federation/inbox/quote.ts @@ -17,15 +17,14 @@ import { } from "@hackerspub/models/actor"; import type { ContextData } from "@hackerspub/models/context"; import { toApplicationContext } from "../context.ts"; +import { isPostObject, type PostObject } from "@hackerspub/models/post/core"; +import { updateQuotesCount } from "@hackerspub/models/post/engagement"; +import { persistPost } from "@hackerspub/models/post/remote"; import { canActorQuotePost, canActorRequestQuotePost, getOriginalPostId, - isPostObject, - persistPost, - type PostObject, - updateQuotesCount, -} from "@hackerspub/models/post"; +} from "@hackerspub/models/post/visibility"; import { type Actor, type Blocking, diff --git a/federation/inbox/subscribe.ts b/federation/inbox/subscribe.ts index cd92304f4..2389462d6 100644 --- a/federation/inbox/subscribe.ts +++ b/federation/inbox/subscribe.ts @@ -12,6 +12,8 @@ import { type Undo, type Update, } from "@fedify/vocab"; +import { getLogger } from "@logtape/logtape"; +import { and, eq, or } from "drizzle-orm"; import { getPersistedActor, isCachedActorFederationBlocked, @@ -19,7 +21,6 @@ import { persistActor, } from "@hackerspub/models/actor"; import type { ContextData } from "@hackerspub/models/context"; -import { toApplicationContext } from "../context.ts"; import { refreshNewsScoresForPostId } from "@hackerspub/models/news"; import { createMentionNotification, @@ -28,17 +29,17 @@ import { createShareNotification, deleteShareNotification, } from "@hackerspub/models/notification"; +import { isPostObject } from "@hackerspub/models/post/core"; +import { updateRepliesCount } from "@hackerspub/models/post/engagement"; import { deletePersistedPost, deleteSharedPost, - isPostObject, PERSIST_POST_OVERALL_BUDGET_MS, persistPost, persistSharedPost, REMOTE_FETCH_TIMEOUT_MS, - updateRepliesCount, withDocumentLoaderTimeout, -} from "@hackerspub/models/post"; +} from "@hackerspub/models/post/remote"; import { persistPollVoteResult } from "@hackerspub/models/poll"; import { deleteReaction, @@ -51,9 +52,8 @@ import { addTagsPubPostToTimeline, removeFromTimeline, } from "@hackerspub/models/timeline"; +import { toApplicationContext } from "../context.ts"; import { isTagsPubHashtagActor } from "../tags-pub.ts"; -import { getLogger } from "@logtape/logtape"; -import { and, eq, or } from "drizzle-orm"; const logger = getLogger(["hackerspub", "federation", "inbox", "subscribe"]); diff --git a/federation/inbox/update.ts b/federation/inbox/update.ts index e962e518d..db8afc775 100644 --- a/federation/inbox/update.ts +++ b/federation/inbox/update.ts @@ -2,7 +2,7 @@ import type { InboxContext } from "@fedify/fedify"; import { isActor, type Update } from "@fedify/vocab"; import { isCachedActorFederationBlocked } from "@hackerspub/models/actor"; import type { ContextData } from "@hackerspub/models/context"; -import { isPostObject } from "@hackerspub/models/post"; +import { isPostObject } from "@hackerspub/models/post/core"; import { getLogger } from "@logtape/logtape"; import { onActorUpdated } from "./actor.ts"; import { onPostUpdated } from "./subscribe.ts"; diff --git a/federation/objects.ts b/federation/objects.ts index 7253f9358..a2ce8c37b 100644 --- a/federation/objects.ts +++ b/federation/objects.ts @@ -21,7 +21,7 @@ import { isActorSanctionHidden, isPostVisibleTo, normalizeQuotePolicyForVisibility, -} from "@hackerspub/models/post"; +} from "@hackerspub/models/post/visibility"; import type { Account, Actor, diff --git a/graphql/account.ts b/graphql/account.ts index 222796561..4980990bd 100644 --- a/graphql/account.ts +++ b/graphql/account.ts @@ -48,7 +48,6 @@ import { InvitationLink } from "./invitation-link.ts"; import { lookupActorByUrl, parseHttpUrl } from "./lookup.ts"; import { Notification } from "./notification.ts"; import { putProfileOgImage } from "./og.ts"; -import { ArticleDraft } from "./post.ts"; import { fromPostVisibility, PostVisibility, @@ -895,24 +894,6 @@ builder.drizzleObjectField(Account, "invitees", (t) => }, )); -builder.drizzleObjectField( - Account, - "articleDrafts", - (t) => - t.relatedConnection("articleDrafts", { - type: ArticleDraft, - description: - "Unpublished article drafts belonging to this account, most " + - "recently updated first. Only visible to the account holder.", - authScopes: (parent) => ({ - selfAccount: "id" in parent ? parent.id : undefined, - }), - query: () => ({ - orderBy: { updated: "desc" }, - }), - }), -); - // Per-request batching loader for Account.postCount and // Account.lastPostPublished. Without this, requesting these fields on a // 50-row connection would fan out to 100 separate aggregate queries. diff --git a/graphql/actor.ts b/graphql/actor.ts index d843bf78a..04b372ac5 100644 --- a/graphql/actor.ts +++ b/graphql/actor.ts @@ -34,11 +34,7 @@ import { OrganizationPermissionError } from "@hackerspub/models/organization"; import { getCensoredPostExclusionFilter, getPostVisibilityFilter, -} from "@hackerspub/models/post"; -import { - formatTimelineCursor, - getProfileInteractions, -} from "@hackerspub/models/profile-interactions"; +} from "@hackerspub/models/post/visibility"; import { type Actor as ActorRow, actorTable } from "@hackerspub/models/schema"; import { parseTimelineCursor, @@ -52,14 +48,10 @@ import { escape } from "@std/html/entities"; import { createGraphQLError } from "graphql-yoga"; import xss from "xss"; import { Account } from "./account.ts"; -import { - resolveActingAccountForGlobalIdArg, - resolveActingAccountForMutation, -} from "./acting-account.ts"; +import { resolveActingAccountForMutation } from "./acting-account.ts"; import { builder, type UserContext } from "./builder.ts"; import { ActorSuspendedError, InvalidInputError } from "./error.ts"; import { lookupActorByUrl, parseHttpUrl } from "./lookup.ts"; -import { Article, Note, Post, Question } from "./post.ts"; import { NotAuthenticatedError } from "./session.ts"; import { type ActingAccountIdArg, @@ -298,19 +290,19 @@ function createViewerFollowStateLoader() { }; } -function authenticationRequired(): never { +export function authenticationRequired(): never { throw createGraphQLError("Authentication required.", { extensions: { code: "AUTHENTICATION_REQUIRED" }, }); } -function conflictingCursors(): never { +export function conflictingCursors(): never { throw createGraphQLError("Cannot paginate with both after and before.", { extensions: { code: "PAGINATION_ERROR" }, }); } -function getConnectionWindow( +export function getConnectionWindow( args: { first?: number | null; last?: number | null }, ): number { if (args.first != null && args.last != null) { @@ -336,7 +328,7 @@ function getConnectionWindow( return window; } -function parseRequiredTimelineCursor(raw: string): TimelineCursor { +export function parseRequiredTimelineCursor(raw: string): TimelineCursor { const cursor = parseTimelineCursor(raw); if (cursor == null) { throw createGraphQLError("Invalid timeline cursor.", { @@ -742,276 +734,6 @@ export const Actor = builder.drizzleNode("actorTable", { select: { columns: { suspended: true, suspendedUntil: true } }, resolve: (actor) => isActorSuspended(actor), }), - posts: t.connection({ - type: Post, - description: - "All of this actor's posts (Notes, Articles, Questions, and " + - "boost wrappers), newest published first. Filtered to posts " + - "visible to the selected viewer account. Pass `actingAccountId` " + - "for an organization perspective.", - args: { - actingAccountId: t.arg.id({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - async resolve(actor, args, ctx) { - const viewerActorId = await resolveViewerActorId(ctx, args); - const viewerActor = viewerActorId == null - ? null - : await getActorById(ctx, viewerActorId); - return await resolveOffsetConnection( - { args }, - async ({ offset, limit }) => { - const postPage = await ctx.db.query.postTable.findMany({ - where: { - AND: [ - { actorId: actor.id }, - getPostVisibilityFilter(viewerActor), - getCensoredPostExclusionFilter(viewerActor?.id), - ], - }, - orderBy: { published: "desc" }, - limit, - offset, - }); - return await loadActorProfilePostPage(ctx, postPage, viewerActorId); - }, - ); - }, - }), - notes: t.connection({ - type: Note, - description: - "This actor's `Note`-type posts, newest first, filtered to those " + - "visible to the viewer. Includes both original notes and boost " + - "wrappers of remote notes. Use `sharedPosts` to see only boosts. " + - "Pass `actingAccountId` for an organization perspective.", - args: { - actingAccountId: t.arg.id({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - async resolve(actor, args, ctx) { - const viewerActorId = await resolveViewerActorId(ctx, args); - const viewerActor = viewerActorId == null - ? null - : await getActorById(ctx, viewerActorId); - return await resolveOffsetConnection( - { args }, - async ({ offset, limit }) => { - const postPage = await ctx.db.query.postTable.findMany({ - where: { - AND: [ - { actorId: actor.id }, - { type: "Note" }, - getPostVisibilityFilter(viewerActor), - getCensoredPostExclusionFilter(viewerActor?.id), - ], - }, - orderBy: { published: "desc" }, - limit, - offset, - }); - return await loadActorProfilePostPage(ctx, postPage, viewerActorId); - }, - ); - }, - }), - noteByUuid: t.drizzleField({ - type: Note, - select: { columns: { id: true } }, - nullable: true, - args: { - uuid: t.arg({ type: "UUID", required: true }), - }, - async resolve(query, actor, args, ctx) { - if (!validateUuid(args.uuid)) return null; - - const visibility = getPostVisibilityFilter(ctx.account?.actor ?? null); - const note = await ctx.db.query.postTable.findFirst(query({ - where: { - AND: [ - { type: "Note", actorId: actor.id }, - { - OR: [ - { id: args.uuid }, - { noteSourceId: args.uuid }, - ], - }, - visibility, - ], - }, - })); - return note || null; - }, - }), - postByUuid: t.drizzleField({ - type: Post, - description: - "Look up one of this actor's posts by any of its UUIDs. Resolves a " + - "match against any of the three UUIDs a post can carry: `Post.uuid` " + - "(the post row's PK), `Note.sourceId` (= `noteSourceTable.id`, set " + - "on source-backed local notes and Questions), or the local article " + - "source's id. The canonical permalink in `Post.url` uses the source " + - "UUID for source-backed local posts, including local `Question`s. " + - "For posts without a local source row (federated remote posts and " + - "local share wrappers), the row PK is the lookup token. The OR-match " + - "here keeps both styles working. Returns `null` if no post matches " + - "or the post is not visible to the selected viewer account.", - select: { columns: { id: true } }, - nullable: true, - args: { - uuid: t.arg({ - type: "UUID", - required: true, - description: - "Any of `Post.uuid`, `Note.sourceId` (also used by local " + - "`Question`s), or the local article source's id.", - }), - actingAccountId: t.arg.id({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - async resolve(query, actor, args, ctx) { - if (!validateUuid(args.uuid)) return null; - - const viewerActorId = await resolveViewerActorId(ctx, args); - const viewerActor = viewerActorId == null - ? null - : await getActorById(ctx, viewerActorId); - const visibility = getPostVisibilityFilter(viewerActor); - return await ctx.db.query.postTable.findFirst(query({ - where: { - AND: [ - { actorId: actor.id }, - { - OR: [ - { id: args.uuid }, - { noteSourceId: args.uuid }, - { articleSourceId: args.uuid }, - ], - }, - visibility, - ], - }, - })) ?? null; - }, - }), - articles: t.connection({ - type: Article, - description: - "This actor's locally-authored `Article`-type posts, newest first. " + - "Only includes articles that have a local `articleSource` row; " + - "remote articles federated in from other instances are excluded. " + - "Pass `actingAccountId` for an organization perspective.", - args: { - actingAccountId: t.arg.id({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - async resolve(actor, args, ctx) { - const viewerActorId = await resolveViewerActorId(ctx, args); - const viewerActor = viewerActorId == null - ? null - : await getActorById(ctx, viewerActorId); - return await resolveOffsetConnection( - { args }, - async ({ offset, limit }) => { - const postPage = await ctx.db.query.postTable.findMany({ - where: { - AND: [ - { actorId: actor.id }, - { type: "Article" }, - { - articleSourceId: { - isNotNull: true, - }, - }, - getPostVisibilityFilter(viewerActor), - getCensoredPostExclusionFilter(viewerActor?.id), - ], - }, - orderBy: { published: "desc" }, - limit, - offset, - }); - return await loadActorProfilePostPage(ctx, postPage, viewerActorId); - }, - ); - }, - }), - questions: t.relatedConnection("posts", { - type: Question, - description: - "This actor's `Question`-type posts (polls), newest first, " + - "filtered to those visible to the viewer.", - query: (_, ctx) => ({ - where: { - AND: [ - { type: "Question" }, - getPostVisibilityFilter(ctx.account?.actor ?? null), - getCensoredPostExclusionFilter(ctx.account?.actor.id), - ], - }, - orderBy: { published: "desc" }, - }), - }), - sharedPosts: t.connection({ - type: Post, - description: - "Posts that this actor has boosted (shared), newest first. " + - "These are boost wrapper rows where `sharedPost` is non-null. " + - "Pass `actingAccountId` for an organization perspective.", - args: { - actingAccountId: t.arg.id({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - async resolve(actor, args, ctx) { - const viewerActorId = await resolveViewerActorId(ctx, args); - const viewerActor = viewerActorId == null - ? null - : await getActorById(ctx, viewerActorId); - return await resolveOffsetConnection( - { args }, - async ({ offset, limit }) => { - const postPage = await ctx.db.query.postTable.findMany({ - where: { - AND: [ - { actorId: actor.id }, - getPostVisibilityFilter(viewerActor), - getCensoredPostExclusionFilter(viewerActor?.id), - { sharedPostId: { isNotNull: true } }, - ], - }, - orderBy: { published: "desc" }, - limit, - offset, - }); - return await loadActorProfilePostPage(ctx, postPage, viewerActorId); - }, - ); - }, - }), - pins: t.connection({ - type: Post, - description: - "Posts this actor has pinned to the top of their profile, most " + - "recently pinned first. Only posts visible to the current viewer " + - "are included.", - select: (args, ctx, nestedSelection) => ({ - with: { - pins: pinConnectionHelpers.getQuery(args, ctx, nestedSelection), - }, - }), - resolve: (actor, args, ctx) => - pinConnectionHelpers.resolve(actor.pins, args, ctx), - }), }), }); @@ -1112,68 +834,6 @@ builder.drizzleObjectFields(Actor, (t) => ({ }), }, ), - viewerInteractions: t.connection({ - type: Post, - description: - "Posts authored by either this `Actor` or the selected viewer account " + - "that directly involve the other actor through a reply, quote, or " + - "explicit mention. Returns an empty connection for the viewer's own " + - "`Actor`; unauthenticated requests raise `AUTHENTICATION_REQUIRED`. " + - "`first` and `last` are capped at 250 posts. Pass `actingAccountId` " + - "for an organization perspective.", - args: { - actingAccountId: t.arg.id({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - async resolve(actor, args, ctx) { - if (ctx.account == null) { - authenticationRequired(); - } else if (args.after != null && args.before != null) { - conflictingCursors(); - } - const actingAccount = await resolveActingAccountForGlobalIdArg(ctx, args); - const backwards = args.last != null; - const window = getConnectionWindow(args); - const since = args.before == null - ? undefined - : parseRequiredTimelineCursor(args.before); - const until = args.after == null - ? undefined - : parseRequiredTimelineCursor(args.after); - const interactions = await getProfileInteractions(ctx.db, { - viewer: actingAccount, - profileActorId: actor.id, - direction: backwards ? "backward" : "forward", - window: window + 1, - since, - until, - }); - const pageEntries = interactions.slice(0, window); - if (backwards) pageEntries.reverse(); - return { - pageInfo: { - hasNextPage: backwards - ? args.before != null - : interactions.length > window, - hasPreviousPage: backwards - ? interactions.length > window - : args.after != null, - startCursor: pageEntries.length < 1 - ? null - : formatTimelineCursor(pageEntries[0]), - endCursor: pageEntries.length < 1 - ? null - : formatTimelineCursor(pageEntries[pageEntries.length - 1]), - }, - edges: pageEntries.map((entry) => ({ - node: entry.post, - cursor: formatTimelineCursor(entry), - })), - }; - }, - }), follows: t.field({ type: "Boolean", description: "One-off check: does this actor follow the given actor? " + @@ -1492,7 +1152,7 @@ const blockeeConnectionHelpers = drizzleConnectionHelpers( }, ); -const pinConnectionHelpers = drizzleConnectionHelpers( +export const pinConnectionHelpers = drizzleConnectionHelpers( builder, "pinTable", { diff --git a/graphql/builder.ts b/graphql/builder.ts index ef3523c05..6ff3e891c 100644 --- a/graphql/builder.ts +++ b/graphql/builder.ts @@ -51,7 +51,7 @@ import type Keyv from "keyv"; import { normalizeEmail } from "@hackerspub/models/account"; import type { ApplicationContext } from "@hackerspub/models/context"; import type { Database } from "@hackerspub/models/db"; -import type { PostInteractionPolicy } from "@hackerspub/models/post"; +import type { PostInteractionPolicy } from "@hackerspub/models/post/visibility"; import { relations } from "@hackerspub/models/relations"; import type { Account, diff --git a/graphql/lookup.ts b/graphql/lookup.ts index 9c1ddf744..ac4cc78bf 100644 --- a/graphql/lookup.ts +++ b/graphql/lookup.ts @@ -1,6 +1,7 @@ import { isActor } from "@fedify/vocab"; import { persistActor } from "@hackerspub/models/actor"; -import { isPostObject, persistPost } from "@hackerspub/models/post"; +import { isPostObject } from "@hackerspub/models/post/core"; +import { persistPost } from "@hackerspub/models/post/remote"; import type { Actor, Post } from "@hackerspub/models/schema"; import type { UserContext } from "./builder.ts"; diff --git a/graphql/mod.ts b/graphql/mod.ts index 5d9a9c2d8..0590e1225 100644 --- a/graphql/mod.ts +++ b/graphql/mod.ts @@ -21,6 +21,8 @@ import "./organization.ts"; import "./passkey.ts"; import "./poll.ts"; import "./post.ts"; +import "./post/actor-fields.ts"; +import "./post/article-fields.ts"; import "./push.ts"; import "./reactable.ts"; import "./refresh.ts"; diff --git a/graphql/moderation.ts b/graphql/moderation.ts index 3fe713b78..3b59ef49a 100644 --- a/graphql/moderation.ts +++ b/graphql/moderation.ts @@ -16,7 +16,7 @@ import { countUnreadModerationNotifications, markModerationNotificationsRead, } from "@hackerspub/models/moderation-notification"; -import { isPostVisibleTo } from "@hackerspub/models/post"; +import { isPostVisibleTo } from "@hackerspub/models/post/visibility"; import type { Account as AccountRow, Flag as FlagRow, diff --git a/graphql/news.ts b/graphql/news.ts index 87e7effa2..42eebf5d8 100644 --- a/graphql/news.ts +++ b/graphql/news.ts @@ -24,7 +24,7 @@ import { import { getCensoredPostExclusionFilter, getPostVisibilityFilter, -} from "@hackerspub/models/post"; +} from "@hackerspub/models/post/visibility"; import type { PostLink as PostLinkRow } from "@hackerspub/models/schema"; import { type Uuid, validateUuid } from "@hackerspub/models/uuid"; import { builder } from "./builder.ts"; diff --git a/graphql/poll.ts b/graphql/poll.ts index c27190512..9f5296cfc 100644 --- a/graphql/poll.ts +++ b/graphql/poll.ts @@ -7,8 +7,8 @@ import { getSanctionVisibleActorFilter, isActorSanctionHidden, isPostVisibleTo, - persistPost, -} from "@hackerspub/models/post"; +} from "@hackerspub/models/post/visibility"; +import { persistPost } from "@hackerspub/models/post/remote"; import { type Actor as ActorRow, actorTable, diff --git a/graphql/post.facade.test.ts b/graphql/post.facade.test.ts new file mode 100644 index 000000000..7070c330e --- /dev/null +++ b/graphql/post.facade.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import * as facade from "./post.ts"; +import * as article from "./post/article.ts"; +import * as core from "./post/core.ts"; +import * as note from "./post/note.ts"; + +test("post compatibility facade preserves its public GraphQL references", () => { + assert.deepEqual(Object.keys(facade).sort(), [ + "Article", + "ArticleContent", + "ArticleDraft", + "Medium", + "Note", + "Post", + "PostLink", + "PostType", + "Question", + "hidePostRelationWithoutActor", + "isPostVisibleToViewer", + ]); + assert.equal(facade.Post, core.Post); + assert.equal(facade.Medium, core.Medium); + assert.equal(facade.Note, note.Note); + assert.equal(facade.Question, note.Question); + assert.equal(facade.Article, article.Article); + assert.equal(facade.ArticleContent, article.ArticleContent); + assert.equal(facade.ArticleDraft, article.ArticleDraft); +}); diff --git a/graphql/post.more.test.ts b/graphql/post.more.test.ts index 50d526bd0..f9eb77b1b 100644 --- a/graphql/post.more.test.ts +++ b/graphql/post.more.test.ts @@ -2675,6 +2675,174 @@ test("createQuestion rejects NONE visibility", async () => { }); }); +test("createNote and createQuestion allow replies to public shares", async () => { + await withRollback(async (tx) => { + const author = await insertAccountWithActor(tx, { + username: "replyshareauthor", + name: "Reply Share Author", + email: "replyshareauthor@example.com", + }); + const sharer = await insertAccountWithActor(tx, { + username: "replysharer", + name: "Reply Sharer", + email: "replysharer@example.com", + }); + const requester = await insertAccountWithActor(tx, { + username: "replysharerequester", + name: "Reply Share Requester", + email: "replysharerequester@example.com", + }); + const { post: original } = await insertNotePost(tx, { + account: author.account, + visibility: "public", + content: "Public post to share", + }); + const { post: share } = await insertNotePost(tx, { + account: sharer.account, + visibility: "public", + sharedPostId: original.id, + }); + const replyTargetId = encodeGlobalID("Note", share.id); + const contextValue = makeTransactionalUserContext(tx, requester.account); + + const noteResult = await execute({ + schema, + document: createNoteMutation, + variableValues: { + input: { + content: "Replying to the shared note", + language: "en", + visibility: "PUBLIC", + replyTargetId, + }, + }, + contextValue, + onError: "NO_PROPAGATE", + }); + + assert.equal(noteResult.errors, undefined); + assert.equal( + (toPlainJson(noteResult.data) as { + createNote: { __typename: string }; + }).createNote.__typename, + "CreateNotePayload", + ); + + const questionResult = await execute({ + schema, + document: createQuestionMutation, + variableValues: { + input: { + content: "Polling from the shared note", + language: "en", + visibility: "PUBLIC", + replyTargetId, + poll: { + title: "Pick one", + multiple: false, + options: ["Yes", "No"], + ends: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + }, + }, + }, + contextValue, + onError: "NO_PROPAGATE", + }); + + assert.equal(questionResult.errors, undefined); + assert.equal( + (toPlainJson(questionResult.data) as { + createQuestion: { __typename: string }; + }).createQuestion.__typename, + "CreateQuestionPayload", + ); + }); +}); + +test("createNote and createQuestion reject shares of invisible posts", async () => { + await withRollback(async (tx) => { + const author = await insertAccountWithActor(tx, { + username: "invisibleshareauthor", + name: "Invisible Share Author", + email: "invisibleshareauthor@example.com", + }); + const sharer = await insertAccountWithActor(tx, { + username: "invisiblesharer", + name: "Invisible Sharer", + email: "invisiblesharer@example.com", + }); + const requester = await insertAccountWithActor(tx, { + username: "invisiblesharerequester", + name: "Invisible Share Requester", + email: "invisiblesharerequester@example.com", + }); + const { post: original } = await insertNotePost(tx, { + account: author.account, + visibility: "followers", + content: "Followers-only post to share", + }); + const { post: share } = await insertNotePost(tx, { + account: sharer.account, + visibility: "public", + sharedPostId: original.id, + }); + const replyTargetId = encodeGlobalID("Note", share.id); + const contextValue = makeTransactionalUserContext(tx, requester.account); + + const noteResult = await execute({ + schema, + document: createNoteWithErrorMutation, + variableValues: { + input: { + content: "Replying to the hidden shared note", + language: "en", + visibility: "PUBLIC", + replyTargetId, + }, + }, + contextValue, + onError: "NO_PROPAGATE", + }); + + assert.equal(noteResult.errors, undefined); + assert.deepEqual(toPlainJson(noteResult.data), { + createNote: { + __typename: "InvalidInputError", + inputPath: "replyTargetId", + }, + }); + + const questionResult = await execute({ + schema, + document: createQuestionMutation, + variableValues: { + input: { + content: "Polling from the hidden shared note", + language: "en", + visibility: "PUBLIC", + replyTargetId, + poll: { + title: "Pick one", + multiple: false, + options: ["Yes", "No"], + ends: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + }, + }, + }, + contextValue, + onError: "NO_PROPAGATE", + }); + + assert.equal(questionResult.errors, undefined); + assert.deepEqual(toPlainJson(questionResult.data), { + createQuestion: { + __typename: "InvalidInputError", + inputPath: "replyTargetId", + }, + }); + }); +}); + test("createNote rejects invisible reply and quote targets", async () => { await withRollback(async (tx) => { const author = await insertAccountWithActor(tx, { diff --git a/graphql/post.ts b/graphql/post.ts index b5c0458e2..fc2c35a6c 100644 --- a/graphql/post.ts +++ b/graphql/post.ts @@ -1,5878 +1,14 @@ -import * as vocab from "@fedify/vocab"; -import { generateAltText } from "@hackerspub/ai/alttext"; -import { getLogger } from "@logtape/logtape"; -import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; -import { - resolveArrayConnection, - resolveOffsetConnection, -} from "@pothos/plugin-relay"; -import { unreachable } from "@std/assert"; -import { assertNever } from "@std/assert/unstable-never"; -import { and, eq, gt, inArray, isNotNull, isNull, lte, or } from "drizzle-orm"; -import { getAvatarUrl } from "@hackerspub/models/account"; -import { - createArticle, - deleteArticleDraft, - getArticleDraftMediumUrls, - getArticleSourceMediumUrls, - getOriginalArticleContent, - LanguageChangeWithTranslationsError, - startArticleContentTranslation, - updateArticle, - updateArticleDraft, -} from "@hackerspub/models/article"; -import { - arePostsBookmarkedBy, - createBookmark, - deleteBookmark, - getBookmarkCountsForPosts, -} from "@hackerspub/models/bookmark"; -import type { Database, Transaction } from "@hackerspub/models/db"; -import { - isReactionEmoji, - type ReactionEmoji, - renderCustomEmojis, -} from "@hackerspub/models/emoji"; -import { - addExternalLinkTargets, - removeQuoteInlineFallback, - sanitizeExcerptHtml, - stripHtml, - transformMentions, - truncateHtml, -} from "@hackerspub/models/html"; -import { negotiateLocale, normalizeLocale } from "@hackerspub/models/i18n"; -import { - getMissingArticleMediumLabel, - renderMarkup, -} from "@hackerspub/models/markup"; -import { - createMediumFromBytes, - createMediumFromUrl, - MAX_STREAMING_MEDIUM_IMAGE_SIZE, - SUPPORTED_MEDIUM_IMAGE_TYPES, - UnsafeMediumUrlError, -} from "@hackerspub/models/medium"; -import { assertAccountActorNotSuspended } from "@hackerspub/models/moderation"; -import { - createNote, - QuotePolicyDeniedError, - updateNote, -} from "@hackerspub/models/note"; -import { - OrganizationPermissionError, - recordOrganizationPostAuthor, -} from "@hackerspub/models/organization"; -import { - pinPost as pinPostModel, - unpinPost as unpinPostModel, -} from "@hackerspub/models/pin"; -import { - canActorRequestQuotePost, - deletePost, - getCensoredPostExclusionFilter, - getPostInteractionPolicies, - getPostVisibilityFilter, - getSanctionVisibleActorFilter, - isActorSanctionHidden, - isPostVisibleTo, - normalizeQuotePolicyForVisibility, - type PostInteractionPolicy, - revokeQuote as revokeQuoteModel, - sharePost, - unsharePost, -} from "@hackerspub/models/post"; -import { InvalidPollInputError } from "@hackerspub/models/poll"; -import { createQuestion } from "@hackerspub/models/question"; -import { react, undoReaction } from "@hackerspub/models/reaction"; -import { - actorTable, - articleContentTable, - articleDraftMediumTable, - articleDraftTable, - articleSourceMediumTable, - pinTable, - postTable, -} from "@hackerspub/models/schema"; -import type * as schema from "@hackerspub/models/schema"; -import DataLoader from "dataloader"; -import { - DESCENDANT_TREE_MAX_DEPTH, - getAncestorChain, - getDescendantPage, -} from "@hackerspub/models/thread"; -import { withTransaction } from "@hackerspub/models/tx"; -import { - generateUuidV7, - type Uuid, - validateUuid, -} from "@hackerspub/models/uuid"; -import { - createMediumUploadSession, - deleteMediumUploadSession, - getMediumUploadSession, - isMediumOwner, - isMediumUploadWindowActive, - MEDIUM_UPLOAD_TTL_MS, - setMediumOwner, -} from "./medium-upload.ts"; -import { createGraphQLError } from "graphql-yoga"; -import { Account } from "./account.ts"; -import { - resolveActingAccountForGlobalIdArg, - resolveActingAccountForMutation, -} from "./acting-account.ts"; -import { - Actor, - actorProfilePostRelations, - getActorById, - isActorProfileHidden, - loadActorProfilePostPage, -} from "./actor.ts"; -import { builder, Node, type UserContext } from "./builder.ts"; -import { - ActorSuspendedError, - InvalidInputError, - NotAuthorizedError, -} from "./error.ts"; -import { lookupPostByUrl, parseHttpUrl } from "./lookup.ts"; -import { putArticleOgImage } from "./og.ts"; -import { PostVisibility, toPostVisibility } from "./postvisibility.ts"; -import { - fromQuotePolicy, - QuotePolicy, - QuoteTargetState, - toQuotePolicy, - toQuoteTargetState, -} from "./quotepolicy.ts"; -import { CustomEmoji, Reactable, Reaction } from "./reactable.ts"; -import { NotAuthenticatedError } from "./session.ts"; -import { - type ActingAccountIdArg, - actingAccountIdArgDescription, - resolveViewerActorId, -} from "./viewer-actor.ts"; - -const articleContentOgImageComplexity = 2_000; -const logger = getLogger(["hackerspub", "graphql", "post"]); - -class SharedPostDeletionNotAllowedError extends Error { - public constructor(public readonly inputPath: string) { - super("Shared posts cannot be deleted. Use unsharePost instead."); - } -} - -type LlmTranslationNotAllowedReason = "DISABLED" | "SAME_LANGUAGE"; - -class LlmTranslationNotAllowedError extends Error { - public constructor(public readonly reason: LlmTranslationNotAllowedReason) { - super(`LLM translation not allowed: ${reason}`); - } -} - -export const PostType = builder.enumType("PostType", { - description: - "Discriminant used to filter a connection to a single post type. " + - "This enum does not appear on the `Post` interface itself; use " + - "__typename` or inline fragments to distinguish concrete types.", - values: { - ARTICLE: { - description: "Long-form article with a title, year-based slug URL, and " + - "optional multi-language translations.", - }, - NOTE: { - description: "Short microblog post (equivalent to a Mastodon Status or " + - "ActivityPub Note).", - }, - QUESTION: { - description: - "ActivityPub `Question` poll. Questions may originate locally via " + - "`createQuestion` or remotely through federation.", - }, - } as const, -}); - -const PostAttributionMode = builder.enumType("PostAttributionMode", { - description: - "How a post written through an organization account should display " + - "the personal member who created it.", - values: { - ACTING_ACCOUNT_ONLY: { - value: "acting_account_only", - description: "Show only the acting account as the public author. For " + - "organization posts, the member remains recorded for audit and " + - "management but is not shown as a co-author.", - }, - ACTING_ACCOUNT_WITH_VIEWER: { - value: "acting_account_with_viewer", - description: - "Show the organization account as the primary author and the " + - "personal member as a co-author.", - }, - } as const, -}); - -const OrganizationPostAuthor = builder.objectRef( - "OrganizationPostAuthor", -); - -const LlmTranslationNotAllowedReasonRef = builder.enumType( - "LlmTranslationNotAllowedReason", - { - values: { - DISABLED: { - description: - "The article's author has opted out of LLM-based translation " + - "for this article.", - }, - SAME_LANGUAGE: { - description: - "The requested target language matches the article's existing " + - "language; translation is a no-op.", - }, - } as const, - }, -); - -builder.objectType(SharedPostDeletionNotAllowedError, { - name: "SharedPostDeletionNotAllowedError", - fields: (t) => ({ - inputPath: t.expose("inputPath", { type: "String" }), - }), -}); - -builder.objectType(LlmTranslationNotAllowedError, { - name: "LlmTranslationNotAllowedError", - fields: (t) => ({ - reason: t.expose("reason", { type: LlmTranslationNotAllowedReasonRef }), - }), -}); - -async function loadOrganizationPostAuthorAccount( - ctx: UserContext, - id: Uuid, -) { - const account = await ctx.db.query.accountTable.findFirst({ - where: { id }, - with: { actor: true }, - }); - if (account == null || account.actor == null) { - throw createGraphQLError("Organization post attribution is broken.", { - extensions: { code: "INTERNAL_SERVER_ERROR" }, - }); - } - return account; -} - -OrganizationPostAuthor.implement({ - description: - "Attribution metadata for a post written through an organization " + - "account. The `Post.actor` remains the organization; this object " + - "records which personal member created it and whether that member " + - "should be shown as a co-author.", - fields: (t) => ({ - attributionMode: t.field({ - type: PostAttributionMode, - description: - "Whether clients should display only the organization or include " + - "the member as a co-author.", - resolve: (author) => author.attributionMode, - }), - organization: t.field({ - type: Account, - description: "The organization account that authored the post.", - async resolve(author, _, ctx) { - return await loadOrganizationPostAuthorAccount( - ctx, - author.organizationAccountId, - ); - }, - }), - member: t.field({ - type: Account, - nullable: true, - description: - "The personal member account to show as a co-author. `null` when " + - "`attributionMode` is `ACTING_ACCOUNT_ONLY`.", - async resolve(author, _, ctx) { - if (author.attributionMode !== "acting_account_with_viewer") { - return null; - } - return await loadOrganizationPostAuthorAccount( - ctx, - author.memberAccountId, - ); - }, - }), - created: t.expose("created", { - type: "DateTime", - description: "When this organization attribution record was created.", - }), - }), -}); - -interface PostActingAccountInput { - actingAccountId?: { id: string } | null; - attributionMode?: schema.PostAttributionMode | null; -} - -interface PostManagementActingAccountInput { - actingAccountId?: { id: string } | null; -} - -interface ResolvedPostActingAccount { - account: schema.Account & { actor: schema.Actor }; - memberAccountId: Uuid; - attributionMode: schema.PostAttributionMode | null; -} - -async function resolvePostActingAccount( - ctx: UserContext, - input: PostActingAccountInput, -): Promise { - if (ctx.account == null) throw new NotAuthenticatedError(); - const account = await resolveActingAccountForMutation(ctx, input); - if (account.kind !== "organization") { - if (input.attributionMode != null) { - throw new InvalidInputError("attributionMode"); - } - return { - account, - memberAccountId: ctx.account.id, - attributionMode: null, - }; - } - return { - account, - memberAccountId: ctx.account.id, - attributionMode: input.attributionMode ?? "acting_account_only", - }; -} - -async function recordPostActingAccount( - db: Database | Transaction, - postId: Uuid, - resolved: ResolvedPostActingAccount, -): Promise { - if (resolved.attributionMode == null) return; - await recordOrganizationPostAuthor( - db, - postId, - resolved.account.id, - resolved.memberAccountId, - resolved.attributionMode, - ); -} - -async function assertActingAccountNotSuspended( - db: Database, - authenticatedAccountId: Uuid, - actingAccountId: Uuid, -): Promise { - await assertAccountActorNotSuspended(db, authenticatedAccountId); - if (actingAccountId !== authenticatedAccountId) { - await assertAccountActorNotSuspended(db, actingAccountId); - } -} - -async function resolvePostManagementActingAccount( - ctx: UserContext, - input: PostManagementActingAccountInput, - ownerAccountId: Uuid, - inputPath: string, -): Promise> { - const account = await resolveActingAccountForMutation(ctx, input); - if (account.id !== ownerAccountId) { - throw new InvalidInputError(inputPath); - } - return account; -} - -/** - * Whether the post's content must be redacted for the current viewer: it - * is censored, or its author is hidden by a moderation sanction (a banned - * local actor, or a remote actor under an active federation block), and - * the viewer is neither its author nor a moderator. The permalink itself - * stays reachable (so a censorship notice can render); only the - * content-bearing fields are emptied. List queries exclude such posts - * entirely; this redaction covers direct `node(id:)` lookups and nested - * relations. - * - * Share wrappers carry denormalized copies of the boosted post's title, - * content, and URL, so when the (loaded) `sharedPost` is censored or its - * author is sanction-hidden the wrapper's content is redacted too; there - * the exemption follows the boosted post's author, not the booster. - */ -function isCensoredForViewer( - post: { - censored: Date | null; - actorId: Uuid; - actor?: SanctionActorColumns | null; - sharedPost?: { - censored: Date | null; - actorId: Uuid; - actor?: SanctionActorColumns | null; - } | null; - }, - ctx: UserContext, -): boolean { - return isRowCensoredForViewer(post, ctx) || - post.sharedPost != null && isRowCensoredForViewer(post.sharedPost, ctx); -} - -type SanctionActorColumns = Pick< - schema.Actor, - "accountId" | "suspended" | "suspendedUntil" ->; - -/** - * The actor columns the redaction helpers need to evaluate the author's - * sanction state; merged into the field selections that call them. - */ -const sanctionActorSelection = { - columns: { - accountId: true, - suspended: true, - suspendedUntil: true, - }, -} as const; - -/** - * Like {@link isCensoredForViewer}, but considers only the row itself, - * ignoring any loaded `sharedPost`. Used for the `sharedPost` relation - * field: a wrapper of a censored post must keep the relation (the boosted - * post redacts itself and exposes `censored` for the notice), while a - * censored wrapper hides it entirely. - */ -function isRowCensoredForViewer( - row: { - censored: Date | null; - actorId: Uuid; - actor?: SanctionActorColumns | null; - }, - ctx: UserContext, -): boolean { - if (ctx.account?.moderator) return false; - if (ctx.account?.actor.id === row.actorId) return false; - if (row.censored != null) return true; - return row.actor != null && isActorSanctionHidden(row.actor); -} - -export const Post = builder.drizzleInterface("postTable", { - variant: "Post", - description: - "Abstract base for all content types: `Note` (short microblog posts), " + - "`Article` (long-form blog posts), and `Question` (polls from federated " + - "instances). Most timeline and feed queries return this interface; " + - "use `__typename` or inline fragments to access type-specific fields. " + - "Content-bearing fields are redacted (empty or `null`) when the post " + - "is censored or its author is hidden by a moderation sanction (a " + - "banned local actor, or a remote actor under an active federation " + - "block), unless the viewer is the author or a moderator; list queries " + - "exclude such posts entirely, so this matters for direct `node(id:)` " + - "lookups and nested relations.", - interfaces: [Reactable, Node], - resolveType(post): string { - switch (post.type) { - case "Article": - return Article.name; - case "Note": - return Note.name; - case "Question": - return Question.name; - default: - return assertNever(post.type); - } - }, - fields: (t) => ({ - uuid: t.expose("id", { - type: "UUID", - description: - "The post row's primary key, stable for the lifetime of the post. " + - "⚠️ This is **not** the UUID embedded in `Post.url` for source-backed " + - "local posts: local notes that originate here use `Note.sourceId` " + - "(= `noteSourceTable.id`), local questions use `Question.sourceId`, " + - "and local articles use `Article.publishedYear` + `Article.slug`. " + - "The row PK is the right token for posts with no local source row: " + - "federated remote posts, local share wrappers (boosts, which carry " + - "no source and copy the shared post's URL), and remote `Question`s. " + - "`actorByHandle.postByUuid` accepts either the row PK or a source " + - "UUID, but resolving by `uuid` for a source-backed local post yields " + - "a URL that differs from `Post.url`.", - }), - iri: t.field({ - type: "URL", - description: - "The post's ActivityPub IRI, used as its canonical identifier in " + - "federation. For local posts this is an `/ap/…` endpoint; for " + - "remote posts it is whatever IRI the originating instance assigned. " + - "Prefer `url` for human-readable links. When the post is censored " + - "or its author is hidden by a moderation sanction, and the viewer " + - "is neither the author nor a moderator, a remote IRI (or a boost " + - "wrapper's, whose `url` is also nulled) is replaced with the local " + - "permalink that renders the notice, so a `url ?? iri` fallback never " + - "leaks the uncensored origin. A local non-wrapper post keeps its " + - "own `/ap/…` IRI (it does not point outside this instance).", - select: { - columns: { - id: true, - iri: true, - noteSourceId: true, - type: true, - censored: true, - actorId: true, - sharedPostId: true, - }, - with: { - actor: { - columns: { - accountId: true, - suspended: true, - suspendedUntil: true, - handle: true, - }, - }, - sharedPost: { - columns: { censored: true, actorId: true }, - with: { actor: sanctionActorSelection }, - }, - }, - }, - resolve: (post, _, ctx) => { - // A hidden post's own IRI (when remote) points at the uncensored - // copy on its origin instance; a boost wrapper's `url` is already - // nulled, so the `url ?? iri` fallback web-next uses for links would - // leak through `iri`. Mirror the `url` field: return the local - // permalink, which renders the notice, for the same hidden - // remote-or-wrapper case. (A local non-wrapper post keeps its own - // local `/ap/…` IRI, which never leaves this instance.) - if ( - isCensoredForViewer(post, ctx) && - post.actor != null && - (post.sharedPostId != null || post.actor.accountId == null) - ) { - return new URL( - `/${post.actor.handle}/${post.id}`, - ctx.fedCtx.canonicalOrigin, - ); - } - if (post.type === "Question" && post.noteSourceId != null) { - return ctx.fedCtx.getObjectUri(vocab.Question, { - id: post.noteSourceId, - }); - } - return new URL(post.iri); - }, - }), - visibility: t.field({ - type: PostVisibility, - select: { - columns: { visibility: true }, - }, - resolve(post) { - return toPostVisibility(post.visibility); - }, - }), - quotePolicy: t.field({ - type: QuotePolicy, - select: { - columns: { quotePolicy: true }, - }, - resolve(post) { - return toQuotePolicy(post.quotePolicy); - }, - }), - quoteTargetState: t.field({ - type: QuoteTargetState, - nullable: true, - select: { - columns: { quoteTargetState: true }, - }, - resolve(post) { - return toQuoteTargetState(post.quoteTargetState); - }, - }), - censored: t.expose("censored", { - type: "DateTime", - nullable: true, - description: - "When a moderator censored this post, or `null` when it is not " + - "censored. Censored posts disappear from timelines, search, and " + - "recommendations, but their permalinks stay reachable so a " + - "censorship notice can be shown. For everyone but the author " + - "and moderators, all content-bearing fields are redacted: " + - "`content`, the excerpt fields, `name`, `summary`, `hashtags`, " + - "`Article.tags`, `media`, `link`, `mentions`, `sharedPost`, " + - "`quotedPost`, article contents, and poll data.", - }), - name: t.field({ - type: "String", - nullable: true, - description: "The post's title. Non-null for `Article`s and local poll " + - "`Question`s; `null` for `Note`s and boost wrappers. `null` when " + - "the post is censored or its author is hidden by a moderation " + - "sanction (or it is a boost wrapper of such a post, whose title " + - "it copies) and the viewer is neither the content's author nor " + - "a moderator.", - select: { - columns: { name: true, censored: true, actorId: true }, - with: { - actor: sanctionActorSelection, - sharedPost: { - columns: { censored: true, actorId: true }, - with: { actor: sanctionActorSelection }, - }, - }, - }, - resolve: (post, _, ctx) => - isCensoredForViewer(post, ctx) ? null : post.name, - }), - summary: t.field({ - type: "String", - nullable: true, - description: - "Author-provided or LLM-generated summary of the post. `null` " + - "when no summary has been set. For LLM summaries, check " + - "`ArticleContent.summary` and `summaryStarted` instead, as those " + - "are tracked per language on articles. `null` when the post is " + - "censored or its author is hidden by a moderation sanction (or it boosts such a post), and the viewer is " + - "neither the content's author nor a moderator.", - select: { - columns: { summary: true, censored: true, actorId: true }, - with: { - actor: sanctionActorSelection, - sharedPost: { - columns: { censored: true, actorId: true }, - with: { actor: sanctionActorSelection }, - }, - }, - }, - resolve: (post, _, ctx) => - isCensoredForViewer(post, ctx) ? null : post.summary, - }), - content: t.field({ - type: "HTML", - description: - "The post's full HTML content, with custom emoji shortcodes " + - "rendered as `` elements and external links annotated with " + - '`target="_blank"`. Boost wrappers copy the boosted post\'s ' + - "content; prefer `sharedPost.content`. Empty when the post is " + - "censored or its author is hidden by a moderation sanction (or it boosts such a post), and the viewer is " + - "neither the content's author nor a moderator.", - select: { - columns: { - actorId: true, - censored: true, - contentHtml: true, - emojis: true, - quotedPostId: true, - tags: true, - }, - with: { - actor: sanctionActorSelection, - mentions: { - with: { actor: true }, - }, - sharedPost: { - columns: { censored: true, actorId: true }, - with: { actor: sanctionActorSelection }, - }, - }, - }, - resolve: (post, _, ctx) => { - if (isCensoredForViewer(post, ctx)) return ""; - let html = renderCustomEmojis(post.contentHtml, post.emojis); - html = transformMentions(html, post.mentions, post.tags); - html = addExternalLinkTargets( - html, - new URL(ctx.fedCtx.canonicalOrigin), - ); - if (post.quotedPostId != null) html = removeQuoteInlineFallback(html); - return html; - }, - }), - excerpt: t.string({ - description: - "Plain-text excerpt of the post. Returns `summary` when set; " + - "otherwise falls back to the HTML content stripped of tags. " + - "For a truncated HTML preview, use `excerptHtml` instead. " + - "Empty when the post is censored or its author is hidden by a " + - "moderation sanction (or it boosts such a post) " + - "and the viewer is neither the content's author nor a moderator.", - select: { - columns: { - actorId: true, - censored: true, - summary: true, - contentHtml: true, - quotedPostId: true, - }, - with: { - actor: sanctionActorSelection, - sharedPost: { - columns: { censored: true, actorId: true }, - with: { actor: sanctionActorSelection }, - }, - }, - }, - resolve(post, _, ctx) { - if (isCensoredForViewer(post, ctx)) return ""; - if (post.summary != null) return post.summary; - let html = post.contentHtml; - if (post.quotedPostId != null) html = removeQuoteInlineFallback(html); - return stripHtml(html); - }, - }), - excerptHtml: t.field({ - type: "HTML", - description: - "A sanitized, truncated HTML preview of this post's content, " + - "clipped to roughly `maxChars` visible characters with valid tag " + - "structure preserved. Use this on feed cards instead of `content` " + - "to keep the rendered DOM small. Anchor tags are stripped — the " + - "surrounding card is expected to be the link to the full post. " + - "This does NOT fall back to `summary`; query `summary` separately " + - "when you want to display a real (e.g. LLM-generated) summary " + - "with its own affordances. Empty when the post is censored or " + - "its author is hidden by a moderation sanction (or it boosts " + - "such a post) and the viewer is neither the " + - "content's author nor a moderator.", - args: { - maxChars: t.arg.int({ required: true }), - }, - select: { - columns: { - actorId: true, - censored: true, - contentHtml: true, - emojis: true, - quotedPostId: true, - }, - with: { - actor: sanctionActorSelection, - sharedPost: { - columns: { censored: true, actorId: true }, - with: { actor: sanctionActorSelection }, - }, - }, - }, - resolve(post, args, ctx) { - if (isCensoredForViewer(post, ctx)) return ""; - // Remove quote-inline fallback first so the truncation budget isn't - // wasted on text the user will never see. - // - // Sanitize BEFORE rendering custom emojis. The reverse order would - // strip the inline `style` `renderCustomEmojis` puts on the emoji - // `` (which sets `height: 1em` and inline alignment), so emoji - // images would render at their intrinsic size instead of inline with - // the surrounding text. Emoji `src` is admin-uploaded and the alt is - // bounded to `[a-z0-9_-]+` by `CUSTOM_EMOJI_REGEXP`, so the post- - // sanitization injection doesn't open a new XSS surface. - let html = post.contentHtml; - if (post.quotedPostId != null) html = removeQuoteInlineFallback(html); - return truncateHtml( - renderCustomEmojis(sanitizeExcerptHtml(html), post.emojis), - args.maxChars, - ); - }, - }), - language: t.exposeString("language", { - nullable: true, - description: - "BCP 47 language tag of the post's primary content (e.g., `en`, " + - "`ja`). `null` when the language is unknown or not specified by " + - "the author.", - }), - hashtags: t.field({ - type: [Hashtag], - description: - "Hashtags mentioned in the post, extracted from the post's tag " + - "map. Each entry includes the tag name and its canonical hashtag " + - "search URL. Empty when the post is censored or its author is " + - "hidden by a moderation sanction (or it boosts such a post) and " + - "the viewer is neither the content's author nor a moderator, " + - "since hashtags are derived from the hidden " + - "content.", - select: { - columns: { tags: true, censored: true, actorId: true }, - with: { - actor: sanctionActorSelection, - sharedPost: { - columns: { censored: true, actorId: true }, - with: { actor: sanctionActorSelection }, - }, - }, - }, - resolve(post, _, ctx) { - if (isCensoredForViewer(post, ctx)) return []; - return Object.entries(post.tags).map(([name, href]) => ({ - name, - href: new URL(href), - })); - }, - }), - sensitive: t.exposeBoolean("sensitive", { - description: - "Whether the post is marked as sensitive (NSFW). Clients should " + - "hide the content behind a content warning when this is `true`.", - }), - engagementStats: t.variant(PostEngagementStats), - url: t.field({ - type: "URL", - nullable: true, - description: - "The canonical, human-readable URL of this post. For source-backed " + - "local posts the path encodes the local source identifier: " + - "`Note.sourceId` for notes, `Article.publishedYear` + `Article.slug` " + - "for articles, and `Question.sourceId` for questions. It does not " + - "encode `Post.uuid`. For federated remote posts and " + - "local share wrappers (boosts) this is whatever URL the originating " + - "instance advertised (copied from the shared post in the boost case) " + - "and is unrelated to the wrapper's own row PK. Prefer this field " + - "over hand-building a path from `Post.uuid`: `uuid` is the row PK and " + - "does not match the path here for source-backed local posts. " + - "`null` when the post is censored or its author is hidden by a " + - "moderation sanction, and the viewer is neither the content's " + - "author nor a moderator, EXCEPT for a local post (whose own " + - "permalink renders the notice): a boost wrapper's URL mirrors the " + - "boosted post's, and a remote post's URL points at the " + - "uncensored copy on its origin instance, so both are hidden.", - select: { - columns: { - url: true, - censored: true, - actorId: true, - sharedPostId: true, - }, - with: { - actor: sanctionActorSelection, - sharedPost: { - columns: { censored: true, actorId: true }, - with: { actor: sanctionActorSelection }, - }, - }, - }, - resolve: (post, _, ctx) => { - if (post.url == null) return null; - // When the content is hidden, a boost wrapper's URL mirrors the - // boosted post's, and a remote post's URL points at the - // uncensored copy on its origin instance. Only a local post's - // own permalink leads to a page that renders the notice, so it - // is the only URL kept. - if ( - isCensoredForViewer(post, ctx) && - (post.sharedPostId != null || post.actor?.accountId == null) - ) { - return null; - } - return new URL(post.url); - }, - }), - updated: t.expose("updated", { type: "DateTime" }), - published: t.expose("published", { type: "DateTime" }), - actor: t.relation("actor", { - description: "The actor who authored or boosted this post.", - }), - organizationAuthor: t.field({ - type: OrganizationPostAuthor, - nullable: true, - description: - "Organization attribution metadata when this post was created " + - "through an organization account. `null` for ordinary personal " + - "posts and for federated posts without local organization metadata.", - select: { - with: { organizationAuthor: true }, - }, - resolve(post) { - return post.organizationAuthor ?? null; - }, - }), - media: t.field({ - type: [PostMediumRef], - description: - "Media attachments on this post, in display order. For federated " + - "posts the URLs point to the originating instance. Empty when " + - "the post is censored or its author is hidden by a moderation " + - "sanction, and the viewer is neither the author nor a " + - "moderator: attachments are part of the hidden content.", - select: { - columns: { censored: true, actorId: true }, - with: { actor: sanctionActorSelection, media: true }, - }, - resolve: (post, _, ctx) => - isCensoredForViewer(post, ctx) ? [] : post.media, - }), - link: t.field({ - type: PostLink, - nullable: true, - description: - "OpenGraph / oEmbed preview for the first link in the post. " + - "`null` when the post has no links or the metadata has not been " + - "fetched yet, and also when the post is censored or its author " + - "is hidden by a moderation sanction, and the viewer " + - "is neither its author nor a moderator (the linked URL is part " + - "of the censored content).", - select: { - columns: { censored: true, actorId: true }, - with: { actor: sanctionActorSelection, link: true }, - }, - resolve: (post, _, ctx) => - isCensoredForViewer(post, ctx) ? null : post.link, - }), - linkPreviewUrl: t.field({ - type: "URL", - nullable: true, - description: - "The exact first external URL shared in this post, including its " + - "query string and fragment, for link-preview navigation. `null` " + - "when no preview metadata is attached, and hidden under the same " + - "moderation rules as `Post.link`. Use `PostLink.url` only as the " + - "resolved identity used to share preview metadata and news scores.", - select: { - columns: { censored: true, actorId: true, linkUrl: true }, - with: { actor: sanctionActorSelection }, - }, - resolve: (post, _, ctx) => - isCensoredForViewer(post, ctx) || post.linkUrl == null - ? null - : new URL(post.linkUrl), - }), - viewerHasShared: t.loadable({ - type: "Boolean", - description: - "Whether the selected viewer account has boosted this post. Always " + - "`false` for unauthenticated requests. Pass `actingAccountId` for " + - "an organization perspective.", - args: { - actingAccountId: t.arg.globalID({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - // cache: false so a mutation that flips share state in the same - // request (e.g., share + read viewerHasShared) re-queries instead - // of returning the pre-mutation value. - loaderOptions: { cache: false }, - load: loadViewerHasShared, - resolve: postViewerActorKey, - }), - viewerHasBookmarked: t.loadable({ - type: "Boolean", - description: - "Whether the authenticated viewer has bookmarked this post. " + - "Always `false` for unauthenticated requests.", - loaderOptions: { cache: false }, - load: async (postIds: Uuid[], ctx: UserContext): Promise => { - if (ctx.account == null) return postIds.map(() => false); - const bookmarked = await arePostsBookmarkedBy( - ctx.db, - postIds, - ctx.account, - ); - return postIds.map((id) => bookmarked.has(id)); - }, - resolve: (post) => post.id, - }), - viewerHasPinned: t.loadable({ - type: "Boolean", - description: - "Whether the selected viewer account has pinned this post to their " + - "profile. Always `false` for unauthenticated requests. Pass " + - "`actingAccountId` for an organization perspective.", - args: { - actingAccountId: t.arg.globalID({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - loaderOptions: { cache: false }, - load: loadViewerHasPinned, - resolve: postViewerActorKey, - }), - viewerCanReply: t.loadable({ - type: "Boolean", - description: - "Whether the selected viewer account is allowed to reply to this " + - "post, based on visibility and block state. Always `false` for " + - "unauthenticated requests. Pass `actingAccountId` for an " + - "organization perspective.", - args: { - actingAccountId: t.arg.globalID({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - loaderOptions: { cache: false }, - load: async ( - keys: ViewerActorPostKey[], - ctx: UserContext, - ): Promise => { - const policies = await loadViewerActionPolicies(ctx, keys); - return keys.map((key) => - policies.get(viewerActorPostKeyCacheKey(key))?.canReply ?? false - ); - }, - resolve: postViewerActorKey, - }), - viewerCanQuote: t.loadable({ - type: "Boolean", - description: - "Whether the selected viewer account is allowed to quote this post, " + - "based on `quotePolicy`, visibility, and block state. A censored " + - "post cannot be quoted by anyone (including its author or a " + - "moderator), so this is `false` for censored posts. Always `false` " + - "for unauthenticated requests. Pass `actingAccountId` for an " + - "organization perspective.", - args: { - actingAccountId: t.arg.globalID({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - loaderOptions: { cache: false }, - load: async ( - keys: ViewerActorPostKey[], - ctx: UserContext, - ): Promise => { - const policies = await loadViewerActionPolicies(ctx, keys); - return keys.map((key) => - policies.get(viewerActorPostKeyCacheKey(key))?.canQuote ?? false - ); - }, - resolve: postViewerActorKey, - }), - viewerCanRevokeQuote: t.boolean({ - description: - "Whether the authenticated viewer (as the quoted post's author) " + - "can revoke a quote of their post. `true` only when the viewer " + - "is the author of `quotedPost` and the quoting post is either " + - "local or has an authorization IRI. Pass `actingAccountId` for an " + - "organization perspective.", - args: { - actingAccountId: t.arg.globalID({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - select: { - columns: { - id: true, - quotedPostId: true, - quoteAuthorizationIri: true, - }, - with: { - actor: { - columns: { accountId: true }, - }, - quotedPost: { - columns: { actorId: true }, - }, - }, - }, - async resolve(post, args, ctx) { - const viewerActorId = await resolveViewerActorId(ctx, args); - return viewerActorId != null && post.quotedPost != null && - post.quotedPost.actorId === viewerActorId && - (post.actor.accountId != null || post.quoteAuthorizationIri != null); - }, - }), - viewerCanShare: t.loadable({ - type: "Boolean", - description: - "Whether the selected viewer account is allowed to boost this post, " + - "based on visibility and block state. A censored post cannot be " + - "boosted by anyone (including its author or a moderator), so this " + - "is `false` for censored posts. Always `false` for unauthenticated " + - "requests. Pass `actingAccountId` for an organization perspective.", - args: { - actingAccountId: t.arg.globalID({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - loaderOptions: { cache: false }, - load: async ( - keys: ViewerActorPostKey[], - ctx: UserContext, - ): Promise => { - const policies = await loadViewerActionPolicies(ctx, keys); - return keys.map((key) => - policies.get(viewerActorPostKeyCacheKey(key))?.canShare ?? false - ); - }, - resolve: postViewerActorKey, - }), - }), -}); - -const DENY_ALL_POLICY: PostInteractionPolicy = { - canReply: false, - canQuote: false, - canShare: false, -}; - -interface ViewerActorPostKey { - postId: Uuid; - viewerActorId: Uuid | null; -} - -function viewerActorPostKeyCacheKey(key: ViewerActorPostKey): string { - return `${key.viewerActorId ?? ""}:${key.postId}`; -} - -async function postViewerActorKey( - post: { id: Uuid }, - args: ActingAccountIdArg, - ctx: UserContext, -): Promise { - return { - postId: post.id, - viewerActorId: await resolveViewerActorId(ctx, args), - }; -} - -async function loadViewerHasShared( - keys: ViewerActorPostKey[], - ctx: UserContext, -): Promise { - const postIdsByViewer = new Map>(); - for (const key of keys) { - if (key.viewerActorId == null) continue; - let postIds = postIdsByViewer.get(key.viewerActorId); - if (postIds == null) { - postIds = new Set(); - postIdsByViewer.set(key.viewerActorId, postIds); - } - postIds.add(key.postId); - } - - const sharedKeys = new Set(); - for (const [viewerActorId, postIds] of postIdsByViewer) { - const rows = await ctx.db.select({ sharedPostId: postTable.sharedPostId }) - .from(postTable) - .where( - and( - eq(postTable.actorId, viewerActorId), - inArray(postTable.sharedPostId, [...postIds]), - ), - ); - for (const row of rows) { - if (row.sharedPostId != null) { - sharedKeys.add(`${viewerActorId}:${row.sharedPostId}`); - } - } - } - - return keys.map((key) => - key.viewerActorId != null && - sharedKeys.has(`${key.viewerActorId}:${key.postId}`) - ); -} - -async function loadViewerHasPinned( - keys: ViewerActorPostKey[], - ctx: UserContext, -): Promise { - const postIdsByViewer = new Map>(); - for (const key of keys) { - if (key.viewerActorId == null) continue; - let postIds = postIdsByViewer.get(key.viewerActorId); - if (postIds == null) { - postIds = new Set(); - postIdsByViewer.set(key.viewerActorId, postIds); - } - postIds.add(key.postId); - } - - const pinnedKeys = new Set(); - for (const [viewerActorId, postIds] of postIdsByViewer) { - const rows = await ctx.db.select({ postId: pinTable.postId }) - .from(pinTable) - .where( - and( - eq(pinTable.actorId, viewerActorId), - inArray(pinTable.postId, [...postIds]), - ), - ); - for (const row of rows) { - pinnedKeys.add(`${viewerActorId}:${row.postId}`); - } - } - - return keys.map((key) => - key.viewerActorId != null && - pinnedKeys.has(`${key.viewerActorId}:${key.postId}`) - ); -} - -async function loadViewerActionPolicies( - ctx: UserContext, - keys: readonly ViewerActorPostKey[], -): Promise> { - const cache = ctx.viewerActionPoliciesCache ??= new Map(); - // Dedupe missing ids so a batch with `cache: false` (which may surface - // duplicate keys) cannot overwrite an already-registered promise — the - // overwritten promise would still reject on a batch failure but be - // un-awaited, producing an unhandled rejection. - const missingByViewer = new Map>(); - for (const key of keys) { - const cacheKey = viewerActorPostKeyCacheKey(key); - if (cache.has(cacheKey)) continue; - if (key.viewerActorId == null) { - cache.set(cacheKey, Promise.resolve(DENY_ALL_POLICY)); - continue; - } - let missing = missingByViewer.get(key.viewerActorId); - if (missing == null) { - missing = new Set(); - missingByViewer.set(key.viewerActorId, missing); - } - missing.add(key.postId); - } - for (const [viewerActorId, missing] of missingByViewer) { - // Kick off the batch lookup synchronously and register a derived promise - // per post id before awaiting so that concurrent dispatch from the three - // viewerCan* loaders deduplicates instead of each firing its own query. - // Once the batch settles, drop the cached entries we registered so a - // subsequent resolve pass (e.g., after a follow/block/visibility-changing - // mutation in the same operation) re-queries and observes fresh state — - // matching the `loaderOptions: { cache: false }` semantics on the - // viewer-state fields. - const missingIds = [...missing]; - const batch = getPostInteractionPolicies( - ctx.db, - missingIds, - { id: viewerActorId } as schema.Actor, - ); - const cleanup = () => { - for (const id of missingIds) { - cache.delete(viewerActorPostKeyCacheKey({ - postId: id, - viewerActorId, - })); - } - }; - batch.then(cleanup, cleanup); - for (const id of missingIds) { - const cacheKey = viewerActorPostKeyCacheKey({ - postId: id, - viewerActorId, - }); - cache.set( - cacheKey, - batch.then((policies) => policies.get(id) ?? DENY_ALL_POLICY), - ); - } - } - const entries = await Promise.all( - keys.map(async (key) => { - const cacheKey = viewerActorPostKeyCacheKey(key); - return [cacheKey, await cache.get(cacheKey)!] as const; - }), - ); - return new Map(entries); -} - -function selectPostRelationWithActor( - nestedSelection: () => unknown, -): Record { - const selection = nestedSelection(); - if (selection == null || typeof selection !== "object") { - return { with: { actor: true } }; - } - const withSelection = "with" in selection && - selection.with != null && - typeof selection.with === "object" - ? selection.with as Record - : {}; - return { - ...selection, - with: { - ...withSelection, - actor: withSelection.actor ?? true, - }, - }; -} - -export function hidePostRelationWithoutActor( - post: T | null | undefined, -): T | null { - if (post == null || typeof post !== "object") return null; - if (!("actor" in post) || post.actor == null) return null; - return post; -} - -// Raw rows to scan per round once the `descendants` page is already full and -// the resolver only needs to confirm one more visible reply exists (for -// `hasNextPage`). Kept small so a probe that finds a survivor early does not -// over-fetch a whole `first`-sized page; a longer run of hidden rows is -// stepped over across the bounded rounds instead. -const DESCENDANT_PROBE_BATCH = 20; - -// A descendants cursor is base64 of the model layer's DFS path: fixed-width -// `~` elements joined by `/` (the timestamp -// is the node's UTC publish time to microsecond precision). The uuid parts -// double as the entry's strict ancestor chain below the focused post, which -// the resolver uses to drop entries whose subtree root got filtered out on an -// earlier page. -const descendantPathElement = - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}~[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/; - -function encodeDescendantCursor(path: string): string { - return btoa(path); -} - -function decodeDescendantCursor(cursor: string): string { - let path: string; - try { - path = atob(cursor); - } catch { - throw createGraphQLError("Malformed `descendants` cursor."); - } - if ( - !path.split("/").every((element) => descendantPathElement.test(element)) - ) { - throw createGraphQLError("Malformed `descendants` cursor."); - } - return path; -} - -function descendantPathAncestorIds(path: string): Uuid[] { - const elements = path.split("/"); - // Each element is `~`; the uuid (36 chars, no `~`) is the id. - return elements.slice(0, -1).map((element) => - element.slice(element.indexOf("~") + 1) as Uuid - ); -} - -// Whether the given post is visible to the authenticated viewer: per-post -// visibility plus the author's sanction state. Censorship is deliberately -// not part of this check; censored posts stay reachable and self-redact -// their content-bearing fields instead. -export function isPostVisibleToViewer( - ctx: UserContext, - postId: Uuid, - viewerActorId: Uuid | null, -): Promise { - ctx.postVisibleLoader ??= new Map(); - let loader = ctx.postVisibleLoader.get(viewerActorId ?? ""); - if (loader == null) { - loader = new DataLoader( - async (ids) => { - const idList = ids as Uuid[]; - const viewerActor = viewerActorId == null - ? null - : await getActorById(ctx, viewerActorId); - const rows = await ctx.db.query.postTable.findMany({ - columns: { id: true }, - where: { - AND: [ - { id: { in: idList } }, - { actor: getSanctionVisibleActorFilter(ctx.now ??= new Date()) }, - getPostVisibilityFilter(viewerActor), - ], - }, - }); - const visible = new Set(rows.map((row) => row.id)); - return idList.map((id) => visible.has(id)); - }, - ); - ctx.postVisibleLoader.set(viewerActorId ?? "", loader); - } - return loader.load(postId); -} - -// Whether the given post has at least one direct reply visible to the viewer, -// under the same sanction + censorship + visibility filter as the `replies` -// connection. Thread views use this instead of the raw -// `engagementStats.replies` counter: a node whose only replies are hidden -// (followers-only to a stranger, censored, or by a sanctioned author) reports -// `false`, so a "continue this thread" affordance cannot reveal that hidden -// replies exist. Batched (one query per reply page) and keyed by viewer. -function postHasVisibleReplies( - ctx: UserContext, - postId: Uuid, - viewerActorId: Uuid | null, -): Promise { - ctx.postHasVisibleRepliesLoader ??= new Map(); - let loader = ctx.postHasVisibleRepliesLoader.get(viewerActorId ?? ""); - if (loader == null) { - loader = new DataLoader( - async (ids) => { - const idList = ids as Uuid[]; - const viewerActor = viewerActorId == null - ? null - : await getActorById(ctx, viewerActorId); - const rows = await ctx.db.query.postTable.findMany({ - columns: { replyTargetId: true }, - where: { - AND: [ - { replyTargetId: { in: idList } }, - { actor: getSanctionVisibleActorFilter(ctx.now ??= new Date()) }, - getCensoredPostExclusionFilter(viewerActorId), - getPostVisibilityFilter(viewerActor), - ], - }, - }); - const withReplies = new Set(rows.map((row) => row.replyTargetId)); - return idList.map((id) => withReplies.has(id)); - }, - ); - ctx.postHasVisibleRepliesLoader.set(viewerActorId ?? "", loader); - } - return loader.load(postId); -} - -// Whether the given post has at least one quote visible to the viewer, under -// the same sanction + censorship + visibility filter as the `quotes` -// connection. The news-discussion view uses this instead of the raw -// `engagementStats.quotes` counter so a "show quotes" affordance (which then -// loads an empty list) cannot reveal that hidden quotes exist. Batched (one -// query per page) and keyed by viewer, mirroring `postHasVisibleReplies`. -function postHasVisibleQuotes( - ctx: UserContext, - postId: Uuid, - viewerActorId: Uuid | null, -): Promise { - ctx.postHasVisibleQuotesLoader ??= new Map(); - let loader = ctx.postHasVisibleQuotesLoader.get(viewerActorId ?? ""); - if (loader == null) { - loader = new DataLoader( - async (ids) => { - const idList = ids as Uuid[]; - const viewerActor = viewerActorId == null - ? null - : await getActorById(ctx, viewerActorId); - const rows = await ctx.db.query.postTable.findMany({ - columns: { quotedPostId: true }, - where: { - AND: [ - { quotedPostId: { in: idList } }, - { actor: getSanctionVisibleActorFilter(ctx.now ??= new Date()) }, - getCensoredPostExclusionFilter(viewerActorId), - getPostVisibilityFilter(viewerActor), - ], - }, - }); - const withQuotes = new Set(rows.map((row) => row.quotedPostId)); - return idList.map((id) => withQuotes.has(id)); - }, - ); - ctx.postHasVisibleQuotesLoader.set(viewerActorId ?? "", loader); - } - return loader.load(postId); -} - -// Backs the `Post.replies` / `Post.quotes` / `Post.shares` connections: -// posts related to `targetId` through `column` (`replyTargetId`, -// `quotedPostId`, or `sharedPostId`), newest first, filtered to those -// visible to the selected viewer account. Resolves the acting account from -// `actingAccountId` (like `ancestors`/`descendants` and `Actor.posts`), so an -// organization perspective sees followers-only interactions the org can see -// but the viewer's personal actor cannot. Censored and sanction-hidden are -// excluded here (these are lists, not the self-redacting permalink). -async function visibleRelatedPostsPage( - ctx: UserContext, - args: ActingAccountIdArg, - column: "replyTargetId" | "quotedPostId" | "sharedPostId", - targetId: Uuid, - offset: number, - limit: number, -) { - const viewerActorId = await resolveViewerActorId(ctx, args); - const viewerActor = viewerActorId == null - ? null - : await getActorById(ctx, viewerActorId); - const page = await ctx.db.query.postTable.findMany({ - columns: { id: true }, - where: { - AND: [ - { [column]: targetId }, - { actor: getSanctionVisibleActorFilter(ctx.now ??= new Date()) }, - getCensoredPostExclusionFilter(viewerActorId), - getPostVisibilityFilter(viewerActor), - ], - }, - // `id` breaks `published` ties so offset pagination is stable (no - // duplicated or skipped rows across pages when timestamps collide). The - // callback form guarantees both columns order deterministically. - orderBy: (post, { desc }) => [desc(post.published), desc(post.id)], - limit, - offset, - }); - return await loadActorProfilePostPage(ctx, page, viewerActorId); -} - -// Exact count of everything `visibleRelatedPostsPage` would return across all -// pages, for a connection `totalCount` that is not capped by the page size. -// Direct replies/quotes/shares of one post are bounded, so counting ids is -// acceptable; the relational visibility filter cannot be expressed as a plain -// SQL `$count` predicate. `resolveViewerActorId`/`getActorById` are -// per-request cached, so re-resolving them here is free. -async function countVisibleRelatedPosts( - ctx: UserContext, - args: ActingAccountIdArg, - column: "replyTargetId" | "quotedPostId" | "sharedPostId", - targetId: Uuid, -): Promise { - const viewerActorId = await resolveViewerActorId(ctx, args); - const viewerActor = viewerActorId == null - ? null - : await getActorById(ctx, viewerActorId); - const rows = await ctx.db.query.postTable.findMany({ - columns: { id: true }, - where: { - AND: [ - { [column]: targetId }, - { actor: getSanctionVisibleActorFilter(ctx.now ??= new Date()) }, - getCensoredPostExclusionFilter(viewerActorId), - getPostVisibilityFilter(viewerActor), - ], - }, - }); - return rows.length; -} - -// The id-only companion of loadVisibleThreadPosts, for checking path -// ancestors that never become connection nodes themselves: same filters, -// no relation hydration. -async function loadVisibleThreadPostIds( - ctx: UserContext, - ids: readonly Uuid[], - viewerActorId: Uuid | null, -): Promise> { - if (ids.length < 1) return new Set(); - const viewerActor = viewerActorId == null - ? null - : await getActorById(ctx, viewerActorId); - const rows = await ctx.db.query.postTable.findMany({ - columns: { id: true }, - where: { - AND: [ - { id: { in: [...ids] } }, - { actor: getSanctionVisibleActorFilter(ctx.now ??= new Date()) }, - getCensoredPostExclusionFilter(viewerActorId), - getPostVisibilityFilter(viewerActor), - ], - }, - }); - return new Set(rows.map((row) => row.id)); -} - -// Loads the given posts with the canonical thread filters (sanction, -// censorship, visibility) applied, keyed by id; absent ids are not visible -// to the viewer. The eager relation set matches what profile/timeline post -// loading uses, since these rows bypass Pothos's nested selection machinery. -async function loadVisibleThreadPosts( - ctx: UserContext, - ids: readonly Uuid[], - viewerActorId: Uuid | null, -) { - const viewerActor = viewerActorId == null - ? null - : await getActorById(ctx, viewerActorId); - const rows = ids.length < 1 ? [] : await ctx.db.query.postTable.findMany({ - where: { - AND: [ - { id: { in: [...ids] } }, - { actor: getSanctionVisibleActorFilter(ctx.now ??= new Date()) }, - getCensoredPostExclusionFilter(viewerActorId), - getPostVisibilityFilter(viewerActor), - ], - }, - with: actorProfilePostRelations(viewerActorId), - }); - return new Map(rows.map((row) => [row.id, row])); -} - -type ThreadPostRow = Awaited< - ReturnType -> extends Map ? R : never; - -builder.drizzleInterfaceFields(Post, (t) => ({ - sharedPost: t.field({ - type: Post, - nullable: true, - description: - "The post being boosted. Non-null only for boost wrapper rows. " + - "When this is non-null, `content` is empty and `url` mirrors the " + - "shared post's URL. `null` when the boost wrapper itself is " + - "censored, or its author is hidden by a moderation sanction, and " + - "the viewer is neither the author nor a moderator (what was boosted " + - "is the censored content), and also when the boosted post is not " + - "visible to the viewer (e.g., a followers-only post the viewer does " + - "not follow), so a boost cannot leak its private target.", - args: { - actingAccountId: t.arg.id({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - select: (_, __, nestedSelection) => ({ - columns: { censored: true, actorId: true }, - with: { - actor: sanctionActorSelection, - sharedPost: selectPostRelationWithActor(nestedSelection), - }, - }), - // Timeline model helpers already sanitize nullable post relations whose - // actor disappeared during Drizzle's multi-SELECT hydration, but Pothos - // Drizzle can re-fetch these relations later while resolving nested - // GraphQL fields. If the related post row survives that re-fetch without - // its required actor, hide the nullable relation instead of letting the - // non-null `Post.actor` field fail the whole query. - resolve: async (post, args, ctx) => { - if (isRowCensoredForViewer(post, ctx)) return null; - const sharedPost = hidePostRelationWithoutActor(post.sharedPost); - if (sharedPost == null) return null; - const viewerActorId = await resolveViewerActorId(ctx, args); - return await isPostVisibleToViewer(ctx, sharedPost.id, viewerActorId) - ? sharedPost - : null; - }, - }), - replyTarget: t.field({ - type: Post, - nullable: true, - description: - "The post this post is a reply to. `null` for top-level posts, and " + - "also when the parent is not visible to the authenticated viewer " + - "(e.g., a followers-only post by an actor the viewer does not " + - "follow, or a post whose author is hidden by a moderation " + - "sanction), so a public reply cannot leak its private parent. A " + - "censored parent is still returned and self-redacts its " + - "content-bearing fields. Pass `actingAccountId` for an " + - "organization perspective, matching the perspective of the " + - "surrounding query.", - args: { - actingAccountId: t.arg.id({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - select: (_, __, nestedSelection) => ({ - with: { - replyTarget: selectPostRelationWithActor(nestedSelection), - }, - }), - resolve: async (post, args, ctx) => { - const replyTarget = hidePostRelationWithoutActor(post.replyTarget); - if (replyTarget == null) return null; - const viewerActorId = await resolveViewerActorId(ctx, args); - return await isPostVisibleToViewer(ctx, replyTarget.id, viewerActorId) - ? replyTarget - : null; - }, - }), - quotedPost: t.field({ - type: Post, - nullable: true, - description: - "The post being quoted inline. `null` for posts that are not " + - "quotes, when the quoting post is censored or its author " + - "is hidden by a moderation sanction and the viewer " + - "is neither its author nor a moderator (the quoted target is part " + - "of the censored content), and also when the quoted post is not " + - "visible to the viewer (e.g., a followers-only post the viewer does " + - "not follow), so a public quote cannot leak its private target.", - args: { - actingAccountId: t.arg.id({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - select: (_, __, nestedSelection) => ({ - columns: { censored: true, actorId: true }, - with: { - actor: sanctionActorSelection, - quotedPost: selectPostRelationWithActor(nestedSelection), - }, - }), - resolve: async (post, args, ctx) => { - if (isCensoredForViewer(post, ctx)) return null; - const quotedPost = hidePostRelationWithoutActor(post.quotedPost); - if (quotedPost == null) return null; - const viewerActorId = await resolveViewerActorId(ctx, args); - return await isPostVisibleToViewer(ctx, quotedPost.id, viewerActorId) - ? quotedPost - : null; - }, - }), - replies: t.connection({ - type: Post, - description: - "Posts that are direct replies to this post, newest first. Censored " + - "replies, replies by actors whose content is hidden by a moderation " + - "sanction, and replies not visible to the selected viewer account " + - "(e.g., followers-only replies by actors the viewer does not " + - "follow) are excluded. Pass `actingAccountId` for an organization " + - "perspective.", - args: { - actingAccountId: t.arg.id({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - resolve: async (post, args, ctx) => { - const { edges, pageInfo } = await resolveOffsetConnection( - { args }, - ({ offset, limit }) => - visibleRelatedPostsPage( - ctx, - args, - "replyTargetId", - post.id, - offset, - limit, - ), - ); - return { - edges: [...edges], - pageInfo: { - hasNextPage: pageInfo.hasNextPage, - hasPreviousPage: pageInfo.hasPreviousPage, - startCursor: pageInfo.startCursor, - endCursor: pageInfo.endCursor, - }, - // Carried for the lazy `totalCount` field below. - countTargetId: post.id, - countArgs: args, - }; - }, - }, { - fields: (t) => ({ - totalCount: t.int({ - description: - "Total number of direct replies visible to the selected viewer " + - "account, independent of the current page size. Unlike counting " + - "the fetched edges, this is not capped by `first`, and excludes " + - "the same censored, sanction-hidden, and not-visible replies as " + - "the edges.", - resolve: (connection, _args, ctx) => - countVisibleRelatedPosts( - ctx, - connection.countArgs, - "replyTargetId", - connection.countTargetId, - ), - }), - }), - }), - hasVisibleReplies: t.boolean({ - description: - "Whether this post has at least one direct reply the selected viewer " + - "can see, under the same filter as the `replies` connection (author " + - "sanction state, censorship, and per-post visibility). Prefer this " + - "over `engagementStats.replies > 0` when deciding whether to show a " + - '"continue this thread" affordance in a thread view: the raw counter ' + - "includes replies hidden from the viewer (followers-only, direct, " + - "censored, or by a sanctioned author), so branching on it would " + - "reveal that hidden replies exist. Pass `actingAccountId` for an " + - "organization perspective.", - args: { - actingAccountId: t.arg.id({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - resolve: async (post, args, ctx) => { - const viewerActorId = await resolveViewerActorId(ctx, args); - return await postHasVisibleReplies(ctx, post.id, viewerActorId); - }, - }), - hasVisibleQuotes: t.boolean({ - description: - "Whether this post has at least one quote the selected viewer can see, " + - "under the same filter as the `quotes` connection (author sanction " + - "state, censorship, and per-post visibility). Prefer this over " + - "`engagementStats.quotes > 0` when deciding whether to show a " + - '"show quotes" affordance: the raw counter includes quotes hidden from ' + - "the viewer (followers-only, direct, censored, or by a sanctioned " + - "author), so branching on it would surface an affordance that then " + - "loads an empty list, revealing that hidden quotes exist. Pass " + - "`actingAccountId` for an organization perspective.", - args: { - actingAccountId: t.arg.id({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - resolve: async (post, args, ctx) => { - const viewerActorId = await resolveViewerActorId(ctx, args); - return await postHasVisibleQuotes(ctx, post.id, viewerActorId); - }, - }), - ancestors: t.connection({ - type: Post, - description: - "The chain of posts this post replies to, from the nearest parent " + - "toward the thread root: the first node is the same post as " + - "`replyTarget`, the last is the oldest reachable ancestor. " + - "Ancestors that are censored, whose author is hidden by a " + - "moderation sanction, or that are not visible to the viewer are " + - "omitted from the chain. To detect such gaps, compare a node's " + - "`replyTarget` with the next node: a mismatching id (censored " + - "parent) or a `null` `replyTarget` on a node that is not the last " + - "one (invisible parent) marks a gap, and a last node with a " + - "non-`null` `replyTarget` means the chain continues past what was " + - "returned. The walk is bounded to 200 hops server-side. Pass " + - "`actingAccountId` for an organization perspective.", - args: { - actingAccountId: t.arg.id({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - resolve: async (post, args, ctx) => { - const viewerActorId = await resolveViewerActorId(ctx, args); - const chain = await getAncestorChain(ctx.db, post.id); - const visible = await loadVisibleThreadPosts( - ctx, - chain.map((entry) => entry.id), - viewerActorId, - ); - const nodes = chain.flatMap((entry) => { - const row = visible.get(entry.id); - return row == null ? [] : [row]; - }); - return resolveArrayConnection({ args }, nodes); - }, - }), - descendants: t.connection({ - type: Post, - description: - "Every reply below this post (replies, replies to replies, and so " + - "on), flattened in depth-first order with siblings ordered by " + - "`published`. A node's parent (`replyTarget`) always appears " + - "before the node itself, including across pages, so clients can " + - "rebuild the tree from `replyTarget` ids alone. Subtrees rooted at " + - "a censored post, a post by a sanction-hidden actor, or a post " + - "invisible to the viewer are pruned along with that post. " + - "Traversal depth is capped at `maxDepth`; fetch a deeper branch " + - "from the deepest returned post's own `descendants`. Only forward " + - "pagination (`first`/`after`) is supported. Pass `actingAccountId` " + - "for an organization perspective.", - args: { - maxDepth: t.arg.int({ - description: "Maximum tree depth to traverse below this post (direct " + - "replies are depth 1). Defaults to 20 and is clamped " + - "server-side to 40.", - }), - actingAccountId: t.arg.id({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - resolve: async (post, args, ctx) => { - if (args.last != null || args.before != null) { - throw createGraphQLError( - "`descendants` only supports forward pagination " + - "(`first`/`after`).", - ); - } - const first = Math.min(Math.max(args.first ?? 60, 1), 200); - const maxDepth = Math.min( - Math.max(args.maxDepth ?? 20, 1), - DESCENDANT_TREE_MAX_DEPTH, - ); - const viewerActorId = await resolveViewerActorId(ctx, args); - const edges: { cursor: string; node: ThreadPostRow }[] = []; - let after = args.after == null - ? null - : decodeDescendantCursor(args.after); - // Whether a reply visible to this viewer exists past the emitted page. - // Derived from actually finding one more visible survivor, never from the - // raw `hasMore`: the raw tail can be entirely invisible to this viewer, - // and reporting `hasNextPage: true` off it would leak that hidden replies - // exist (the client would then load a phantom, empty next page). - let sawExtraVisible = false; - // Bound the work per request so a subtree padded with replies hidden - // from this viewer cannot make one request scan without limit. `after` - // (the raw scan position) advances through hidden runs within a request, - // but the returned `endCursor` never does: see below. - const maxRounds = 10; - for (let round = 0; round < maxRounds; round++) { - const remaining = first - edges.length; - const page = await getDescendantPage(ctx.db, post.id, { - after, - // While filling, fetch what is left plus one so a single dense page - // both fills and reveals the next survivor. Once full, only one more - // visible survivor is needed, so fetch a small fixed batch (enough to - // step over a short run of hidden rows) rather than another `first`. - limit: remaining > 0 ? remaining + 1 : DESCENDANT_PROBE_BATCH, - maxDepth, - viewerActorId, - }); - if (page.entries.length < 1) break; - const idsToCheck = new Set(); - for (const entry of page.entries) { - idsToCheck.add(entry.id); - for (const ancestorId of descendantPathAncestorIds(entry.cursor)) { - idsToCheck.add(ancestorId); - } - } - const visibleIds = await loadVisibleThreadPostIds( - ctx, - [...idsToCheck], - viewerActorId, - ); - const survivors = page.entries.filter((entry) => - visibleIds.has(entry.id) && - descendantPathAncestorIds(entry.cursor).every((id) => - visibleIds.has(id) - ) - ); - const rows = await loadVisibleThreadPosts( - ctx, - survivors.map((entry) => entry.id), - viewerActorId, - ); - for (const entry of survivors) { - const row = rows.get(entry.id); - if (row == null) continue; - if (edges.length < first) { - edges.push({ - cursor: encodeDescendantCursor(entry.cursor), - node: row, - }); - } else { - // One visible survivor beyond the emitted page is enough to know a - // real next page exists; do not emit it, the next request will. - sawExtraVisible = true; - break; - } - } - if (sawExtraVisible) break; - after = page.entries[page.entries.length - 1].cursor; - if (!page.hasMore) break; - } - // `endCursor` is only ever a visible edge we emitted, never a hidden - // row: a hidden row's cursor is base64 of its path (its id and publish - // time), so exposing it would disclose that hidden descendants exist and - // leak their identity. A page that emits nothing returns a `null` - // cursor, indistinguishable from a post that has no descendants at all. - const endCursor = edges.length > 0 - ? edges[edges.length - 1].cursor - : null; - // Offer a next page only when the probe actually found one more visible - // reply, never off unread rows alone. Once the page is full, a bounded - // run of replies hidden from this viewer must not surface a "load more" - // that then yields an empty page: that would disclose that hidden - // descendants exist. A visible reply buried under a run of hidden ones - // longer than the probe budget stays reachable through its own permalink. - const hasNextPage = sawExtraVisible; - return { - edges, - pageInfo: { - hasNextPage, - hasPreviousPage: args.after != null, - startCursor: edges.length > 0 ? edges[0].cursor : null, - endCursor, - }, - }; - }, - }), - shares: t.connection({ - type: Post, - description: - "Boost wrapper posts that reshare this post, newest first. Each edge " + - "represents a single boost by a specific actor. Censored boosts " + - "(including boosts of a censored post), boosts by actors whose " + - "content is hidden by a moderation sanction, and boosts not visible " + - "to the selected viewer account (e.g., followers-only boosts by " + - "actors the viewer does not follow) are excluded. Pass " + - "`actingAccountId` for an organization perspective.", - args: { - actingAccountId: t.arg.id({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - resolve: async (post, args, ctx) => { - const { edges, pageInfo } = await resolveOffsetConnection( - { args }, - ({ offset, limit }) => - visibleRelatedPostsPage( - ctx, - args, - "sharedPostId", - post.id, - offset, - limit, - ), - ); - return { - edges: [...edges], - pageInfo: { - hasNextPage: pageInfo.hasNextPage, - hasPreviousPage: pageInfo.hasPreviousPage, - startCursor: pageInfo.startCursor, - endCursor: pageInfo.endCursor, - }, - }; - }, - }), - quotes: t.connection({ - type: Post, - description: - "Posts that quote this post inline, newest first. Censored quotes, " + - "quotes by actors whose content is hidden by a moderation sanction, " + - "and quotes not visible to the selected viewer account (e.g., " + - "followers-only quotes by actors the viewer does not follow) are " + - "excluded. Pass `actingAccountId` for an organization perspective.", - args: { - actingAccountId: t.arg.id({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - resolve: async (post, args, ctx) => { - const { edges, pageInfo } = await resolveOffsetConnection( - { args }, - ({ offset, limit }) => - visibleRelatedPostsPage( - ctx, - args, - "quotedPostId", - post.id, - offset, - limit, - ), - ); - return { - edges: [...edges], - pageInfo: { - hasNextPage: pageInfo.hasNextPage, - hasPreviousPage: pageInfo.hasPreviousPage, - startCursor: pageInfo.startCursor, - endCursor: pageInfo.endCursor, - }, - }; - }, - }), - mentions: t.connection({ - type: Actor, - description: - "Actors explicitly @-mentioned in this post. Does not include " + - "implicit mentions (e.g., the author of the post being replied to). " + - "Empty when the post is censored or its author is hidden by a " + - "moderation sanction, and the viewer is neither the " + - "author nor a moderator, since the mention targets are part of the " + - "censored content.", - select: (args, ctx, nestedSelection) => ({ - columns: { censored: true, actorId: true }, - with: { - actor: sanctionActorSelection, - mentions: mentionConnectionHelpers.getQuery(args, ctx, nestedSelection), - }, - }), - resolve: (post, args, ctx) => - mentionConnectionHelpers.resolve( - isCensoredForViewer(post, ctx) ? [] : post.mentions, - args, - ctx, - ), - }), -})); - -export const Note = builder.drizzleNode("postTable", { - variant: "Note", - description: - "A short-form microblog post, equivalent to a Mastodon Status or " + - "ActivityPub Note. Notes can be composed locally or federated in from " + - "remote instances. Boost wrappers (`sharedPost` is non-null) have empty " + - "content and copy the shared post's URL.", - interfaces: [Post, Reactable], - id: { - column: (post) => post.id, - }, - fields: (t) => ({ - sourceId: t.expose("noteSourceId", { - type: "UUID", - nullable: true, - description: - "The local source UUID for this note — `noteSourceTable.id`, the " + - "identifier embedded in `Post.url` (`/@username/`). " + - "Non-null only for source-backed local notes (notes originally " + - "composed on this instance). Null for federated remote notes and for " + - "local share wrappers (boosts), since neither carries a " + - "`noteSourceTable` row; for those, fall back to `Post.uuid`.", - }), - rawContent: t.field({ - type: "Markdown", - nullable: true, - description: - "The raw Markdown source of this note. Non-null only when the " + - "viewer is the note's author. Pass `actingAccountId` to read the " + - "source of a note authored by an organization account the viewer " + - "belongs to. Returns `null` for federated remote notes, local share " + - "wrappers, and notes authored by someone else.", - args: { - actingAccountId: t.arg.id({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - select: { - with: { noteSource: { columns: { content: true, accountId: true } } }, - }, - async resolve(post, args, ctx) { - if (post.noteSource == null) return null; - if (ctx.account == null) return null; - const actingAccount = await resolveActingAccountForGlobalIdArg( - ctx, - args, - ); - if (actingAccount.id !== post.noteSource.accountId) return null; - return post.noteSource.content; - }, - }), - }), -}); - -export const Article = builder.drizzleNode("postTable", { - variant: "Article", - description: - "A long-form blog article written on this platform. Articles have a " + - "title, year-based URL slug, and can have multiple `ArticleContent` " + - "translations. Remote articles federated from other instances lack a " + - "local `articleSource` and will have `null` for `slug`, `publishedYear`, and `tags`.", - interfaces: [Post, Reactable], - id: { - column: (post) => post.id, - }, - fields: (t) => ({ - // articleSource is only present for locally-authored articles. Articles - // federated in from remote servers don't have one — the upstream - // metadata lives on the post itself, not in our articleSource table — - // so the fields below have to be nullable to represent that. - sourceId: t.expose("articleSourceId", { - type: "UUID", - nullable: true, - description: - "The local source UUID for this article (`articleSourceTable.id`). " + - "Non-null only for source-backed local articles (articles originally " + - "composed on this instance). Use it when calling APIs that need to " + - "resolve the article's attached media, e.g. `renderMarkdown` with " + - "an `articleSourceId` argument for edit-time previews. `null` for " + - "articles federated in from remote instances.", - }), - publishedYear: t.int({ - nullable: true, - description: - "The year the article was first published, used as part of its " + - "URL path (e.g., `/@alice/2024/my-article`). `null` for articles " + - "federated in from remote instances.", - select: { - with: { - articleSource: { - columns: { publishedYear: true }, - }, - }, - }, - resolve: (post) => post.articleSource?.publishedYear ?? null, - }), - slug: t.string({ - nullable: true, - description: - "URL slug for the article, used together with `publishedYear` " + - "to build its permalink. `null` for remote articles.", - select: { - with: { - articleSource: { - columns: { slug: true }, - }, - }, - }, - resolve: (post) => post.articleSource?.slug ?? null, - }), - tags: t.stringList({ - nullable: true, - description: - "Author-assigned tags for this article. `null` for articles " + - "federated in from remote instances. Empty when the post is " + - "censored, or its author is hidden by a moderation sanction, and the viewer is neither the author nor a moderator, " + - "since the tags are part of the censored content.", - select: { - columns: { censored: true, actorId: true }, - with: { - actor: sanctionActorSelection, - articleSource: { - columns: { tags: true }, - }, - }, - }, - resolve: (post, _, ctx) => { - if (isCensoredForViewer(post, ctx)) return []; - return post.articleSource?.tags ?? null; - }, - }), - allowLlmTranslation: t.boolean({ - nullable: true, - description: - "Whether the author has enabled LLM-based translation for this " + - "article. `null` for articles federated from remote instances.", - select: { - with: { - articleSource: { - columns: { allowLlmTranslation: true }, - }, - }, - }, - resolve: (post) => post.articleSource?.allowLlmTranslation ?? null, - }), - contents: t.field({ - type: [ArticleContent], - description: - "All available language versions of this article's content. " + - "Pass `language` to get only the best-matching locale (BCP 47 " + - "negotiation). Pass `includeBeingTranslated: true` to also include " + - "language versions whose LLM translation is still in progress. " + - "Empty when the article is censored or its author is hidden by " + - "a moderation sanction, and the viewer is neither " + - "its author nor a moderator.", - args: { - language: t.arg({ type: "Locale", required: false }), - includeBeingTranslated: t.arg({ - type: "Boolean", - required: false, - defaultValue: false, - }), - }, - complexity: (args) => ({ - field: 1, - multiplier: args.language == null ? 10 : 1, - }), - select: (args) => ({ - columns: { actorId: true, censored: true }, - with: { - actor: sanctionActorSelection, - articleSource: { - with: { - contents: args.includeBeingTranslated - ? {} - : { where: { beingTranslated: false } }, - }, - }, - }, - }), - resolve(post, args, ctx) { - if (isCensoredForViewer(post, ctx)) return []; - const contents = post.articleSource?.contents ?? []; - if (args.language == null) return contents; - const availableLocales = contents.map((c) => c.language); - const selectedLocale = negotiateLocale(args.language, availableLocales); - return contents.filter( - (c) => c.language === selectedLocale?.baseName, - ); - }, - }), - }), -}); - -builder.drizzleObjectField(Article, "account", (t) => - t.field({ - // Federated remote articles don't carry an articleSource (see the - // articleSource-backed fields on Article above), so the author has - // to be nullable here too — for remote articles, callers should fall - // back to the post-level actor. - type: Account, - nullable: true, - select: (_, __, nestedSelection) => ({ - with: { - articleSource: { - with: { - account: nestedSelection(), - }, - }, - }, - }), - resolve: (post) => post.articleSource?.account ?? null, - })); - -export const ArticleDraft = builder.drizzleNode("articleDraftTable", { - variant: "ArticleDraft", - description: - "An unpublished article draft. Visible only to the owning account. " + - "Drafts are promoted to `Article`s via the `publishArticleDraft` mutation.", - // The `articleDraft` query already scopes lookups to the owner, but a - // draft's global ID must not let anyone else read it via `node(id:)`. - // Owner-only, matching the query (drafts belong to personal accounts; - // not even moderators can read them). - authScopes: (draft, ctx) => - ctx.account != null && draft.accountId === ctx.account.id, - runScopesOnType: true, - id: { - column: (draft) => draft.id, - }, - fields: (t) => ({ - uuid: t.expose("id", { type: "UUID" }), - title: t.exposeString("title"), - content: t.expose("content", { type: "Markdown" }), - contentHtml: t.field({ - type: "HTML", - description: "The rendered HTML of the draft's markdown content.", - select: { - columns: { - content: true, - }, - }, - async resolve(draft, _, ctx) { - const rendered = await renderMarkup(ctx.fedCtx, draft.content, { - mediumUrls: await getArticleDraftMediumUrls( - ctx.db, - ctx.disk, - draft.id, - ), - missingMediumLabel: getMissingArticleMediumLabel( - ctx.account?.locales?.[0], - ), - }); - return addExternalLinkTargets( - rendered.html, - new URL(ctx.fedCtx.canonicalOrigin), - ); - }, - }), - tags: t.exposeStringList("tags"), - created: t.expose("created", { type: "DateTime" }), - updated: t.expose("updated", { type: "DateTime" }), - account: t.relation("account"), - }), -}); - -export const Question = builder.drizzleNode("postTable", { - variant: "Question", - description: - "An ActivityPub `Question` poll. Local Questions are source-backed " + - "short posts with immutable poll settings; remote Questions may have " + - "`null` for `sourceId`. Use `Question.sourceId` for source-backed local " + - "Question routes, and fall back to `Post.uuid` for federated remote " + - "Questions and local share wrappers.", - interfaces: [Post, Reactable], - id: { - column: (post) => post.id, - }, - fields: (t) => ({ - sourceId: t.expose("noteSourceId", { - type: "UUID", - nullable: true, - description: - "The local source UUID for this question (`noteSourceTable.id`), " + - "embedded in source-backed local Question URLs as " + - "`/@username/`. `null` for federated remote questions and " + - "local share wrappers; use `Post.uuid` as the fallback route token.", - }), - }), -}); - -/** - * Whether this article content version belongs to a censored article whose - * content must be redacted for the current viewer (the author and - * moderators are exempt). Guards direct `ArticleContent` node access, - * which bypasses `Article.contents`. - */ -function isArticleContentCensoredForViewer( - content: { source: { post: { censored: Date | null; actorId: Uuid } } }, - ctx: UserContext, -): boolean { - return isCensoredForViewer(content.source.post, ctx); -} - -export const ArticleContent = builder.drizzleNode("articleContentTable", { - name: "ArticleContent", - description: - "A single language version of an `Article`'s content. Each language is " + - "stored separately; `Article.contents` lists all available translations. " + - "Translated versions have a non-null `originalLanguage`; `translator` " + - "can be `null` when the translating account was deleted.", - id: { - column: (content) => [content.sourceId, content.language], - }, - fields: (t) => ({ - language: t.expose("language", { - type: "Locale", - description: "BCP 47 language tag identifying this content version.", - }), - title: t.field({ - type: "String", - description: - "The article's title in this language. Empty when the article " + - "is censored, or its author is hidden by a moderation sanction, " + - "and the viewer is neither the author nor a " + - "moderator.", - select: { - columns: { title: true }, - with: { - source: { - with: { - post: { - columns: { censored: true, actorId: true }, - with: { actor: sanctionActorSelection }, - }, - }, - }, - }, - }, - resolve: (content, _, ctx) => - isArticleContentCensoredForViewer(content, ctx) ? "" : content.title, - }), - summary: t.field({ - type: "String", - nullable: true, - select: { - columns: { summary: true }, - with: { - source: { - with: { - post: { - columns: { censored: true, actorId: true }, - with: { actor: sanctionActorSelection }, - }, - }, - }, - }, - }, - resolve: (content, _, ctx) => - isArticleContentCensoredForViewer(content, ctx) - ? null - : content.summary, - description: - "`null` when the article is censored, or its author is hidden by " + - "a moderation sanction, and the viewer is neither " + - "its author nor a moderator. Otherwise the " + - "LLM-generated summary for this language version: `null` until " + - "generation completes. Check `summaryStarted` to distinguish " + - 'between "not requested" and "in progress".', - }), - summaryStarted: t.expose("summaryStarted", { - type: "DateTime", - nullable: true, - description: - "When LLM summary generation was started for this content version. " + - "`null` if summary generation has not been requested.", - }), - content: t.field({ - type: "HTML", - description: - "Rendered HTML of this language version, with media URLs resolved " + - "and external links annotated. Empty when the article is " + - "censored, or its author is hidden by a moderation sanction, and the viewer is neither the author nor a moderator.", - select: { - columns: { - content: true, - language: true, - }, - with: { - source: { - with: { - post: { - columns: { - actorId: true, - censored: true, - emojis: true, - tags: true, - }, - with: { - actor: sanctionActorSelection, - mentions: { - with: { actor: true }, - }, - }, - }, - }, - }, - }, - }, - async resolve(content, _, ctx) { - if (isArticleContentCensoredForViewer(content, ctx)) return ""; - const html = await renderMarkup(ctx.fedCtx, content.content, { - kv: ctx.kv, - mediumUrls: await getArticleSourceMediumUrls( - ctx.db, - ctx.disk, - content.sourceId, - ), - missingMediumLabel: getMissingArticleMediumLabel(content.language), - }); - const post = content.source.post; - let rendered = renderCustomEmojis(html.html, post.emojis); - rendered = transformMentions(rendered, post.mentions, post.tags); - return addExternalLinkTargets( - rendered, - new URL(ctx.fedCtx.canonicalOrigin), - ); - }, - }), - rawContent: t.field({ - type: "Markdown", - description: - "The raw markdown content for editing. Empty when the article " + - "is censored, or its author is hidden by a moderation sanction, " + - "and the viewer is neither the author nor a " + - "moderator.", - select: { - columns: { content: true }, - with: { - source: { - with: { - post: { - columns: { censored: true, actorId: true }, - with: { actor: sanctionActorSelection }, - }, - }, - }, - }, - }, - resolve(content, _, ctx) { - if (isArticleContentCensoredForViewer(content, ctx)) return ""; - return content.content; - }, - }), - toc: t.field({ - type: "JSON", - description: - "Table of contents for the article content. Empty when the " + - "article is censored, or its author is hidden by a moderation " + - "sanction, and the viewer is neither the author nor a " + - "moderator.", - select: { - columns: { content: true, language: true, sourceId: true }, - with: { - source: { - with: { - post: { - columns: { censored: true, actorId: true }, - with: { actor: sanctionActorSelection }, - }, - }, - }, - }, - }, - async resolve(content, _, ctx) { - if (isArticleContentCensoredForViewer(content, ctx)) return []; - const rendered = await renderMarkup(ctx.fedCtx, content.content, { - kv: ctx.kv, - mediumUrls: await getArticleSourceMediumUrls( - ctx.db, - ctx.disk, - content.sourceId, - ), - missingMediumLabel: getMissingArticleMediumLabel(content.language), - }); - return rendered.toc; - }, - }), - originalLanguage: t.expose("originalLanguage", { - type: "Locale", - nullable: true, - description: - "The source language this content was translated from. Non-null " + - "only for LLM-translated versions; `null` for original content.", - }), - translator: t.relation("translator", { - nullable: true, - description: - "The account whose LLM translation produced this content version. " + - "`null` for original (non-translated) content.", - }), - translationRequester: t.relation("translationRequester", { - nullable: true, - description: - "The account that requested this translation. May differ from " + - "`translator` if translations are requested on behalf of others.", - }), - beingTranslated: t.exposeBoolean("beingTranslated", { - description: - "Whether an LLM translation into this language is currently " + - "in progress. When `true`, the content may be incomplete.", - }), - updated: t.expose("updated", { type: "DateTime" }), - published: t.expose("published", { type: "DateTime" }), - ogImageUrl: t.field({ - type: "URL", - nullable: true, - description: "The generated Open Graph preview image for this language " + - "version. `null` when the article is censored, or its author is " + - "hidden by a moderation sanction, and the viewer " + - "is neither its author nor a moderator: the image is rendered " + - "from the title and excerpt and would otherwise leak censored " + - "content.", - complexity: articleContentOgImageComplexity, - select: { - columns: { - content: true, - language: true, - ogImageKey: true, - sourceId: true, - summary: true, - title: true, - }, - with: { - source: { - with: { - account: { - with: { - actor: { - columns: { - handleHost: true, - }, - }, - avatarMedium: true, - emails: true, - }, - }, - post: { - columns: { censored: true, actorId: true }, - with: { actor: sanctionActorSelection }, - }, - }, - }, - }, - }, - async resolve(content, _, ctx) { - if (isArticleContentCensoredForViewer(content, ctx)) return null; - const account = content.source.account; - const rendered = await renderMarkup(ctx.fedCtx, content.content, { - kv: ctx.kv, - mediumUrls: await getArticleSourceMediumUrls( - ctx.db, - ctx.disk, - content.sourceId, - ), - missingMediumLabel: getMissingArticleMediumLabel(content.language), - }); - const avatarUrl = await getAvatarUrl(ctx.disk, account); - const key = await putArticleOgImage(ctx.disk, content.ogImageKey, { - authorName: account.name, - avatarKey: account.avatarMedium?.key ?? avatarUrl, - avatarUrl, - excerpt: content.summary ?? rendered.text, - handle: `@${account.username}@${account.actor.handleHost}`, - language: content.language, - sourceId: content.sourceId, - title: content.title, - }); - if (key !== content.ogImageKey) { - await ctx.db.update(articleContentTable) - .set({ ogImageKey: key }) - .where( - and( - eq(articleContentTable.sourceId, content.sourceId), - eq(articleContentTable.language, content.language), - ), - ); - } - return new URL(await ctx.disk.getUrl(key)); - }, - }), - url: t.field({ - type: "URL", - description: - "Canonical URL for this language version. For the article's " + - "primary language this is `/@username/year/slug`; for other " + - "language versions it appends `/{language}` to that path.", - select: { - with: { - source: { - columns: { - publishedYear: true, - slug: true, - }, - with: { - account: { - columns: { - username: true, - }, - }, - post: { - columns: { - language: true, - }, - }, - }, - }, - }, - }, - resolve(content, _, ctx) { - if ( - content.originalLanguage != null || - content.language !== content.source.post.language - ) { - return new URL( - `/@${content.source.account.username}/${content.source.publishedYear}/${content.source.slug}/${content.language}`, - ctx.fedCtx.canonicalOrigin, - ); - } - return new URL( - `/@${content.source.account.username}/${content.source.publishedYear}/${content.source.slug}`, - ctx.fedCtx.canonicalOrigin, - ); - }, - }), - }), -}); - -const Hashtag = builder.simpleObject("Hashtag", { - fields: (t) => ({ - name: t.string(), - href: t.field({ type: "URL" }), - }), -}); - -const PostEngagementStats = builder.drizzleObject("postTable", { - variant: "PostEngagementStats", - description: - "Cached engagement counters for a post. Updated asynchronously; may " + - "be slightly stale. Query the live connections (`replies`, `shares`, " + - "etc.) directly when exact real-time counts matter.", - fields: (t) => ({ - replies: t.exposeInt("repliesCount"), - shares: t.exposeInt("sharesCount"), - quotes: t.exposeInt("quotesCount"), - reactions: t.exposeInt("reactionsCount"), - bookmarks: t.loadable({ - type: "Int", - // cache: false so a mutation that flips bookmark state in the same - // request (bookmark + read bookmarks + unbookmark + read bookmarks) - // re-queries instead of returning the pre-mutation count. - loaderOptions: { cache: false }, - load: async (postIds: Uuid[], ctx: UserContext): Promise => { - const counts = await getBookmarkCountsForPosts(ctx.db, postIds); - return postIds.map((id) => counts.get(id) ?? 0); - }, - resolve: (post) => post.id, - }), - }), -}); - -builder.drizzleObjectField(PostEngagementStats, "post", (t) => t.variant(Post)); - -const mentionConnectionHelpers = drizzleConnectionHelpers( - builder, - "mentionTable", - { - select: (nodeSelection) => ({ - with: { - actor: nodeSelection(), - }, - }), - resolveNode: (mention) => mention.actor, - }, -); - -const PostMediumRef = builder.drizzleNode("postMediumTable", { - name: "PostMedium", - description: - "A media attachment on a post. For local posts this refers to an " + - "uploaded `Medium` stored on this instance; for federated posts the " + - "`url` points to the remote media URL on the originating instance. " + - "Attachments of a censored post, or of a post whose author is hidden " + - "by a moderation sanction, are part of the moderation-hidden content " + - "and are only resolvable by the author and moderators, even through " + - "direct `node(id:)` lookups.", - authScopes: async (medium, ctx) => { - const post = await ctx.db.query.postTable.findFirst({ - where: { id: medium.postId }, - columns: { censored: true, actorId: true }, - with: { actor: sanctionActorSelection }, - }); - if ( - post == null || - post.censored == null && !isActorSanctionHidden(post.actor) - ) { - return true; - } - if (ctx.account?.actor.id === post.actorId) return true; - return { moderator: true }; - }, - runScopesOnType: true, - id: { - column: (medium) => [medium.postId, medium.index], - }, - fields: (t) => ({ - type: t.expose("type", { type: "MediaType" }), - url: t.field({ type: "URL", resolve: (medium) => new URL(medium.url) }), - alt: t.exposeString("alt", { nullable: true }), - width: t.exposeInt("width", { nullable: true }), - height: t.exposeInt("height", { nullable: true }), - sensitive: t.exposeBoolean("sensitive"), - thumbnailUrl: t.string({ - nullable: true, - resolve(medium, _, ctx) { - if (medium.thumbnailKey == null) return; - return ctx.disk.getUrl(medium.thumbnailKey); - }, - }), - }), -}); - -export const Medium = builder.drizzleNode("mediumTable", { - name: "Medium", - description: "A stored media object (image). Two-step upload flow: call " + - "`startMediumUpload` to get a pre-signed upload URL, PUT the image " + - "to that URL, then call `finishMediumUpload` to complete the transaction. " + - "Alternatively, call `createMedium` with a remote URL to import an " + - "image directly. Unreferenced media older than the grace period are " + - "deleted by the `deleteOrphanMedia` mutation. Resolvable via " + - "`node(id:)` when it has at least one reference visible to the viewer: " + - "the avatar of an account that is not banned, or a published post that " + - "is neither censored nor authored by a sanction-hidden actor (the " + - "viewer's own account/posts and moderators always count as visible). " + - "Hidden when it has references but every avatar and post reference is " + - "moderation-hidden for the viewer; fresh, orphan, and draft-only media " + - "(with no such references) remain resolvable.", - authScopes: async (medium, ctx) => { - if (ctx.account?.moderator) return true; - const viewerActorId = ctx.account?.actor.id; - // A medium stays resolvable when it has at least one reference visible - // to this viewer: the avatar of a non-hidden account, or a published - // post that is not censored and whose author is not sanction-hidden - // (or that the viewer authored). A freshly uploaded / orphan / - // draft-only medium has no references and passes (preserving the - // prior unscoped behavior); it is denied only when it has references - // and every one of them is moderation-hidden for the viewer. - const postSelection = { - columns: { censored: true, actorId: true }, - with: { - actor: { - columns: { - accountId: true, - suspended: true, - suspendedUntil: true, - }, - }, - }, - } as const; - const avatarAccounts = await ctx.db.query.accountTable.findMany({ - where: { avatarMediumId: medium.id }, - columns: { id: true }, - with: { - actor: { - columns: { id: true, suspended: true, suspendedUntil: true }, - }, - }, - }); - for (const account of avatarAccounts) { - if (account.actor == null || !isActorProfileHidden(account.actor, ctx)) { - return true; - } - } - const noteMedia = await ctx.db.query.noteSourceMediumTable.findMany({ - where: { mediumId: medium.id }, - columns: { sourceId: true }, - with: { - source: { columns: { id: true }, with: { post: postSelection } }, - }, - }); - for (const { source } of noteMedia) { - const post = source?.post; - if ( - post != null && - (post.actorId === viewerActorId || - (post.censored == null && !isActorSanctionHidden(post.actor))) - ) { - return true; - } - } - const articleMedia = await ctx.db.query.articleSourceMediumTable.findMany({ - where: { mediumId: medium.id }, - columns: { articleSourceId: true }, - with: { - articleSource: { - columns: { id: true }, - with: { post: postSelection }, - }, - }, - }); - for (const { articleSource } of articleMedia) { - const post = articleSource?.post; - if ( - post != null && - (post.actorId === viewerActorId || - (post.censored == null && !isActorSanctionHidden(post.actor))) - ) { - return true; - } - } - const hasReference = avatarAccounts.length > 0 || - noteMedia.length > 0 || articleMedia.length > 0; - return !hasReference; - }, - // Run the scope when the node itself is resolved, so a cached avatar - // medium id cannot bypass the redacted Account.avatarMediumId. - runScopesOnType: true, - id: { - column: (medium) => medium.id, - }, - fields: (t) => ({ - uuid: t.expose("id", { type: "UUID" }), - url: t.field({ - type: "URL", - description: "Public URL for the stored medium.", - resolve: async (medium, _, ctx) => - new URL(await ctx.disk.getUrl(medium.key)), - }), - type: t.expose("type", { - type: "MediaType", - description: "The medium's media type. Local uploads are stored as WebP.", - }), - contentHash: t.expose("contentHash", { - type: "Sha256", - nullable: true, - description: "SHA-256 hash of the normalized stored content, if known.", - }), - width: t.exposeInt("width", { nullable: true }), - height: t.exposeInt("height", { nullable: true }), - created: t.expose("created", { type: "DateTime" }), - }), -}); - -builder.drizzleObjectField(Medium, "generatedAltText", (t) => - t.string({ - nullable: true, - description: "AI-generated alternative text for this medium. " + - "Requires authentication. " + - "Within the 2-hour upload window only the uploader may call this " + - "field; after the window expires any authenticated user may call it " + - "(the medium is either publicly referenced or pending orphan cleanup). " + - "Multiple uploaders of identical content each get independent " + - "ownership entries, so content-hash deduplication does not grant " + - "the later uploader access to the earlier one's window. " + - "High-complexity operation (cost 1000). " + - "The context argument is truncated server-side to 1000 characters.", - complexity: 1000, - args: { - language: t.arg({ type: "Locale", required: true }), - context: t.arg({ type: "String", required: false }), - }, - async resolve(medium, args, ctx) { - const session = await ctx.session; - if (session == null) throw new NotAuthenticatedError(); - const owner = await isMediumOwner(ctx.kv, medium.id, session.accountId); - if (!owner) { - const windowActive = await isMediumUploadWindowActive( - ctx.kv, - medium.id, - ); - if (windowActive) throw new NotAuthorizedError(); - } - const imageUrl = await ctx.disk.getUrl(medium.key); - return await generateAltText({ - model: ctx.altTextGenerator, - imageUrl, - language: (args.language as Intl.Locale).baseName, - context: args.context ?? undefined, - }); - }, - })); - -const MediumUploadHeader = builder.simpleObject("MediumUploadHeader", { - fields: (t) => ({ - name: t.string(), - value: t.string(), - }), -}); - -export const PostLink = builder.drizzleNode("postLinkTable", { - variant: "PostLink", - description: "OpenGraph / oEmbed metadata for a link embedded in a post. " + - "Populated asynchronously after the post is created; individual " + - "fields may be `null` until the metadata fetch completes or if the " + - "linked page does not expose the corresponding tag. Not resolvable " + - "via `node(id:)` when every post referencing the link is censored or " + - "authored by a sanction-hidden actor, for this viewer: the linked " + - "URL is part of the moderation-hidden content.", - authScopes: (link, ctx) => { - if (ctx.account?.moderator) return true; - // Link rows are shared across posts referencing the same URL, so the - // link counts as hidden only when no referencing post still shows it - // to this viewer: a referencing post must be uncensored AND have a - // sanction-visible author (the same rule post visibility applies), - // or be the viewer's own. Orphan rows with no referencing post - // (e.g. news-only links) pass. Batched through a request-scoped - // loader so link-heavy pages (the news list) stay free of per-link - // lookups. - ctx.postLinkVisibleLoader ??= new DataLoader( - async (linkIds) => { - const ids = [...linkIds]; - const viewerActorId = ctx.account?.actor.id; - // Bind the sanction-activeness comparison to a request-time `Date`, - // the same application clock the write path and `isActorSanctionHidden` - // use, NOT SQL `now()`: inside a transaction `now()` is frozen at the - // transaction start, so a ban recorded later (with `new Date()`) would - // read as not-yet-active and leak the hidden link. - const now = new Date(); - // NULL-safe mirror of isActorSanctionHidden's complement, built with - // drizzle operators so the `now` Date binds as a parameter (a remote - // actor's content is hidden by an active federation block; a local - // actor's only by a permanent ban). `lte`/`gt` on a `null` - // `suspendedUntil` yield `null` (not matched), which is the intended - // NULL-safe behavior. - const shows = and( - isNull(postTable.censored), - or( - isNull(actorTable.suspended), - gt(actorTable.suspended, now), - lte(actorTable.suspendedUntil, now), - and( - isNotNull(actorTable.accountId), - gt(actorTable.suspendedUntil, now), - ), - ), - )!; - const visible = await ctx.db - .selectDistinct({ linkId: postTable.linkId }) - .from(postTable) - .innerJoin(actorTable, eq(postTable.actorId, actorTable.id)) - .where(and( - inArray(postTable.linkId, ids), - viewerActorId == null - ? shows - : or(eq(postTable.actorId, viewerActorId), shows), - )); - const visibleSet = new Set(visible.map((row) => row.linkId)); - if (visibleSet.size === ids.length) return ids.map(() => true); - const referenced = await ctx.db - .selectDistinct({ linkId: postTable.linkId }) - .from(postTable) - .where(inArray(postTable.linkId, ids)); - const referencedSet = new Set(referenced.map((row) => row.linkId)); - return ids.map((id) => visibleSet.has(id) || !referencedSet.has(id)); - }, - ); - return ctx.postLinkVisibleLoader.load(link.id); - }, - // Run the scope when the node itself is resolved, so a cached link - // node id cannot bypass the Post.link redaction via node(id:). - runScopesOnType: true, - id: { - column: (link) => link.id, - }, - fields: (t) => ({ - url: t.field({ - type: "URL", - resolve: (link) => new URL(link.url), - }), - title: t.exposeString("title", { nullable: true }), - siteName: t.exposeString("siteName", { nullable: true }), - type: t.exposeString("type", { nullable: true }), - description: t.exposeString("description", { nullable: true }), - author: t.exposeString("author", { nullable: true }), - image: t.variant(PostLinkImage, { - isNull: (link) => link.imageUrl == null, - }), - creator: t.relation("creator", { nullable: true }), - }), -}); - -const PostLinkImage = builder.drizzleObject("postLinkTable", { - variant: "PostLinkImage", - fields: (t) => ({ - url: t.field({ - type: "URL", - resolve(link) { - if (link.imageUrl == null) { - unreachable("Expected imageUrl to be not null"); - } - return new URL(link.imageUrl); - }, - }), - alt: t.exposeString("imageAlt", { nullable: true }), - type: t.expose("imageType", { type: "MediaType", nullable: true }), - width: t.exposeInt("imageWidth", { nullable: true }), - height: t.exposeInt("imageHeight", { nullable: true }), - }), -}); - -builder.drizzleObjectField(PostLinkImage, "post", (t) => t.variant(PostLink)); - -const CreateNoteMediumInput = builder.inputType("CreateNoteMediumInput", { - fields: (t) => ({ - mediumId: t.field({ - type: "UUID", - required: true, - description: "UUID of a Medium to attach to the note.", - }), - alt: t.string({ - required: true, - description: "Alternative text for this note's use of the medium.", - }), - }), -}); - -const CreateQuestionPollInput = builder.inputType("CreateQuestionPollInput", { - description: - "Immutable poll settings for `createQuestion`. These settings cannot " + - "be edited after the Question is published.", - fields: (t) => ({ - title: t.string({ - required: true, - description: - "Poll title used as the ActivityPub `Question.name`. Must be " + - "between `1` and `200` characters after trimming.", - }), - multiple: t.boolean({ - required: true, - description: - "Whether voters may choose more than one option. `false` creates " + - "an ActivityPub Question with exclusive options.", - }), - options: t.stringList({ - required: true, - description: - "Poll option labels in display order. Must contain between `2` " + - "and `20` unique, non-empty entries after trimming.", - }), - ends: t.field({ - type: "DateTime", - required: true, - description: - "Voting deadline. Must be at least `1` minute and at most `1` " + - "year in the future.", - }), - }), -}); - -builder.relayMutationField( - "createNote", - { - description: - "Publish a new short-form note. Sends an ActivityPub `Create` " + - "activity to relevant inboxes based on `visibility`. Requires " + - "authentication.", - inputFields: (t) => ({ - visibility: t.field({ type: PostVisibility, required: true }), - content: t.field({ type: "Markdown", required: true }), - language: t.field({ type: "Locale", required: true }), - quotePolicy: t.field({ type: QuotePolicy, required: false }), - actingAccountId: t.globalID({ - for: Account, - required: false, - description: - "Optional `Account` id to publish as. Omit to publish as the " + - "authenticated personal account; pass an organization account " + - "where the viewer is an accepted member to publish as that " + - "organization.", - }), - attributionMode: t.field({ - type: PostAttributionMode, - required: false, - description: - "How to display the personal member when `actingAccountId` is " + - "an organization. Defaults to `ACTING_ACCOUNT_ONLY`; invalid " + - "when publishing as a personal account.", - }), - media: t.field({ - type: [CreateNoteMediumInput], - required: false, - defaultValue: [], - description: "Media to attach to the note, in display order.", - }), - replyTargetId: t.globalID({ - for: [Note, Article, Question], - required: false, - }), - quotedPostId: t.globalID({ - for: [Note, Article, Question], - required: false, - }), - }), - }, - { - errors: { - types: [ - NotAuthenticatedError, - InvalidInputError, - ActorSuspendedError, - OrganizationPermissionError, - ], - }, - async resolve(_root, args, ctx) { - const session = await ctx.session; - if (session == null || ctx.account == null) { - throw new NotAuthenticatedError(); - } - const authenticatedAccountId = ctx.account.id; - const { - visibility, - content, - language, - quotePolicy, - media, - replyTargetId, - quotedPostId, - } = args.input; - const actingAccount = await resolvePostActingAccount(ctx, args.input); - const attachedMedia = media ?? []; - if (attachedMedia.length > 20) { - throw new InvalidInputError("media"); - } - let replyTarget: schema.Post & { actor: schema.Actor } | undefined; - if (replyTargetId != null) { - const post = await ctx.db.query.postTable.findFirst({ - with: { - actor: { - with: { - followers: { - where: { followerId: actingAccount.account.actor.id }, - }, - blockees: { - where: { blockeeId: actingAccount.account.actor.id }, - }, - blockers: { - where: { blockerId: actingAccount.account.actor.id }, - }, - }, - }, - mentions: { where: { actorId: actingAccount.account.actor.id } }, - }, - where: { id: replyTargetId.id }, - }); - if ( - post == null || !isPostVisibleTo(post, actingAccount.account.actor) - ) { - throw new InvalidInputError("replyTargetId"); - } - replyTarget = post; - } - let quotedPost: schema.Post & { actor: schema.Actor } | undefined; - if (quotedPostId != null) { - const post = await ctx.db.query.postTable.findFirst({ - with: { - actor: { - with: { - followers: { - where: { followerId: actingAccount.account.actor.id }, - }, - blockees: { - where: { blockeeId: actingAccount.account.actor.id }, - }, - blockers: { - where: { blockerId: actingAccount.account.actor.id }, - }, - }, - }, - mentions: { where: { actorId: actingAccount.account.actor.id } }, - sharedPost: { - with: { - actor: { - with: { - followers: { - where: { followerId: actingAccount.account.actor.id }, - }, - blockees: { - where: { blockeeId: actingAccount.account.actor.id }, - }, - blockers: { - where: { blockerId: actingAccount.account.actor.id }, - }, - }, - }, - mentions: { - where: { actorId: actingAccount.account.actor.id }, - }, - }, - }, - }, - where: { id: quotedPostId.id }, - }); - if ( - post == null || !isPostVisibleTo(post, actingAccount.account.actor) - ) { - throw new InvalidInputError("quotedPostId"); - } - // Validate against the effective original post to prevent bypassing - // via a public share wrapper of a non-quotable original. - const effectivePost = post.sharedPost ?? post; - if (effectivePost.sharedPostId != null) { - throw new InvalidInputError("quotedPostId"); - } - if (!isPostVisibleTo(effectivePost, actingAccount.account.actor)) { - throw new InvalidInputError("quotedPostId"); - } - // Neither a censored post nor a censored share wrapper can be - // quoted; the model revalidates, but the submitted row is - // unwrapped here, so the wrapper must be checked here too. - if (post.censored != null || effectivePost.censored != null) { - throw new InvalidInputError("quotedPostId"); - } - if ( - !canActorRequestQuotePost(effectivePost, actingAccount.account.actor) - ) { - throw new InvalidInputError("quotedPostId"); - } - quotedPost = effectivePost; - } - return await withTransaction(ctx.fedCtx, async (context) => { - const noteMedia = await Promise.all( - attachedMedia.map(async (medium, i) => { - const alt = medium.alt.trim(); - if (alt === "") throw new InvalidInputError(`media.${i}.alt`); - const storedMedium = await context.db.query.mediumTable - .findFirst({ - where: { id: medium.mediumId }, - }); - if (storedMedium == null) { - throw new InvalidInputError(`media.${i}.mediumId`); - } - return { mediumId: medium.mediumId, alt }; - }), - ); - let note: Awaited>; - await assertActingAccountNotSuspended( - ctx.db, - authenticatedAccountId, - actingAccount.account.id, - ); - try { - note = await createNote( - context, - { - accountId: actingAccount.account.id, - visibility: visibility === "PUBLIC" - ? "public" - : visibility === "UNLISTED" - ? "unlisted" - : visibility === "FOLLOWERS" - ? "followers" - : visibility === "DIRECT" - ? "direct" - : visibility === "NONE" - ? "none" - : assertNever( - visibility, - `Unknown value in Post.visibility: "${visibility}"`, - ), - quotePolicy: normalizeQuotePolicyForVisibility( - visibility === "PUBLIC" - ? "public" - : visibility === "UNLISTED" - ? "unlisted" - : visibility === "FOLLOWERS" - ? "followers" - : visibility === "DIRECT" - ? "direct" - : visibility === "NONE" - ? "none" - : assertNever( - visibility, - `Unknown value in Post.visibility: "${visibility}"`, - ), - quotePolicy == null ? undefined : fromQuotePolicy(quotePolicy), - ), - content, - language: language.baseName, - media: noteMedia, - }, - { replyTarget, quotedPost }, - { - afterPostCreated: (post, db) => - recordPostActingAccount(db, post.id, actingAccount), - }, - ); - } catch (error) { - if (error instanceof QuotePolicyDeniedError) { - throw new InvalidInputError("quotedPostId"); - } - throw error; - } - if (note == null) { - throw createGraphQLError("Failed to create note.", { - originalError: new Error("Failed to create note."), - extensions: { code: "INTERNAL_SERVER_ERROR" }, - }); - } - return note; - }); - }, - }, - { - outputFields: (t) => ({ - note: t.field({ - type: Note, - resolve(result) { - return result; - }, - }), - }), - }, -); - -builder.relayMutationField( - "createQuestion", - { - description: - "Publish a new short-form post with an immutable ActivityPub " + - "`Question` poll. Sends an ActivityPub `Create` activity to relevant " + - "inboxes based on `visibility`. Requires authentication.", - inputFields: (t) => ({ - visibility: t.field({ - type: PostVisibility, - required: true, - description: "Audience for the new Question post.", - }), - content: t.field({ - type: "Markdown", - required: true, - description: - "Markdown body shown above the poll. The body remains editable " + - "only through future note-editing support; poll settings are " + - "immutable.", - }), - language: t.field({ - type: "Locale", - required: true, - description: "BCP 47 language tag for the Question body.", - }), - quotePolicy: t.field({ - type: QuotePolicy, - required: false, - description: - "Who may quote this Question. Omit to use the default policy for " + - "the selected `visibility`.", - }), - actingAccountId: t.globalID({ - for: Account, - required: false, - description: - "Optional `Account` id to publish as. Omit to publish as the " + - "authenticated personal account; pass an organization account " + - "where the viewer is an accepted member to publish as that " + - "organization.", - }), - attributionMode: t.field({ - type: PostAttributionMode, - required: false, - description: - "How to display the personal member when `actingAccountId` is " + - "an organization. Defaults to `ACTING_ACCOUNT_ONLY`; invalid " + - "when publishing as a personal account.", - }), - poll: t.field({ - type: CreateQuestionPollInput, - required: true, - description: - "Poll title, options, selection mode, and deadline. These values " + - "cannot be changed after publishing.", - }), - media: t.field({ - type: [CreateNoteMediumInput], - required: false, - defaultValue: [], - description: "Media to attach to the Question body, in display order.", - }), - replyTargetId: t.globalID({ - for: [Note, Article, Question], - required: false, - description: - "Optional post to reply to. The target must be visible to the " + - "authenticated account.", - }), - quotedPostId: t.globalID({ - for: [Note, Article, Question], - required: false, - description: - "Optional post to quote. Share wrappers are resolved to their " + - "original post before quote policy checks.", - }), - }), - }, - { - errors: { - types: [ - NotAuthenticatedError, - InvalidInputError, - ActorSuspendedError, - OrganizationPermissionError, - ], - }, - async resolve(_root, args, ctx) { - const session = await ctx.session; - if (session == null || ctx.account == null) { - throw new NotAuthenticatedError(); - } - const authenticatedAccountId = ctx.account.id; - const { - visibility, - content, - language, - quotePolicy, - poll, - media, - replyTargetId, - quotedPostId, - } = args.input; - const actingAccount = await resolvePostActingAccount(ctx, args.input); - const attachedMedia = media ?? []; - if (attachedMedia.length > 20) { - throw new InvalidInputError("media"); - } - if (visibility === "NONE") { - throw new InvalidInputError("visibility"); - } - let replyTarget: schema.Post & { actor: schema.Actor } | undefined; - if (replyTargetId != null) { - const post = await ctx.db.query.postTable.findFirst({ - with: { - actor: { - with: { - followers: { - where: { followerId: actingAccount.account.actor.id }, - }, - blockees: { - where: { blockeeId: actingAccount.account.actor.id }, - }, - blockers: { - where: { blockerId: actingAccount.account.actor.id }, - }, - }, - }, - mentions: { where: { actorId: actingAccount.account.actor.id } }, - }, - where: { id: replyTargetId.id }, - }); - if ( - post == null || !isPostVisibleTo(post, actingAccount.account.actor) - ) { - throw new InvalidInputError("replyTargetId"); - } - replyTarget = post; - } - let quotedPost: schema.Post & { actor: schema.Actor } | undefined; - if (quotedPostId != null) { - const post = await ctx.db.query.postTable.findFirst({ - with: { - actor: { - with: { - followers: { - where: { followerId: actingAccount.account.actor.id }, - }, - blockees: { - where: { blockeeId: actingAccount.account.actor.id }, - }, - blockers: { - where: { blockerId: actingAccount.account.actor.id }, - }, - }, - }, - mentions: { where: { actorId: actingAccount.account.actor.id } }, - sharedPost: { - with: { - actor: { - with: { - followers: { - where: { followerId: actingAccount.account.actor.id }, - }, - blockees: { - where: { blockeeId: actingAccount.account.actor.id }, - }, - blockers: { - where: { blockerId: actingAccount.account.actor.id }, - }, - }, - }, - mentions: { - where: { actorId: actingAccount.account.actor.id }, - }, - }, - }, - }, - where: { id: quotedPostId.id }, - }); - if ( - post == null || !isPostVisibleTo(post, actingAccount.account.actor) - ) { - throw new InvalidInputError("quotedPostId"); - } - const effectivePost = post.sharedPost ?? post; - if (effectivePost.sharedPostId != null) { - throw new InvalidInputError("quotedPostId"); - } - if (!isPostVisibleTo(effectivePost, actingAccount.account.actor)) { - throw new InvalidInputError("quotedPostId"); - } - // Neither a censored post nor a censored share wrapper can be - // quoted; the model revalidates, but the submitted row is - // unwrapped here, so the wrapper must be checked here too. - if (post.censored != null || effectivePost.censored != null) { - throw new InvalidInputError("quotedPostId"); - } - if ( - !canActorRequestQuotePost(effectivePost, actingAccount.account.actor) - ) { - throw new InvalidInputError("quotedPostId"); - } - quotedPost = effectivePost; - } - return await withTransaction(ctx.fedCtx, async (context) => { - const noteMedia = await Promise.all( - attachedMedia.map(async (medium, i) => { - const alt = medium.alt.trim(); - if (alt === "") throw new InvalidInputError(`media.${i}.alt`); - const storedMedium = await context.db.query.mediumTable - .findFirst({ - where: { id: medium.mediumId }, - }); - if (storedMedium == null) { - throw new InvalidInputError(`media.${i}.mediumId`); - } - return { mediumId: medium.mediumId, alt }; - }), - ); - const modelVisibility = visibility === "PUBLIC" - ? "public" - : visibility === "UNLISTED" - ? "unlisted" - : visibility === "FOLLOWERS" - ? "followers" - : visibility === "DIRECT" - ? "direct" - : visibility === "NONE" - ? "none" - : assertNever( - visibility, - `Unknown value in Post.visibility: "${visibility}"`, - ); - let question: Awaited>; - await assertActingAccountNotSuspended( - ctx.db, - authenticatedAccountId, - actingAccount.account.id, - ); - try { - question = await createQuestion( - context, - { - accountId: actingAccount.account.id, - visibility: modelVisibility, - quotePolicy: normalizeQuotePolicyForVisibility( - modelVisibility, - quotePolicy == null ? undefined : fromQuotePolicy(quotePolicy), - ), - content, - language: language.baseName, - media: noteMedia, - poll: { - title: poll.title, - multiple: poll.multiple, - options: poll.options, - ends: poll.ends, - }, - }, - { replyTarget, quotedPost }, - { - afterPostCreated: (post, db) => - recordPostActingAccount(db, post.id, actingAccount), - }, - ); - } catch (error) { - if (error instanceof QuotePolicyDeniedError) { - throw new InvalidInputError("quotedPostId"); - } - if (error instanceof InvalidPollInputError) { - throw new InvalidInputError(error.inputPath); - } - throw error; - } - if (question == null) { - throw createGraphQLError("Failed to create question.", { - originalError: new Error("Failed to create question."), - extensions: { code: "INTERNAL_SERVER_ERROR" }, - }); - } - return question; - }); - }, - }, - { - outputFields: (t) => ({ - question: t.field({ - type: Question, - description: "The newly published `Question` post.", - resolve(result) { - return result; - }, - }), - }), - }, -); - -builder.relayMutationField( - "updateNote", - { - description: - "Edit the content, language, or quote policy of an existing local " + - "note. Visibility cannot be changed after creation. Only the note's " + - "author may call this. Pass `actingAccountId` when editing a note " + - "authored by an organization account you belong to. Sends an " + - "ActivityPub `Update` activity to the appropriate recipients. " + - "Requires authentication.", - inputFields: (t) => ({ - noteId: t.globalID({ - for: [Note], - required: true, - description: "Global ID of the note to update.", - }), - actingAccountId: t.globalID({ - for: [Account], - required: false, - description: actingAccountIdArgDescription, - }), - content: t.field({ - type: "Markdown", - required: false, - description: "New Markdown body. Omit to keep the existing content.", - }), - language: t.field({ - type: "Locale", - required: false, - description: "New language. Omit to keep the existing language.", - }), - quotePolicy: t.field({ - type: QuotePolicy, - required: false, - description: "New quote policy. Omit to keep the existing policy.", - }), - }), - }, - { - errors: { - types: [ - NotAuthenticatedError, - InvalidInputError, - OrganizationPermissionError, - ], - }, - async resolve(_root, args, ctx) { - if (ctx.account == null) throw new NotAuthenticatedError(); - - const post = await ctx.db.query.postTable.findFirst({ - where: { id: args.input.noteId.id }, - with: { noteSource: true }, - }); - if (post?.noteSource == null) throw new InvalidInputError("noteId"); - await resolvePostManagementActingAccount( - ctx, - args.input, - post.noteSource.accountId, - "noteId", - ); - - const patch = { - ...(args.input.content != null ? { content: args.input.content } : {}), - ...(args.input.language != null - ? { language: args.input.language.baseName } - : {}), - ...(args.input.quotePolicy != null - ? { quotePolicy: fromQuotePolicy(args.input.quotePolicy) } - : {}), - }; - if (Object.keys(patch).length === 0) { - throw new InvalidInputError("input"); - } - const updated = await updateNote(ctx.fedCtx, post.noteSource.id, patch); - if (updated == null) throw new InvalidInputError("noteId"); - return updated; - }, - }, - { - outputFields: (t) => ({ - note: t.field({ - type: Note, - description: "The note after the update has been applied.", - resolve: (post) => post, - }), - }), - }, -); - -builder.relayMutationField( - "saveArticleDraft", - { - description: - "Create or update an article draft. Omit `id` to create a new draft. " + - "Requires authentication.", - inputFields: (t) => ({ - id: t.globalID({ for: [ArticleDraft], required: false }), - uuid: t.field({ - type: "UUID", - required: false, - description: "Draft UUID to use when creating a new draft.", - }), - title: t.string({ required: true }), - content: t.field({ type: "Markdown", required: true }), - tags: t.stringList({ required: true }), - }), - }, - { - errors: { - types: [ - NotAuthenticatedError, - InvalidInputError, - ], - }, - async resolve(_root, args, ctx) { - const session = await ctx.session; - if (session == null) { - throw new NotAuthenticatedError(); - } - const { id, title, content, tags } = args.input; - if (id != null && args.input.uuid != null) { - throw new InvalidInputError("uuid"); - } - - const draft = await updateArticleDraft(ctx.db, { - id: id?.id ?? args.input.uuid ?? generateUuidV7(), - accountId: session.accountId, - title, - content, - tags, - }); - if (draft == null) { - throw new InvalidInputError(args.input.uuid == null ? "id" : "uuid"); - } - - return draft; - }, - }, - { - outputFields: (t) => ({ - draft: t.field({ - type: ArticleDraft, - resolve(result) { - return result; - }, - }), - }), - }, -); - -builder.relayMutationField( - "deleteArticleDraft", - { - description: - "Permanently delete an article draft. Only the draft's owner may " + - "delete it. Requires authentication.", - inputFields: (t) => ({ - id: t.globalID({ for: [ArticleDraft], required: true }), - }), - }, - { - errors: { - types: [ - NotAuthenticatedError, - InvalidInputError, - ], - }, - async resolve(_root, args, ctx) { - const session = await ctx.session; - if (session == null) { - throw new NotAuthenticatedError(); - } - - const deleted = await deleteArticleDraft( - ctx.db, - session.accountId, - args.input.id.id, - ); - - if (!deleted) { - throw new InvalidInputError("id"); - } - - return { deletedDraftId: args.input.id.id }; - }, - }, - { - outputFields: (t) => ({ - deletedDraftId: t.globalID({ - resolve(result) { - return { type: "ArticleDraft", id: result.deletedDraftId }; - }, - }), - }), - }, -); - -builder.relayMutationField( - "deletePost", - { - description: "Delete a post and send an ActivityPub `Delete` activity to " + - "federated instances. Boost wrappers cannot be deleted this way; " + - "use `unsharePost` instead (returns `SharedPostDeletionNotAllowedError`). " + - "Pass `actingAccountId` to delete a post authored by an organization " + - "account you belong to.", - inputFields: (t) => ({ - id: t.globalID({ - for: [Note, Article, Question], - required: true, - description: "Global ID of the post to delete.", - }), - actingAccountId: t.globalID({ - for: [Account], - required: false, - description: actingAccountIdArgDescription, - }), - }), - }, - { - errors: { - types: [ - NotAuthenticatedError, - InvalidInputError, - SharedPostDeletionNotAllowedError, - OrganizationPermissionError, - ], - }, - async resolve(_root, args, ctx) { - if (ctx.account == null) { - throw new NotAuthenticatedError(); - } - - const post = await ctx.db.query.postTable.findFirst({ - with: { actor: true, replyTarget: true }, - where: { id: args.input.id.id }, - }); - - if (post == null || post.actor.accountId == null) { - throw new InvalidInputError("id"); - } - await resolvePostManagementActingAccount( - ctx, - args.input, - post.actor.accountId, - "id", - ); - - if (post.sharedPostId != null) { - throw new SharedPostDeletionNotAllowedError("id"); - } - - await deletePost(ctx.fedCtx, post); - - return { deletedPostId: args.input.id }; - }, - }, - { - outputFields: (t) => ({ - deletedPostId: t.globalID({ - resolve(result) { - return { - type: result.deletedPostId.typename, - id: result.deletedPostId.id, - }; - }, - }), - }), - }, -); - -builder.relayMutationField( - "revokeQuote", - { - description: - "As the quoted post's author, revoke permission for a quote of " + - "your post. Sends a revocation activity to the quoting instance. " + - "Only the `quotedPost`'s author may call this. Pass `actingAccountId` " + - "when the quoted post was authored by an organization account you " + - "belong to.", - inputFields: (t) => ({ - quotePostId: t.globalID({ - for: [Note, Article, Question], - required: true, - description: "Global ID of the quote post to revoke.", - }), - actingAccountId: t.globalID({ - for: [Account], - required: false, - description: actingAccountIdArgDescription, - }), - }), - }, - { - errors: { - types: [ - NotAuthenticatedError, - InvalidInputError, - OrganizationPermissionError, - ], - }, - async resolve(_root, args, ctx) { - if (ctx.account == null) { - throw new NotAuthenticatedError(); - } - const actingAccount = await resolveActingAccountForMutation( - ctx, - args.input, - ); - const quote = await ctx.db.query.postTable.findFirst({ - with: { - actor: true, - quotedPost: true, - }, - where: { id: args.input.quotePostId.id }, - }); - if (quote?.quotedPost == null) { - throw new InvalidInputError("quotePostId"); - } - if (quote.quotedPost.actorId !== actingAccount.actor.id) { - throw new InvalidInputError("quotePostId"); - } - if ( - quote.actor.accountId == null && quote.quoteAuthorizationIri == null - ) { - throw new InvalidInputError("quotePostId"); - } - const updatedQuote = await withTransaction( - ctx.fedCtx, - async (context) => - await revokeQuoteModel( - context, - actingAccount, - quote, - quote.quotedPost!, - ), - ); - const quotedPost = await ctx.db.query.postTable.findFirst({ - where: { id: quote.quotedPost.id }, - }); - if (quotedPost == null) throw new InvalidInputError("quotePostId"); - return { quote: updatedQuote, quotedPost }; - }, - }, - { - outputFields: (t) => ({ - quote: t.field({ - type: Post, - resolve(result) { - return result.quote; - }, - }), - quotedPost: t.field({ - type: Post, - resolve(result) { - return result.quotedPost; - }, - }), - }), - }, -); - -builder.relayMutationField( - "publishArticleDraft", - { - description: - "Publish an article draft, converting it to a live `Article` post " + - "and deleting the draft. Sends an ActivityPub `Create` activity. " + - "Requires authentication.", - inputFields: (t) => ({ - id: t.globalID({ for: [ArticleDraft], required: true }), - slug: t.string({ required: true }), - language: t.field({ type: "Locale", required: true }), - allowLlmTranslation: t.boolean({ required: false }), - quotePolicy: t.field({ type: QuotePolicy, required: false }), - actingAccountId: t.globalID({ - for: Account, - required: false, - description: - "Optional `Account` id to publish as. The draft must still be " + - "owned by the authenticated personal account; this only changes " + - "the published article's author.", - }), - attributionMode: t.field({ - type: PostAttributionMode, - required: false, - description: - "How to display the personal member when `actingAccountId` is " + - "an organization. Defaults to `ACTING_ACCOUNT_ONLY`; invalid " + - "when publishing as a personal account.", - }), - }), - }, - { - errors: { - types: [ - NotAuthenticatedError, - InvalidInputError, - ActorSuspendedError, - OrganizationPermissionError, - ], - }, - async resolve(_root, args, ctx) { - const session = await ctx.session; - if (session == null || ctx.account == null) { - throw new NotAuthenticatedError(); - } - const authenticatedAccountId = ctx.account.id; - const actingAccount = await resolvePostActingAccount(ctx, args.input); - - // Get draft - const drafts = await ctx.db - .select() - .from(articleDraftTable) - .where( - and( - eq(articleDraftTable.id, args.input.id.id), - eq(articleDraftTable.accountId, session.accountId), - ), - ) - .limit(1); - const draft = drafts[0]; - - if (!draft) { - throw new InvalidInputError("id"); - } - - const { slug, language, allowLlmTranslation, quotePolicy } = args.input; - - await assertActingAccountNotSuspended( - ctx.db, - authenticatedAccountId, - actingAccount.account.id, - ); - - // Create article from draft - const article = await withTransaction(ctx.fedCtx, async (context) => { - const media = await context.db.query.articleDraftMediumTable - .findMany({ - where: { articleDraftId: draft.id }, - }); - const created = await createArticle(context, { - accountId: actingAccount.account.id, - publishedYear: new Date().getFullYear(), - slug, - tags: draft.tags, - allowLlmTranslation: allowLlmTranslation ?? true, - quotePolicy: quotePolicy == null - ? "everyone" - : fromQuotePolicy(quotePolicy), - title: draft.title, - content: draft.content, - language: language.baseName, - media, - }, { - afterPostCreated: (post, db) => - recordPostActingAccount(db, post.id, actingAccount), - }); - return created; - }); - - if (!article) { - throw createGraphQLError("Failed to publish article.", { - originalError: new Error("Failed to publish article."), - extensions: { code: "INTERNAL_SERVER_ERROR" }, - }); - } - // Delete draft after successful publish - await deleteArticleDraft(ctx.db, session.accountId, draft.id); - - return { article, deletedDraftId: draft.id }; - }, - }, - { - outputFields: (t) => ({ - article: t.field({ - type: Article, - resolve(result) { - return result.article; - }, - }), - deletedDraftId: t.globalID({ - resolve(result) { - return { type: "ArticleDraft", id: result.deletedDraftId }; - }, - }), - }), - }, -); - -builder.drizzleObjectField( - Reaction, - "post", - (t) => t.relation("post", { type: Post }), -); - -builder.relayMutationField( - "addReactionToPost", - { - description: - "Add an emoji reaction to a post. Sends an ActivityPub `Like` " + - "or `EmojiReact` activity. Idempotent: adding the same emoji twice " + - "has no effect. Exactly one of `emoji` or `customEmojiId` must be " + - "provided. Requires authentication.", - inputFields: (t) => ({ - postId: t.globalID({ - for: [Note, Article, Question], - required: true, - }), - actingAccountId: t.globalID({ - for: Account, - required: false, - description: - "Optional `Account` id to react as. Omit to react as the " + - "authenticated personal account; pass an organization account " + - "where the viewer is an accepted member to react as that " + - "organization.", - }), - emoji: t.string({ required: false }), - customEmojiId: t.globalID({ for: CustomEmoji, required: false }), - }), - }, - { - errors: { - types: [ - NotAuthenticatedError, - InvalidInputError, - ActorSuspendedError, - OrganizationPermissionError, - ], - }, - async resolve(_root, args, ctx) { - if (ctx.account == null) { - throw new NotAuthenticatedError(); - } - const authenticatedAccountId = ctx.account.id; - const actingAccount = await resolveActingAccountForMutation( - ctx, - args.input, - ); - - const { postId, emoji, customEmojiId } = args.input; - - if (emoji == null && customEmojiId == null) { - throw new InvalidInputError("emoji"); - } - if (emoji != null && customEmojiId != null) { - throw new InvalidInputError("emoji"); - } - if (emoji != null && !isReactionEmoji(emoji)) { - throw new InvalidInputError("emoji"); - } - - const post = await ctx.db.query.postTable.findFirst({ - with: { - actor: { - with: { - followers: true, - blockees: true, - blockers: true, - }, - }, - replyTarget: { - with: { actor: true }, - }, - // Load the boosted post's author so a wrapper of a sanction-hidden - // actor's post is correctly hidden by isPostVisibleTo (which now - // fails closed when a wrapper's sharedPost is not loaded). - sharedPost: { with: { actor: true } }, - mentions: true, - }, - where: { id: postId.id }, - }); - - if (post == null) { - throw new InvalidInputError("postId"); - } - - if (!isPostVisibleTo(post, actingAccount.actor)) { - throw new InvalidInputError("postId"); - } - - await assertActingAccountNotSuspended( - ctx.db, - authenticatedAccountId, - actingAccount.id, - ); - - const reaction = await react( - ctx.fedCtx, - actingAccount, - post, - emoji as ReactionEmoji | null ?? null, - customEmojiId?.id as Uuid | undefined, - ); - - if (reaction != null) { - return reaction; - } - - const existingReaction = await ctx.db.query.reactionTable.findFirst({ - where: emoji != null - ? { postId: post.id, actorId: actingAccount.actor.id, emoji } - : { - postId: post.id, - actorId: actingAccount.actor.id, - customEmojiId: customEmojiId!.id as Uuid, - }, - }); - - if (existingReaction != null) { - return existingReaction; - } - - throw createGraphQLError("Failed to react to the post.", { - originalError: new Error("Failed to react to the post."), - extensions: { code: "INTERNAL_SERVER_ERROR" }, - }); - }, - }, - { - outputFields: (t) => ({ - reaction: t.drizzleField({ - type: Reaction, - nullable: true, - resolve(_query, result) { - return result; - }, - }), - }), - }, -); - -builder.relayMutationField( - "removeReactionFromPost", - { - description: - "Remove an emoji reaction from a post. Sends an ActivityPub `Undo " + - "Like` activity. Idempotent: removing a reaction that doesn't exist " + - "returns `success: true`. Exactly one of `emoji` or `customEmojiId` " + - "must be provided. Requires authentication.", - inputFields: (t) => ({ - postId: t.globalID({ - for: [Note, Article, Question], - required: true, - }), - actingAccountId: t.globalID({ - for: Account, - required: false, - description: - "Optional `Account` id to remove the reaction as. Omit to use " + - "the authenticated personal account; pass an organization account " + - "where the viewer is an accepted member to remove that " + - "organization's reaction.", - }), - emoji: t.string({ required: false }), - customEmojiId: t.globalID({ for: CustomEmoji, required: false }), - }), - }, - { - errors: { - types: [ - NotAuthenticatedError, - InvalidInputError, - OrganizationPermissionError, - ], - }, - async resolve(_root, args, ctx) { - const actingAccount = await resolveActingAccountForMutation( - ctx, - args.input, - ); - - const { postId, emoji, customEmojiId } = args.input; - - if (emoji == null && customEmojiId == null) { - throw new InvalidInputError("emoji"); - } - if (emoji != null && customEmojiId != null) { - throw new InvalidInputError("emoji"); - } - if (emoji != null && !isReactionEmoji(emoji)) { - throw new InvalidInputError("emoji"); - } - - const post = await ctx.db.query.postTable.findFirst({ - with: { - actor: { - with: { - followers: true, - blockees: true, - blockers: true, - }, - }, - replyTarget: { - with: { actor: true }, - }, - mentions: true, - // A boost shows the wrapper id, so undoing a reaction on it must - // hydrate sharedPost: isPostVisibleTo() fails closed on a wrapper - // whose boosted post was not loaded. - sharedPost: { with: { actor: true } }, - }, - where: { id: postId.id }, - }); - - if (post == null) { - throw new InvalidInputError("postId"); - } - - if (!isPostVisibleTo(post, actingAccount.actor)) { - throw new InvalidInputError("postId"); - } - - await undoReaction( - ctx.fedCtx, - actingAccount, - post, - emoji as ReactionEmoji | null ?? null, - customEmojiId?.id as Uuid | undefined, - ); - - return { success: true }; - }, - }, - { - outputFields: (t) => ({ - success: t.boolean({ - resolve() { - return true; - }, - }), - }), - }, -); - -builder.relayMutationField( - "sharePost", - { - description: - "Boost (reshare) a post by creating a share wrapper post and " + - "sending an ActivityPub `Announce` activity. Returns the wrapper " + - "post as `share` and the original post as `originalPost`. " + - "Requires authentication.", - inputFields: (t) => ({ - postId: t.globalID({ - for: [Note, Article, Question], - required: true, - }), - actingAccountId: t.globalID({ - for: Account, - required: false, - description: - "Optional `Account` id to boost as. Omit to boost as the " + - "authenticated personal account; pass an organization account " + - "where the viewer is an accepted member to boost as that " + - "organization.", - }), - }), - }, - { - errors: { - types: [ - NotAuthenticatedError, - InvalidInputError, - ActorSuspendedError, - OrganizationPermissionError, - ], - }, - async resolve(_root, args, ctx) { - if (ctx.account == null) { - throw new NotAuthenticatedError(); - } - const authenticatedAccountId = ctx.account.id; - const actingAccount = await resolveActingAccountForMutation( - ctx, - args.input, - ); - - const { postId } = args.input; - - const post = await ctx.db.query.postTable.findFirst({ - with: { - actor: { - with: { - followers: true, - blockees: true, - blockers: true, - }, - }, - replyTarget: { - with: { actor: true }, - }, - mentions: true, - sharedPost: { - with: { - actor: { - with: { - followers: true, - blockees: true, - blockers: true, - }, - }, - mentions: true, - }, - }, - }, - where: { id: postId.id }, - }); - - if (post == null) { - throw new InvalidInputError("postId"); - } - - if (!isPostVisibleTo(post, actingAccount.actor)) { - throw new InvalidInputError("postId"); - } - - // Validate sharing eligibility against the effective original post. - // When the submitted post is itself a share wrapper, the sharing rules - // apply to the original post's visibility, not the wrapper's. - // Reject nested wrappers (share-of-share chains) outright; only - // direct originals (sharedPostId == null) are authoritative. - const effectivePost = post.sharedPost ?? post; - if (effectivePost.sharedPostId != null) { - throw new InvalidInputError("postId"); - } - if (!isPostVisibleTo(effectivePost, actingAccount.actor)) { - throw new InvalidInputError("postId"); - } - // A censored post cannot be boosted (by anyone, including its - // author): the wrapper would copy the censored content and - // federate an `Announce` re-amplifying moderation-hidden content. - if (post.censored != null || effectivePost.censored != null) { - throw new InvalidInputError("postId"); - } - if ( - effectivePost.visibility !== "public" && - effectivePost.visibility !== "unlisted" && - !(effectivePost.visibility === "followers" && - effectivePost.actorId === actingAccount.actor.id) - ) { - throw new InvalidInputError("postId"); - } - - await assertActingAccountNotSuspended( - ctx.db, - authenticatedAccountId, - actingAccount.id, - ); - - const share = await sharePost( - ctx.fedCtx, - actingAccount, - post, - ); - - return { - share, - originalPostId: postId.id, - }; - }, - }, - { - outputFields: (t) => ({ - share: t.field({ - type: Post, - resolve(result) { - return result.share; - }, - }), - originalPost: t.drizzleField({ - type: Post, - async resolve(query, result, _args, ctx) { - const post = await ctx.db.query.postTable.findFirst( - query({ where: { id: result.originalPostId } }), - ); - return post!; - }, - }), - }), - }, -); - -builder.relayMutationField( - "unsharePost", - { - description: - "Undo a boost by deleting the share wrapper post and sending an " + - "ActivityPub `Undo Announce` activity. Pass the original post's " + - "`id` (not the wrapper's). Requires authentication.", - inputFields: (t) => ({ - postId: t.globalID({ - for: [Note, Article, Question], - required: true, - }), - actingAccountId: t.globalID({ - for: Account, - required: false, - description: - "Optional `Account` id to undo a boost as. Omit to use the " + - "authenticated personal account; pass an organization account " + - "where the viewer is an accepted member to remove that " + - "organization's boost.", - }), - }), - }, - { - errors: { - types: [ - NotAuthenticatedError, - InvalidInputError, - OrganizationPermissionError, - ], - }, - async resolve(_root, args, ctx) { - const actingAccount = await resolveActingAccountForMutation( - ctx, - args.input, - ); - - const { postId } = args.input; - - const post = await ctx.db.query.postTable.findFirst({ - with: { - actor: { - with: { - followers: true, - blockees: true, - blockers: true, - }, - }, - replyTarget: { - with: { actor: true }, - }, - mentions: true, - // Hydrate sharedPost so a boost wrapper passed by id is judged by - // isPostVisibleTo() instead of failing closed on the unloaded - // boosted post. - sharedPost: { with: { actor: true } }, - }, - where: { id: postId.id }, - }); - - if (post == null) { - throw new InvalidInputError("postId"); - } - - if (!isPostVisibleTo(post, actingAccount.actor)) { - throw new InvalidInputError("postId"); - } - - const unshared = await unsharePost( - ctx.fedCtx, - actingAccount, - post, - ); - - if (unshared == null) { - throw new InvalidInputError("postId"); - } - - return { success: true, originalPostId: postId.id }; - }, - }, - { - outputFields: (t) => ({ - originalPost: t.drizzleField({ - type: Post, - async resolve(query, result, _args, ctx) { - const post = await ctx.db.query.postTable.findFirst( - query({ where: { id: result.originalPostId } }), - ); - return post!; - }, - }), - }), - }, -); - -builder.relayMutationField( - "bookmarkPost", - { - description: - "Save a post to the viewer's bookmarks. Bookmarks are private and " + - "not federated. Idempotent. Requires authentication.", - inputFields: (t) => ({ - postId: t.globalID({ - for: [Note, Article, Question], - required: true, - }), - }), - }, - { - errors: { - types: [ - NotAuthenticatedError, - InvalidInputError, - ], - }, - async resolve(_root, args, ctx) { - if (ctx.account == null) { - throw new NotAuthenticatedError(); - } - - const { postId } = args.input; - - const post = await ctx.db.query.postTable.findFirst({ - with: { - actor: { - with: { - followers: true, - blockees: true, - blockers: true, - }, - }, - mentions: true, - // A boost shows the wrapper id, so bookmarking one must hydrate - // sharedPost: isPostVisibleTo() fails closed on a wrapper whose - // boosted post was not loaded. - sharedPost: { with: { actor: true } }, - }, - where: { id: postId.id }, - }); - - if (post == null) { - throw new InvalidInputError("postId"); - } - - if (!isPostVisibleTo(post, ctx.account.actor)) { - throw new InvalidInputError("postId"); - } - - await createBookmark(ctx.db, ctx.account, post); - - return { postId: postId.id }; - }, - }, - { - outputFields: (t) => ({ - post: t.drizzleField({ - type: Post, - async resolve(query, result, _args, ctx) { - const post = await ctx.db.query.postTable.findFirst( - query({ where: { id: result.postId } }), - ); - return post!; - }, - }), - }), - }, -); - -builder.relayMutationField( - "unbookmarkPost", - { - description: - "Remove a post from the viewer's bookmarks. Idempotent. Requires authentication.", - inputFields: (t) => ({ - postId: t.globalID({ - for: [Note, Article, Question], - required: true, - }), - }), - }, - { - errors: { - types: [ - NotAuthenticatedError, - InvalidInputError, - ], - }, - async resolve(_root, args, ctx) { - if (ctx.account == null) { - throw new NotAuthenticatedError(); - } - - const { postId } = args.input; - - const alreadyBookmarked = - (await arePostsBookmarkedBy(ctx.db, [postId.id], ctx.account)) - .has(postId.id); - - const post = await ctx.db.query.postTable.findFirst({ - with: { - actor: { - with: { - followers: true, - blockees: true, - blockers: true, - }, - }, - mentions: true, - // Mirror `bookmarkPost`: a boost wrapper's boosted post must be - // hydrated so `isPostVisibleTo` does not fail closed on it. - sharedPost: { with: { actor: true } }, - }, - where: { id: postId.id }, - }); - - if (post == null) { - throw new InvalidInputError("postId"); - } - - // Removing an existing bookmark is always allowed, even if the post has - // since become invisible to the viewer (they bookmarked it while it was - // visible). The visibility gate applies only when there is no bookmark - // to remove, so this mutation cannot be used as an oracle to probe a - // post the viewer cannot see via its `post` output field - // (`deleteBookmark` is otherwise a silent no-op). - if (!alreadyBookmarked && !isPostVisibleTo(post, ctx.account.actor)) { - throw new InvalidInputError("postId"); - } - - await deleteBookmark(ctx.db, ctx.account, post); - - return { postId: postId.id, unbookmarkedPostId: postId }; - }, - }, - { - outputFields: (t) => ({ - post: t.drizzleField({ - type: Post, - // Nullable and visibility-gated: removing an owned bookmark is - // allowed even after the post became invisible to the viewer, but - // the payload must not then re-expose that post's content. Returns - // `null` when the post is no longer visible; the client can still - // reconcile its cache from `unbookmarkedPostId`. - nullable: true, - async resolve(query, result, _args, ctx) { - const viewerActorId = ctx.account?.actor.id ?? null; - if (!await isPostVisibleToViewer(ctx, result.postId, viewerActorId)) { - return null; - } - const post = await ctx.db.query.postTable.findFirst( - query({ where: { id: result.postId } }), - ); - return post ?? null; - }, - }), - unbookmarkedPostId: t.globalID({ - resolve(result) { - return { - type: result.unbookmarkedPostId.typename, - id: result.unbookmarkedPostId.id, - }; - }, - }), - }), - }, -); - -builder.relayMutationField( - "pinPost", - { - description: - "Pin a post to the top of the viewer's profile. Only the post's " + - "author may pin it. Pass `actingAccountId` to pin an organization " + - "post to that organization's profile. Requires authentication.", - inputFields: (t) => ({ - postId: t.globalID({ - for: [Note, Article, Question], - required: true, - description: "Global ID of the post to pin.", - }), - actingAccountId: t.globalID({ - for: [Account], - required: false, - description: actingAccountIdArgDescription, - }), - }), - }, - { - errors: { - types: [ - NotAuthenticatedError, - InvalidInputError, - OrganizationPermissionError, - ], - }, - async resolve(_root, args, ctx) { - const actingAccount = await resolveActingAccountForMutation( - ctx, - args.input, - ); - - const { postId } = args.input; - - const post = await ctx.db.query.postTable.findFirst({ - where: { id: postId.id }, - }); - - if (post == null) { - throw new InvalidInputError("postId"); - } - - const pin = await pinPostModel(ctx.fedCtx, actingAccount.actor, post); - if (pin == null) { - throw new InvalidInputError("postId"); - } - - return { postId: postId.id }; - }, - }, - { - outputFields: (t) => ({ - post: t.drizzleField({ - type: Post, - async resolve(query, result, _args, ctx) { - const post = await ctx.db.query.postTable.findFirst( - query({ where: { id: result.postId } }), - ); - return post!; - }, - }), - }), - }, -); - -builder.relayMutationField( - "unpinPost", - { - description: - "Remove a pin from the viewer's profile. Pass `actingAccountId` to " + - "remove a pin from an organization profile. Requires authentication.", - inputFields: (t) => ({ - postId: t.globalID({ - for: [Note, Article, Question], - required: true, - description: "Global ID of the post to unpin.", - }), - actingAccountId: t.globalID({ - for: [Account], - required: false, - description: actingAccountIdArgDescription, - }), - }), - }, - { - errors: { - types: [ - NotAuthenticatedError, - InvalidInputError, - OrganizationPermissionError, - ], - }, - async resolve(_root, args, ctx) { - const actingAccount = await resolveActingAccountForMutation( - ctx, - args.input, - ); - - const { postId } = args.input; - - const post = await ctx.db.query.postTable.findFirst({ - where: { id: postId.id }, - }); - - if (post == null) { - throw new InvalidInputError("postId"); - } - - const pin = await unpinPostModel(ctx.fedCtx, actingAccount.actor, post); - if (pin == null) { - throw new InvalidInputError("postId"); - } - - return { postId: postId.id, unpinnedPostId: postId }; - }, - }, - { - outputFields: (t) => ({ - post: t.drizzleField({ - type: Post, - async resolve(query, result, _args, ctx) { - const post = await ctx.db.query.postTable.findFirst( - query({ where: { id: result.postId } }), - ); - return post!; - }, - }), - unpinnedPostId: t.globalID({ - resolve(result) { - return { - type: result.unpinnedPostId.typename, - id: result.unpinnedPostId.id, - }; - }, - }), - }), - }, -); - -builder.queryField("articleDraft", (t) => - t.field({ - type: ArticleDraft, - nullable: true, - description: "Look up an article draft by its global `id` or its `uuid`. " + - "Requires authentication; only returns drafts owned by the " + - "authenticated viewer.", - args: { - id: t.arg.globalID({ for: [ArticleDraft], required: false }), - uuid: t.arg({ type: "UUID", required: false }), - }, - async resolve(_root, args, ctx) { - if (ctx.account == null) return null; - - // At least one of id or uuid must be provided - if (!args.id && !args.uuid) { - throw createGraphQLError("Either id or uuid must be provided.", { - extensions: { code: "BAD_USER_INPUT" }, - }); - } - - // Use uuid if provided, otherwise use id - const draftId = args.uuid ?? args.id!.id; - - const drafts = await ctx.db - .select() - .from(articleDraftTable) - .where( - and( - eq(articleDraftTable.id, draftId), - eq(articleDraftTable.accountId, ctx.account.id), - ), - ) - .limit(1); - - return drafts[0] ?? null; - }, - })); - -builder.queryField("postByUrl", (t) => - t.field({ - type: Post, - nullable: true, - description: - "Resolve a post by its URL, fetching it from the originating " + - "instance via ActivityPub if it is not already cached. Requires " + - "authentication (unauthenticated callers always receive `null`). " + - "Returns `null` if the post is not found or not visible to the " + - "selected viewer account. Pass `actingAccountId` when validating a " + - "quote target for an organization account.", - args: { - url: t.arg.string({ required: true }), - actingAccountId: t.arg.id({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - async resolve(_root, args, ctx) { - if (ctx.account == null) return null; - const parsed = parseHttpUrl(args.url.trim()); - if (parsed == null) return null; - const actingAccount = await resolveActingAccountForGlobalIdArg( - ctx, - args, - ); - const viewerActor = actingAccount.actor; - const looked = await lookupPostByUrl(ctx, parsed); - if (looked == null) return null; - const postId = looked.id; - const withRelations = { - actor: { - with: { - followers: { - where: { followerId: viewerActor.id }, - }, - blockees: { - where: { blockeeId: viewerActor.id }, - }, - blockers: { - where: { blockerId: viewerActor.id }, - }, - }, - }, - mentions: true, - // A boost URL resolves to the wrapper, so hydrate sharedPost: - // isPostVisibleTo() fails closed on a wrapper whose boosted post was - // not loaded (and this keeps a boost of a sanction-hidden author - // hidden here too). - sharedPost: { with: { actor: true } }, - } as const; - const post = await ctx.db.query.postTable.findFirst({ - with: withRelations, - where: { id: postId }, - }); - if (post == null) return null; - if (!isPostVisibleTo(post, viewerActor)) return null; - return post; - }, - })); - -builder.queryField("articleByYearAndSlug", (t) => - t.drizzleField({ - type: Article, - nullable: true, - description: "Look up a locally-authored article by the author's handle, " + - "publication year, and URL slug. This is the resolver for the " + - "canonical article permalink path `/@{handle}/{year}/{slug}`.", - args: { - handle: t.arg.string({ required: true }), - idOrYear: t.arg.string({ required: true }), - slug: t.arg.string({ required: true }), - actingAccountId: t.arg.id({ - required: false, - description: actingAccountIdArgDescription, - }), - }, - async resolve(query, _, args, ctx) { - if (!/^\d+$/.test(args.idOrYear)) return null; - const year = parseInt(args.idOrYear, 10); - if (!Number.isFinite(year)) return null; - - let handle = args.handle; - if (handle.startsWith("@")) handle = handle.substring(1); - const split = handle.split("@"); - - let actor; - if (split.length === 2) { - const [username, host] = split; - actor = await ctx.db.query.actorTable.findFirst({ - where: { - username, - OR: [{ instanceHost: host }, { handleHost: host }], - }, - }); - } else if (split.length === 1) { - actor = await ctx.db.query.actorTable.findFirst({ - where: { username: split[0], accountId: { isNotNull: true } }, - }); - } - if (actor == null) return null; - - // Only local actors have articles with sources - if (actor.accountId == null) return null; - - const account = await ctx.db.query.accountTable.findFirst({ - where: { id: actor.accountId }, - }); - if (account == null) return null; - - const source = await ctx.db.query.articleSourceTable.findFirst({ - where: { - accountId: account.id, - publishedYear: year, - slug: args.slug, - }, - }); - if (source == null) return null; - - const viewerActorId = await resolveViewerActorId(ctx, args); - const viewerActor = viewerActorId == null - ? null - : await getActorById(ctx, viewerActorId); - const visibility = getPostVisibilityFilter(viewerActor); - return await ctx.db.query.postTable.findFirst( - query({ - where: { - AND: [ - { - type: "Article", - actorId: actor.id, - articleSourceId: source.id, - }, - visibility, - ], - }, - }), - ) ?? null; - }, - })); - -const UpdateArticleMediumInput = builder.inputType("UpdateArticleMediumInput", { - fields: (t) => ({ - mediumId: t.field({ - type: "UUID", - required: true, - description: "UUID of a Medium to make available to the article source.", - }), - key: t.string({ - required: false, - description: - "Key used in article markdown as hp-medium:KEY. Defaults to mediumId.", - }), - }), -}); - -builder.relayMutationField( - "updateArticle", - { - description: - "Edit an existing article's content, title, or tags. Only the " + - "article's author may update it. Sends an ActivityPub `Update` " + - "activity. Pass `actingAccountId` when updating an article authored " + - "by an organization account you belong to. Requires authentication.", - inputFields: (t) => ({ - articleId: t.globalID({ - for: [Article], - required: true, - description: "Global ID of the article to update.", - }), - actingAccountId: t.globalID({ - for: [Account], - required: false, - description: actingAccountIdArgDescription, - }), - title: t.string({ required: false }), - content: t.field({ type: "Markdown", required: false }), - tags: t.stringList({ required: false }), - language: t.field({ type: "Locale", required: false }), - allowLlmTranslation: t.boolean({ required: false }), - media: t.field({ - type: [UpdateArticleMediumInput], - required: false, - description: - "Media to make available to hp-medium:KEY references in the updated article markdown.", - }), - }), - }, - { - errors: { - types: [ - NotAuthenticatedError, - InvalidInputError, - OrganizationPermissionError, - ], - }, - async resolve(_root, args, ctx) { - if (ctx.account == null) { - throw new NotAuthenticatedError(); - } - - const articleId = args.input.articleId.id; - // Find the post and its articleSource - const post = await ctx.db.query.postTable.findFirst({ - where: { id: articleId }, - with: { articleSource: true }, - }); - if (post == null || post.articleSource == null) { - throw new InvalidInputError("articleId"); - } - - await resolvePostManagementActingAccount( - ctx, - args.input, - post.articleSource.accountId, - "articleId", - ); - - const media: { key: string; mediumId: Uuid }[] = []; - for (const [i, mediumInput] of (args.input.media ?? []).entries()) { - const medium = await ctx.db.query.mediumTable.findFirst({ - where: { id: mediumInput.mediumId }, - }); - if (medium == null) { - throw new InvalidInputError(`media.${i}.mediumId`); - } - const key = mediumInput.key?.trim() || medium.id; - if (!key.match(/^[A-Za-z0-9._:/-]+$/)) { - throw new InvalidInputError(`media.${i}.key`); - } - media.push({ key, mediumId: medium.id }); - } - - let updated; - try { - updated = await updateArticle(ctx.fedCtx, post.articleSource.id, { - title: args.input.title ?? undefined, - content: args.input.content ?? undefined, - tags: args.input.tags ?? undefined, - language: args.input.language?.baseName ?? undefined, - allowLlmTranslation: args.input.allowLlmTranslation ?? undefined, - media: args.input.media == null ? undefined : media, - }); - } catch (e) { - if (e instanceof LanguageChangeWithTranslationsError) { - throw new InvalidInputError("language"); - } - throw e; - } - if (updated == null) { - throw new InvalidInputError("articleId"); - } - - return updated; - }, - }, - { - outputFields: (t) => ({ - article: t.field({ - type: Article, - resolve: (post) => post, - }), - }), - }, -); - -builder.relayMutationField( - "requestArticleTranslation", - { - description: - "Request an LLM translation of an article into the given target " + - "language. Returns immediately; the translated `ArticleContent` " + - "appears asynchronously (check `beingTranslated`). Returns " + - "`LlmTranslationNotAllowedError` if translation is disabled on " + - "the article or the target language matches the source language.", - inputFields: (t) => ({ - articleId: t.globalID({ for: [Article], required: true }), - targetLanguage: t.field({ type: "Locale", required: true }), - }), - }, - { - errors: { - types: [ - NotAuthenticatedError, - InvalidInputError, - LlmTranslationNotAllowedError, - ], - }, - async resolve(_root, args, ctx) { - const session = await ctx.session; - if (session == null || ctx.account == null) { - throw new NotAuthenticatedError(); - } - - const post = await ctx.db.query.postTable.findFirst({ - where: { id: args.input.articleId.id }, - with: { - actor: { - with: { - followers: true, - blockees: true, - blockers: true, - }, - }, - mentions: true, - articleSource: { - with: { contents: true }, - }, - }, - }); - if ( - post == null || - post.type !== "Article" || - post.articleSource == null || - !isPostVisibleTo(post, ctx.account.actor) - ) { - throw new InvalidInputError("articleId"); - } - if (!post.articleSource.allowLlmTranslation) { - throw new LlmTranslationNotAllowedError("DISABLED"); - } - const original = getOriginalArticleContent(post.articleSource); - if (original == null) { - throw new InvalidInputError("articleId"); - } - // The `Locale` scalar accepts any syntactically valid BCP 47 - // tag, but the `[lang]` route only serves locales that pass - // `normalizeLocale` (i.e. the `POSSIBLE_LOCALES` whitelist - // used across the project). Run the same check here so an - // API client cannot enqueue a translation for a tag the - // canonical article URL flow will never display. - const targetLanguage = normalizeLocale( - args.input.targetLanguage.baseName, - ); - if (targetLanguage == null) { - throw new InvalidInputError("targetLanguage"); - } - // Reject targets that share the source's *language and script* - // subtags after maximization (so `en` -> `en-US` and `ko` -> - // `ko-KR` are blocked because they both maximize to the same - // `language`+`script` pair, but `zh-CN` -> `zh-TW` is allowed - // because Simplified vs Traditional script genuinely produces a - // different translation output). `Article.contents` negotiates - // among available locales rather than requiring exact tags, so - // permitting a same-script variant would create a redundant - // placeholder row whose canonical URL would negotiate back to - // the existing source content and leave the newly inserted row - // unreachable; a different-script variant has its own canonical - // URL slot in the negotiation result. - const targetMax = new Intl.Locale(targetLanguage).maximize(); - const originalMax = new Intl.Locale(original.language).maximize(); - if ( - targetMax.language === originalMax.language && - targetMax.script === originalMax.script - ) { - throw new LlmTranslationNotAllowedError("SAME_LANGUAGE"); - } - - // Skip enqueueing if a *completed* translation for the target - // locale already exists. `startArticleContentTranslation` is - // already idempotent against this case (it returns early without - // calling the translator), but checking here against the - // already-fetched `articleSource.contents` lets the resolver - // avoid the extra DB round-trip and makes the no-op intent - // explicit at the resolver layer. In-progress and stale rows - // are intentionally not short-circuited here so the model layer - // can re-queue them per its 30-minute staleness window. - const alreadyTranslated = post.articleSource.contents.some( - (c) => - !c.beingTranslated && normalizeLocale(c.language) === targetLanguage, - ); - if (!alreadyTranslated) { - await startArticleContentTranslation(ctx.fedCtx, { - content: original, - targetLanguage, - requester: ctx.account, - }); - } - - return post; - }, - }, - { - outputFields: (t) => ({ - article: t.field({ - type: Article, - resolve: (post) => post, - }), - }), - }, -); - -builder.relayMutationField( - "createMedium", - { - description: - "Import a media object from a remote URL and store it locally. " + - "Use this instead of the two-step `startMediumUpload` / " + - "`finishMediumUpload` flow when the image is already accessible " + - "via URL. Requires authentication.", - inputFields: (t) => ({ - url: t.field({ - type: "URL", - required: true, - description: - "Image URL to import. Data URLs, HTTP, and HTTPS are supported.", - }), - }), - }, - { - errors: { - types: [ - NotAuthenticatedError, - InvalidInputError, - ], - }, - async resolve(_root, args, ctx) { - const session = await ctx.session; - if (session == null) { - throw new NotAuthenticatedError(); - } - let medium: schema.Medium | undefined; - try { - medium = await createMediumFromUrl( - ctx.db, - ctx.disk, - args.input.url, - { userAgentUrl: new URL(ctx.fedCtx.canonicalOrigin) }, - ); - } catch (error) { - if (!(error instanceof UnsafeMediumUrlError)) throw error; - } - if (medium == null) { - throw new InvalidInputError("url"); - } - // Record the importing account as the medium owner during the upload - // window. Without this, content-hash deduplication can return a row - // owned by another account, and downstream owner-gated operations - // (e.g. `attachArticleSourceMedium`) would then reject this user - // even though they performed the import themselves. Mirrors the - // ownership marker that `finishMediumUpload` sets. - await setMediumOwner(ctx.kv, medium.id, session.accountId); - return medium; - }, - }, - { - outputFields: (t) => ({ - medium: t.field({ - type: Medium, - resolve(result) { - return result; - }, - }), - }), - }, -); - -interface MediumUploadStart { - uploadId: Uuid; - uploadUrl: URL; - method: string; - headers: { name: string; value: string }[]; - expires: Date; -} - -builder.relayMutationField( - "startMediumUpload", - { - description: - "Step 1 of direct image upload: obtain a pre-signed URL and headers " + - "for a PUT request. After uploading, call `finishMediumUpload` with " + - "the returned `uploadId`. Requires authentication.", - inputFields: (t) => ({ - contentType: t.field({ - type: "MediaType", - required: true, - description: "Original image content type.", - }), - contentLength: t.int({ - required: true, - description: "Exact number of bytes the client will upload.", - }), - }), - }, - { - errors: { types: [NotAuthenticatedError, InvalidInputError] }, - async resolve(_root, args, ctx) { - const session = await ctx.session; - if (session == null) throw new NotAuthenticatedError(); - if ( - !SUPPORTED_MEDIUM_IMAGE_TYPES.includes( - args.input.contentType as typeof SUPPORTED_MEDIUM_IMAGE_TYPES[number], - ) - ) { - throw new InvalidInputError("contentType"); - } - if ( - args.input.contentLength < 1 || - args.input.contentLength > MAX_STREAMING_MEDIUM_IMAGE_SIZE - ) { - throw new InvalidInputError("contentLength"); - } - const upload = await createMediumUploadSession( - ctx.kv, - session.accountId, - args.input.contentType, - args.input.contentLength, - ); - let uploadUrl: URL; - try { - uploadUrl = new URL( - await ctx.disk.getSignedUploadUrl(upload.key, { - contentType: upload.contentType, - contentSize: upload.contentLength, - contentLength: upload.contentLength, - expiresIn: "30mins", - }), - ); - } catch { - uploadUrl = new URL(`/medium-uploads/${upload.id}`, ctx.request.url); - uploadUrl.searchParams.set("token", upload.token); - } - return { - uploadId: upload.id, - uploadUrl, - method: "PUT", - headers: [{ name: "Content-Type", value: upload.contentType }], - expires: new Date(Date.now() + MEDIUM_UPLOAD_TTL_MS), - } satisfies MediumUploadStart; - }, - }, - { - outputFields: (t) => ({ - uploadId: t.field({ - type: "UUID", - resolve: (result) => result.uploadId, - }), - uploadUrl: t.field({ - type: "URL", - resolve: (result) => result.uploadUrl, - }), - method: t.string({ resolve: (result) => result.method }), - headers: t.field({ - type: [MediumUploadHeader], - resolve: (result) => result.headers, - }), - expires: t.field({ - type: "DateTime", - resolve: (result) => result.expires, - }), - }), - }, -); - -builder.relayMutationField( - "finishMediumUpload", - { - description: "Step 2 of direct image upload: confirm that the PUT to the " + - "pre-signed URL from `startMediumUpload` has completed, returning " + - "the resulting `Medium`. Requires authentication.", - inputFields: (t) => ({ - uploadId: t.field({ type: "UUID", required: true }), - }), - }, - { - errors: { types: [NotAuthenticatedError, InvalidInputError] }, - async resolve(_root, args, ctx) { - const session = await ctx.session; - if (session == null) throw new NotAuthenticatedError(); - const upload = await getMediumUploadSession(ctx.kv, args.input.uploadId); - if (upload == null || upload.accountId !== session.accountId) { - throw new InvalidInputError("uploadId"); - } - try { - let metadata: { contentLength: number }; - try { - metadata = await ctx.disk.getMetaData(upload.key); - } catch { - throw new InvalidInputError("uploadId"); - } - if ( - metadata.contentLength !== upload.contentLength || - metadata.contentLength > MAX_STREAMING_MEDIUM_IMAGE_SIZE - ) { - throw new InvalidInputError("uploadId"); - } - let bytes: Uint8Array; - try { - bytes = await ctx.disk.getBytes(upload.key); - } catch { - throw new InvalidInputError("uploadId"); - } - if (bytes.byteLength !== upload.contentLength) { - throw new InvalidInputError("uploadId"); - } - const medium = await createMediumFromBytes(ctx.db, ctx.disk, bytes, { - maxSize: MAX_STREAMING_MEDIUM_IMAGE_SIZE, - contentType: upload.contentType, - }); - if (medium == null) throw new InvalidInputError("uploadId"); - await setMediumOwner(ctx.kv, medium.id, session.accountId); - return medium; - } finally { - try { - await ctx.disk.delete(upload.key); - } catch (error) { - logger.warn( - "Failed to delete temporary medium upload {key}: {error}", - { - key: upload.key, - error, - }, - ); - } - try { - await deleteMediumUploadSession(ctx.kv, upload.id); - } catch (error) { - logger.warn("Failed to delete medium upload session {id}: {error}", { - id: upload.id, - error, - }); - } - } - }, - }, - { - outputFields: (t) => ({ - medium: t.field({ - type: Medium, - resolve(result) { - return result; - }, - }), - }), - }, -); - -interface AttachedArticleDraftMedium { - key: string; - medium: schema.Medium; -} - -builder.relayMutationField( - "attachArticleDraftMedium", - { - description: - "Associate an uploaded `Medium` with an article draft so it can be " + - "referenced in the draft's Markdown as `hp-medium:{key}`. Must be " + - "called before publishing if the draft's content uses `hp-medium:` " + - "references. Requires authentication.", - inputFields: (t) => ({ - draftId: t.field({ type: "UUID", required: true }), - mediumId: t.field({ type: "UUID", required: true }), - key: t.string({ - required: false, - description: - "Key used in article markdown as hp-medium:KEY. Defaults to mediumId.", - }), - }), - }, - { - errors: { types: [NotAuthenticatedError, InvalidInputError] }, - async resolve(_root, args, ctx) { - const session = await ctx.session; - if (session == null) throw new NotAuthenticatedError(); - let draft = await ctx.db.query.articleDraftTable.findFirst({ - where: { - id: args.input.draftId, - accountId: session.accountId, - }, - }); - if (draft == null) { - const inserted = await ctx.db.insert(articleDraftTable).values({ - id: args.input.draftId, - accountId: session.accountId, - title: "", - content: "", - tags: [], - }).onConflictDoNothing().returning(); - draft = inserted[0]; - } - if (draft == null) throw new InvalidInputError("draftId"); - const medium = await ctx.db.query.mediumTable.findFirst({ - where: { id: args.input.mediumId }, - }); - if (medium == null) throw new InvalidInputError("mediumId"); - const key = args.input.key?.trim() || medium.id; - if (!key.match(/^[A-Za-z0-9._:/-]+$/)) { - throw new InvalidInputError("key"); - } - await ctx.db.insert(articleDraftMediumTable).values({ - articleDraftId: draft.id, - key, - mediumId: medium.id, - }).onConflictDoUpdate({ - target: [ - articleDraftMediumTable.articleDraftId, - articleDraftMediumTable.key, - ], - set: { mediumId: medium.id }, - }); - return { key, medium } satisfies AttachedArticleDraftMedium; - }, - }, - { - outputFields: (t) => ({ - key: t.string({ resolve: (result) => result.key }), - medium: t.field({ - type: Medium, - resolve: (result) => result.medium, - }), - }), - }, -); - -interface AttachedArticleSourceMedium { - key: string; - medium: schema.Medium; -} - -builder.relayMutationField( - "attachArticleSourceMedium", - { - description: - "Associate an uploaded `Medium` with a published article so it can " + - "be referenced in the article's Markdown as `hp-medium:{key}`. Use " + - "this when adding new media to an article during editing: pair each " + - "attach call with an `hp-medium:` reference in the new content " + - "passed to `updateArticle`. The viewer must own the article. " + - "Requires authentication.", - inputFields: (t) => ({ - articleSourceId: t.field({ - type: "UUID", - required: true, - description: - "UUID of the `ArticleSource` to attach the medium to. The viewer " + - "must be the article's author.", - }), - actingAccountId: t.globalID({ - for: [Account], - required: false, - description: actingAccountIdArgDescription, - }), - mediumId: t.field({ - type: "UUID", - required: true, - description: "UUID of a `Medium` to make available to the article.", - }), - key: t.string({ - required: false, - description: - "Key used in article markdown as hp-medium:KEY. Defaults to mediumId.", - }), - }), - }, - { - errors: { - types: [ - NotAuthenticatedError, - NotAuthorizedError, - InvalidInputError, - OrganizationPermissionError, - ], - }, - async resolve(_root, args, ctx) { - const session = await ctx.session; - if (session == null) throw new NotAuthenticatedError(); - const actingAccount = await resolveActingAccountForMutation( - ctx, - args.input, - ); - if (!validateUuid(args.input.articleSourceId)) { - throw new InvalidInputError("articleSourceId"); - } - const source = await ctx.db.query.articleSourceTable.findFirst({ - where: { id: args.input.articleSourceId }, - columns: { id: true, accountId: true }, - }); - if (source == null || source.accountId !== actingAccount.id) { - throw new InvalidInputError("articleSourceId"); - } - const medium = await ctx.db.query.mediumTable.findFirst({ - where: { id: args.input.mediumId }, - }); - if (medium == null) throw new InvalidInputError("mediumId"); - // During the upload window the medium is private to its uploader. - // Block attaching a freshly-uploaded medium that belongs to someone - // else, matching the ownership check used by `Medium.generatedAltText`. - // After the window expires the medium is either publicly referenced - // or pending orphan cleanup, so any authenticated owner of the - // target article may attach it. - const owner = await isMediumOwner( - ctx.kv, - medium.id, - session.accountId, - ); - if (!owner) { - const windowActive = await isMediumUploadWindowActive( - ctx.kv, - medium.id, - ); - if (windowActive) throw new NotAuthorizedError(); - } - const key = args.input.key?.trim() || medium.id; - if (!key.match(/^[A-Za-z0-9._:/-]+$/)) { - throw new InvalidInputError("key"); - } - // Don't silently overwrite an existing row. A published article's - // rendered HTML resolves `hp-medium:KEY` against this table at - // request time, so changing the `mediumId` for an in-use key would - // change the live article without going through `updateArticle` - // (no timestamp bump, no ActivityPub `Update`). Treat re-attaching - // the same medium as idempotent; reject conflicting medium IDs. - const inserted = await ctx.db.insert(articleSourceMediumTable).values({ - articleSourceId: source.id, - key, - mediumId: medium.id, - }).onConflictDoNothing().returning(); - if (inserted.length === 0) { - const existing = await ctx.db.query.articleSourceMediumTable - .findFirst({ - where: { articleSourceId: source.id, key }, - }); - if (existing == null || existing.mediumId !== medium.id) { - throw new InvalidInputError("key"); - } - } - return { key, medium } satisfies AttachedArticleSourceMedium; - }, - }, - { - outputFields: (t) => ({ - key: t.string({ - description: - "The key the medium was attached under. Reference it in the " + - "article's Markdown as `hp-medium:KEY`. Equals the requested " + - "`key` input when provided, otherwise the medium's UUID.", - resolve: (result) => result.key, - }), - medium: t.field({ - type: Medium, - description: "The `Medium` that was attached to the article source.", - resolve: (result) => result.medium, - }), - }), - }, -); +// Compatibility facade for the historical `graphql/post.ts` module. +// Schema registration is split by feature under `graphql/post/`. +import "./post/mutations.ts"; + +export { + hidePostRelationWithoutActor, + isPostVisibleToViewer, + Medium, + Post, + PostLink, + PostType, +} from "./post/core.ts"; +export { Article, ArticleContent, ArticleDraft } from "./post/article.ts"; +export { Note, Question } from "./post/note.ts"; diff --git a/graphql/post/actor-fields.ts b/graphql/post/actor-fields.ts new file mode 100644 index 000000000..c1e56747b --- /dev/null +++ b/graphql/post/actor-fields.ts @@ -0,0 +1,361 @@ +import { resolveOffsetConnection } from "@pothos/plugin-relay"; +import { + getCensoredPostExclusionFilter, + getPostVisibilityFilter, +} from "@hackerspub/models/post/visibility"; +import { + formatTimelineCursor, + getProfileInteractions, +} from "@hackerspub/models/profile-interactions"; +import { validateUuid } from "@hackerspub/models/uuid"; +import { resolveActingAccountForGlobalIdArg } from "../acting-account.ts"; +import { + Actor, + authenticationRequired, + conflictingCursors, + getActorById, + getConnectionWindow, + loadActorProfilePostPage, + parseRequiredTimelineCursor, + pinConnectionHelpers, +} from "../actor.ts"; +import { builder } from "../builder.ts"; +import { + actingAccountIdArgDescription, + resolveViewerActorId, +} from "../viewer-actor.ts"; +import { Article } from "./article.ts"; +import { Post } from "./core.ts"; +import { Note, Question } from "./note.ts"; + +builder.drizzleObjectFields(Actor, (t) => ({ + posts: t.connection({ + type: Post, + description: "All of this actor's posts (Notes, Articles, Questions, and " + + "boost wrappers), newest published first. Filtered to posts " + + "visible to the selected viewer account. Pass `actingAccountId` " + + "for an organization perspective.", + args: { + actingAccountId: t.arg.id({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + async resolve(actor, args, ctx) { + const viewerActorId = await resolveViewerActorId(ctx, args); + const viewerActor = viewerActorId == null + ? null + : await getActorById(ctx, viewerActorId); + return await resolveOffsetConnection( + { args }, + async ({ offset, limit }) => { + const postPage = await ctx.db.query.postTable.findMany({ + where: { + AND: [ + { actorId: actor.id }, + getPostVisibilityFilter(viewerActor), + getCensoredPostExclusionFilter(viewerActor?.id), + ], + }, + orderBy: { published: "desc" }, + limit, + offset, + }); + return await loadActorProfilePostPage(ctx, postPage, viewerActorId); + }, + ); + }, + }), + notes: t.connection({ + type: Note, + description: + "This actor's `Note`-type posts, newest first, filtered to those " + + "visible to the viewer. Includes both original notes and boost " + + "wrappers of remote notes. Use `sharedPosts` to see only boosts. " + + "Pass `actingAccountId` for an organization perspective.", + args: { + actingAccountId: t.arg.id({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + async resolve(actor, args, ctx) { + const viewerActorId = await resolveViewerActorId(ctx, args); + const viewerActor = viewerActorId == null + ? null + : await getActorById(ctx, viewerActorId); + return await resolveOffsetConnection( + { args }, + async ({ offset, limit }) => { + const postPage = await ctx.db.query.postTable.findMany({ + where: { + AND: [ + { actorId: actor.id }, + { type: "Note" }, + getPostVisibilityFilter(viewerActor), + getCensoredPostExclusionFilter(viewerActor?.id), + ], + }, + orderBy: { published: "desc" }, + limit, + offset, + }); + return await loadActorProfilePostPage(ctx, postPage, viewerActorId); + }, + ); + }, + }), + noteByUuid: t.drizzleField({ + type: Note, + select: { columns: { id: true } }, + nullable: true, + args: { + uuid: t.arg({ type: "UUID", required: true }), + }, + async resolve(query, actor, args, ctx) { + if (!validateUuid(args.uuid)) return null; + + const visibility = getPostVisibilityFilter(ctx.account?.actor ?? null); + const note = await ctx.db.query.postTable.findFirst(query({ + where: { + AND: [ + { type: "Note", actorId: actor.id }, + { + OR: [ + { id: args.uuid }, + { noteSourceId: args.uuid }, + ], + }, + visibility, + ], + }, + })); + return note || null; + }, + }), + postByUuid: t.drizzleField({ + type: Post, + description: + "Look up one of this actor's posts by any of its UUIDs. Resolves a " + + "match against any of the three UUIDs a post can carry: `Post.uuid` " + + "(the post row's PK), `Note.sourceId` (= `noteSourceTable.id`, set " + + "on source-backed local notes and Questions), or the local article " + + "source's id. The canonical permalink in `Post.url` uses the source " + + "UUID for source-backed local posts, including local `Question`s. " + + "For posts without a local source row (federated remote posts and " + + "local share wrappers), the row PK is the lookup token. The OR-match " + + "here keeps both styles working. Returns `null` if no post matches " + + "or the post is not visible to the selected viewer account.", + select: { columns: { id: true } }, + nullable: true, + args: { + uuid: t.arg({ + type: "UUID", + required: true, + description: + "Any of `Post.uuid`, `Note.sourceId` (also used by local " + + "`Question`s), or the local article source's id.", + }), + actingAccountId: t.arg.id({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + async resolve(query, actor, args, ctx) { + if (!validateUuid(args.uuid)) return null; + + const viewerActorId = await resolveViewerActorId(ctx, args); + const viewerActor = viewerActorId == null + ? null + : await getActorById(ctx, viewerActorId); + const visibility = getPostVisibilityFilter(viewerActor); + return await ctx.db.query.postTable.findFirst(query({ + where: { + AND: [ + { actorId: actor.id }, + { + OR: [ + { id: args.uuid }, + { noteSourceId: args.uuid }, + { articleSourceId: args.uuid }, + ], + }, + visibility, + ], + }, + })) ?? null; + }, + }), + articles: t.connection({ + type: Article, + description: + "This actor's locally-authored `Article`-type posts, newest first. " + + "Only includes articles that have a local `articleSource` row; " + + "remote articles federated in from other instances are excluded. " + + "Pass `actingAccountId` for an organization perspective.", + args: { + actingAccountId: t.arg.id({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + async resolve(actor, args, ctx) { + const viewerActorId = await resolveViewerActorId(ctx, args); + const viewerActor = viewerActorId == null + ? null + : await getActorById(ctx, viewerActorId); + return await resolveOffsetConnection( + { args }, + async ({ offset, limit }) => { + const postPage = await ctx.db.query.postTable.findMany({ + where: { + AND: [ + { actorId: actor.id }, + { type: "Article" }, + { + articleSourceId: { + isNotNull: true, + }, + }, + getPostVisibilityFilter(viewerActor), + getCensoredPostExclusionFilter(viewerActor?.id), + ], + }, + orderBy: { published: "desc" }, + limit, + offset, + }); + return await loadActorProfilePostPage(ctx, postPage, viewerActorId); + }, + ); + }, + }), + questions: t.relatedConnection("posts", { + type: Question, + description: "This actor's `Question`-type posts (polls), newest first, " + + "filtered to those visible to the viewer.", + query: (_, ctx) => ({ + where: { + AND: [ + { type: "Question" }, + getPostVisibilityFilter(ctx.account?.actor ?? null), + getCensoredPostExclusionFilter(ctx.account?.actor.id), + ], + }, + orderBy: { published: "desc" }, + }), + }), + sharedPosts: t.connection({ + type: Post, + description: "Posts that this actor has boosted (shared), newest first. " + + "These are boost wrapper rows where `sharedPost` is non-null. " + + "Pass `actingAccountId` for an organization perspective.", + args: { + actingAccountId: t.arg.id({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + async resolve(actor, args, ctx) { + const viewerActorId = await resolveViewerActorId(ctx, args); + const viewerActor = viewerActorId == null + ? null + : await getActorById(ctx, viewerActorId); + return await resolveOffsetConnection( + { args }, + async ({ offset, limit }) => { + const postPage = await ctx.db.query.postTable.findMany({ + where: { + AND: [ + { actorId: actor.id }, + getPostVisibilityFilter(viewerActor), + getCensoredPostExclusionFilter(viewerActor?.id), + { sharedPostId: { isNotNull: true } }, + ], + }, + orderBy: { published: "desc" }, + limit, + offset, + }); + return await loadActorProfilePostPage(ctx, postPage, viewerActorId); + }, + ); + }, + }), + pins: t.connection({ + type: Post, + description: + "Posts this actor has pinned to the top of their profile, most " + + "recently pinned first. Only posts visible to the current viewer " + + "are included.", + select: (args, ctx, nestedSelection) => ({ + with: { + pins: pinConnectionHelpers.getQuery(args, ctx, nestedSelection), + }, + }), + resolve: (actor, args, ctx) => + pinConnectionHelpers.resolve(actor.pins, args, ctx), + }), + viewerInteractions: t.connection({ + type: Post, + description: + "Posts authored by either this `Actor` or the selected viewer account " + + "that directly involve the other actor through a reply, quote, or " + + "explicit mention. Returns an empty connection for the viewer's own " + + "`Actor`; unauthenticated requests raise `AUTHENTICATION_REQUIRED`. " + + "`first` and `last` are capped at 250 posts. Pass `actingAccountId` " + + "for an organization perspective.", + args: { + actingAccountId: t.arg.id({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + async resolve(actor, args, ctx) { + if (ctx.account == null) { + authenticationRequired(); + } else if (args.after != null && args.before != null) { + conflictingCursors(); + } + const actingAccount = await resolveActingAccountForGlobalIdArg(ctx, args); + const backwards = args.last != null; + const window = getConnectionWindow(args); + const since = args.before == null + ? undefined + : parseRequiredTimelineCursor(args.before); + const until = args.after == null + ? undefined + : parseRequiredTimelineCursor(args.after); + const interactions = await getProfileInteractions(ctx.db, { + viewer: actingAccount, + profileActorId: actor.id, + direction: backwards ? "backward" : "forward", + window: window + 1, + since, + until, + }); + const pageEntries = interactions.slice(0, window); + if (backwards) pageEntries.reverse(); + return { + pageInfo: { + hasNextPage: backwards + ? args.before != null + : interactions.length > window, + hasPreviousPage: backwards + ? interactions.length > window + : args.after != null, + startCursor: pageEntries.length < 1 + ? null + : formatTimelineCursor(pageEntries[0]), + endCursor: pageEntries.length < 1 + ? null + : formatTimelineCursor(pageEntries[pageEntries.length - 1]), + }, + edges: pageEntries.map((entry) => ({ + node: entry.post, + cursor: formatTimelineCursor(entry), + })), + }; + }, + }), +})); diff --git a/graphql/post/article-fields.ts b/graphql/post/article-fields.ts new file mode 100644 index 000000000..cbe7c5b5a --- /dev/null +++ b/graphql/post/article-fields.ts @@ -0,0 +1,21 @@ +import { Account } from "../account.ts"; +import { builder } from "../builder.ts"; +import { ArticleDraft } from "./article.ts"; + +builder.drizzleObjectField( + Account, + "articleDrafts", + (t) => + t.relatedConnection("articleDrafts", { + type: ArticleDraft, + description: + "Unpublished article drafts belonging to this account, most " + + "recently updated first. Only visible to the account holder.", + authScopes: (parent) => ({ + selfAccount: "id" in parent ? parent.id : undefined, + }), + query: () => ({ + orderBy: { updated: "desc" }, + }), + }), +); diff --git a/graphql/post/article.ts b/graphql/post/article.ts new file mode 100644 index 000000000..418b28a17 --- /dev/null +++ b/graphql/post/article.ts @@ -0,0 +1,595 @@ +import { and, eq } from "drizzle-orm"; +import { getAvatarUrl } from "@hackerspub/models/account"; +import { + getArticleDraftMediumUrls, + getArticleSourceMediumUrls, +} from "@hackerspub/models/article"; +import { renderCustomEmojis } from "@hackerspub/models/emoji"; +import { + addExternalLinkTargets, + transformMentions, +} from "@hackerspub/models/html"; +import { negotiateLocale } from "@hackerspub/models/i18n"; +import { + getMissingArticleMediumLabel, + renderMarkup, +} from "@hackerspub/models/markup"; +import { articleContentTable } from "@hackerspub/models/schema"; +import type { Uuid } from "@hackerspub/models/uuid"; +import { Account } from "../account.ts"; +import { builder, type UserContext } from "../builder.ts"; +import { putArticleOgImage } from "../og.ts"; +import { Reactable } from "../reactable.ts"; +import { + articleContentOgImageComplexity, + isCensoredForViewer, + Post, + sanctionActorSelection, +} from "./core.ts"; + +export const Article = builder.drizzleNode("postTable", { + variant: "Article", + description: + "A long-form blog article written on this platform. Articles have a " + + "title, year-based URL slug, and can have multiple `ArticleContent` " + + "translations. Remote articles federated from other instances lack a " + + "local `articleSource` and will have `null` for `slug`, `publishedYear`, and `tags`.", + interfaces: [Post, Reactable], + id: { + column: (post) => post.id, + }, + fields: (t) => ({ + // articleSource is only present for locally-authored articles. Articles + // federated in from remote servers don't have one — the upstream + // metadata lives on the post itself, not in our articleSource table — + // so the fields below have to be nullable to represent that. + sourceId: t.expose("articleSourceId", { + type: "UUID", + nullable: true, + description: + "The local source UUID for this article (`articleSourceTable.id`). " + + "Non-null only for source-backed local articles (articles originally " + + "composed on this instance). Use it when calling APIs that need to " + + "resolve the article's attached media, e.g. `renderMarkdown` with " + + "an `articleSourceId` argument for edit-time previews. `null` for " + + "articles federated in from remote instances.", + }), + publishedYear: t.int({ + nullable: true, + description: + "The year the article was first published, used as part of its " + + "URL path (e.g., `/@alice/2024/my-article`). `null` for articles " + + "federated in from remote instances.", + select: { + with: { + articleSource: { + columns: { publishedYear: true }, + }, + }, + }, + resolve: (post) => post.articleSource?.publishedYear ?? null, + }), + slug: t.string({ + nullable: true, + description: + "URL slug for the article, used together with `publishedYear` " + + "to build its permalink. `null` for remote articles.", + select: { + with: { + articleSource: { + columns: { slug: true }, + }, + }, + }, + resolve: (post) => post.articleSource?.slug ?? null, + }), + tags: t.stringList({ + nullable: true, + description: + "Author-assigned tags for this article. `null` for articles " + + "federated in from remote instances. Empty when the post is " + + "censored, or its author is hidden by a moderation sanction, and the viewer is neither the author nor a moderator, " + + "since the tags are part of the censored content.", + select: { + columns: { censored: true, actorId: true }, + with: { + actor: sanctionActorSelection, + articleSource: { + columns: { tags: true }, + }, + }, + }, + resolve: (post, _, ctx) => { + if (isCensoredForViewer(post, ctx)) return []; + return post.articleSource?.tags ?? null; + }, + }), + allowLlmTranslation: t.boolean({ + nullable: true, + description: + "Whether the author has enabled LLM-based translation for this " + + "article. `null` for articles federated from remote instances.", + select: { + with: { + articleSource: { + columns: { allowLlmTranslation: true }, + }, + }, + }, + resolve: (post) => post.articleSource?.allowLlmTranslation ?? null, + }), + contents: t.field({ + type: [ArticleContent], + description: + "All available language versions of this article's content. " + + "Pass `language` to get only the best-matching locale (BCP 47 " + + "negotiation). Pass `includeBeingTranslated: true` to also include " + + "language versions whose LLM translation is still in progress. " + + "Empty when the article is censored or its author is hidden by " + + "a moderation sanction, and the viewer is neither " + + "its author nor a moderator.", + args: { + language: t.arg({ + type: "Locale", + required: false, + description: + "Preferred BCP 47 locale for content negotiation. Omit it to " + + "return every eligible language version.", + }), + includeBeingTranslated: t.arg({ + type: "Boolean", + required: false, + defaultValue: false, + description: + "Whether to include language versions whose LLM translation is " + + "still in progress. Defaults to `false`.", + }), + }, + complexity: (args) => ({ + field: 1, + multiplier: args.language == null ? 10 : 1, + }), + select: (args) => ({ + columns: { actorId: true, censored: true }, + with: { + actor: sanctionActorSelection, + articleSource: { + with: { + contents: args.includeBeingTranslated + ? {} + : { where: { beingTranslated: false } }, + }, + }, + }, + }), + resolve(post, args, ctx) { + if (isCensoredForViewer(post, ctx)) return []; + const contents = post.articleSource?.contents ?? []; + if (args.language == null) return contents; + const availableLocales = contents.map((c) => c.language); + const selectedLocale = negotiateLocale(args.language, availableLocales); + return contents.filter( + (c) => c.language === selectedLocale?.baseName, + ); + }, + }), + }), +}); + +builder.drizzleObjectField(Article, "account", (t) => + t.field({ + // Federated remote articles don't carry an articleSource (see the + // articleSource-backed fields on Article above), so the author has + // to be nullable here too — for remote articles, callers should fall + // back to the post-level actor. + type: Account, + nullable: true, + select: (_, __, nestedSelection) => ({ + with: { + articleSource: { + with: { + account: nestedSelection(), + }, + }, + }, + }), + resolve: (post) => post.articleSource?.account ?? null, + })); + +export const ArticleDraft = builder.drizzleNode("articleDraftTable", { + variant: "ArticleDraft", + description: + "An unpublished article draft. Visible only to the owning account. " + + "Drafts are promoted to `Article`s via the `publishArticleDraft` mutation.", + // The `articleDraft` query already scopes lookups to the owner, but a + // draft's global ID must not let anyone else read it via `node(id:)`. + // Owner-only, matching the query (drafts belong to personal accounts; + // not even moderators can read them). + authScopes: (draft, ctx) => + ctx.account != null && draft.accountId === ctx.account.id, + runScopesOnType: true, + id: { + column: (draft) => draft.id, + }, + fields: (t) => ({ + uuid: t.expose("id", { type: "UUID" }), + title: t.exposeString("title"), + content: t.expose("content", { type: "Markdown" }), + contentHtml: t.field({ + type: "HTML", + description: "The rendered HTML of the draft's markdown content.", + select: { + columns: { + content: true, + }, + }, + async resolve(draft, _, ctx) { + const rendered = await renderMarkup(ctx.fedCtx, draft.content, { + mediumUrls: await getArticleDraftMediumUrls( + ctx.db, + ctx.disk, + draft.id, + ), + missingMediumLabel: getMissingArticleMediumLabel( + ctx.account?.locales?.[0], + ), + }); + return addExternalLinkTargets( + rendered.html, + new URL(ctx.fedCtx.canonicalOrigin), + ); + }, + }), + tags: t.exposeStringList("tags"), + created: t.expose("created", { type: "DateTime" }), + updated: t.expose("updated", { type: "DateTime" }), + account: t.relation("account"), + }), +}); + +/** + * Whether this article content version belongs to a censored article whose + * content must be redacted for the current viewer (the author and + * moderators are exempt). Guards direct `ArticleContent` node access, + * which bypasses `Article.contents`. + */ +function isArticleContentCensoredForViewer( + content: { source: { post: { censored: Date | null; actorId: Uuid } } }, + ctx: UserContext, +): boolean { + return isCensoredForViewer(content.source.post, ctx); +} + +export const ArticleContent = builder.drizzleNode("articleContentTable", { + name: "ArticleContent", + description: + "A single language version of an `Article`'s content. Each language is " + + "stored separately; `Article.contents` lists all available translations. " + + "Translated versions have a non-null `originalLanguage`; `translator` " + + "can be `null` when the translating account was deleted.", + id: { + column: (content) => [content.sourceId, content.language], + }, + fields: (t) => ({ + language: t.expose("language", { + type: "Locale", + description: "BCP 47 language tag identifying this content version.", + }), + title: t.field({ + type: "String", + description: + "The article's title in this language. Empty when the article " + + "is censored, or its author is hidden by a moderation sanction, " + + "and the viewer is neither the author nor a " + + "moderator.", + select: { + columns: { title: true }, + with: { + source: { + with: { + post: { + columns: { censored: true, actorId: true }, + with: { actor: sanctionActorSelection }, + }, + }, + }, + }, + }, + resolve: (content, _, ctx) => + isArticleContentCensoredForViewer(content, ctx) ? "" : content.title, + }), + summary: t.field({ + type: "String", + nullable: true, + select: { + columns: { summary: true }, + with: { + source: { + with: { + post: { + columns: { censored: true, actorId: true }, + with: { actor: sanctionActorSelection }, + }, + }, + }, + }, + }, + resolve: (content, _, ctx) => + isArticleContentCensoredForViewer(content, ctx) + ? null + : content.summary, + description: + "`null` when the article is censored, or its author is hidden by " + + "a moderation sanction, and the viewer is neither " + + "its author nor a moderator. Otherwise the " + + "LLM-generated summary for this language version: `null` until " + + "generation completes. Check `summaryStarted` to distinguish " + + 'between "not requested" and "in progress".', + }), + summaryStarted: t.expose("summaryStarted", { + type: "DateTime", + nullable: true, + description: + "When LLM summary generation was started for this content version. " + + "`null` if summary generation has not been requested.", + }), + content: t.field({ + type: "HTML", + description: + "Rendered HTML of this language version, with media URLs resolved " + + "and external links annotated. Empty when the article is " + + "censored, or its author is hidden by a moderation sanction, and the viewer is neither the author nor a moderator.", + select: { + columns: { + content: true, + language: true, + }, + with: { + source: { + with: { + post: { + columns: { + actorId: true, + censored: true, + emojis: true, + tags: true, + }, + with: { + actor: sanctionActorSelection, + mentions: { + with: { actor: true }, + }, + }, + }, + }, + }, + }, + }, + async resolve(content, _, ctx) { + if (isArticleContentCensoredForViewer(content, ctx)) return ""; + const html = await renderMarkup(ctx.fedCtx, content.content, { + kv: ctx.kv, + mediumUrls: await getArticleSourceMediumUrls( + ctx.db, + ctx.disk, + content.sourceId, + ), + missingMediumLabel: getMissingArticleMediumLabel(content.language), + }); + const post = content.source.post; + let rendered = renderCustomEmojis(html.html, post.emojis); + rendered = transformMentions(rendered, post.mentions, post.tags); + return addExternalLinkTargets( + rendered, + new URL(ctx.fedCtx.canonicalOrigin), + ); + }, + }), + rawContent: t.field({ + type: "Markdown", + description: + "The raw markdown content for editing. Empty when the article " + + "is censored, or its author is hidden by a moderation sanction, " + + "and the viewer is neither the author nor a " + + "moderator.", + select: { + columns: { content: true }, + with: { + source: { + with: { + post: { + columns: { censored: true, actorId: true }, + with: { actor: sanctionActorSelection }, + }, + }, + }, + }, + }, + resolve(content, _, ctx) { + if (isArticleContentCensoredForViewer(content, ctx)) return ""; + return content.content; + }, + }), + toc: t.field({ + type: "JSON", + description: + "Table of contents for the article content. Empty when the " + + "article is censored, or its author is hidden by a moderation " + + "sanction, and the viewer is neither the author nor a " + + "moderator.", + select: { + columns: { content: true, language: true, sourceId: true }, + with: { + source: { + with: { + post: { + columns: { censored: true, actorId: true }, + with: { actor: sanctionActorSelection }, + }, + }, + }, + }, + }, + async resolve(content, _, ctx) { + if (isArticleContentCensoredForViewer(content, ctx)) return []; + const rendered = await renderMarkup(ctx.fedCtx, content.content, { + kv: ctx.kv, + mediumUrls: await getArticleSourceMediumUrls( + ctx.db, + ctx.disk, + content.sourceId, + ), + missingMediumLabel: getMissingArticleMediumLabel(content.language), + }); + return rendered.toc; + }, + }), + originalLanguage: t.expose("originalLanguage", { + type: "Locale", + nullable: true, + description: + "The source language this content was translated from. Non-null " + + "only for LLM-translated versions; `null` for original content.", + }), + translator: t.relation("translator", { + nullable: true, + description: + "The account whose LLM translation produced this content version. " + + "`null` for original (non-translated) content.", + }), + translationRequester: t.relation("translationRequester", { + nullable: true, + description: + "The account that requested this translation. May differ from " + + "`translator` if translations are requested on behalf of others.", + }), + beingTranslated: t.exposeBoolean("beingTranslated", { + description: + "Whether an LLM translation into this language is currently " + + "in progress. When `true`, the content may be incomplete.", + }), + updated: t.expose("updated", { type: "DateTime" }), + published: t.expose("published", { type: "DateTime" }), + ogImageUrl: t.field({ + type: "URL", + nullable: true, + description: "The generated Open Graph preview image for this language " + + "version. `null` when the article is censored, or its author is " + + "hidden by a moderation sanction, and the viewer " + + "is neither its author nor a moderator: the image is rendered " + + "from the title and excerpt and would otherwise leak censored " + + "content.", + complexity: articleContentOgImageComplexity, + select: { + columns: { + content: true, + language: true, + ogImageKey: true, + sourceId: true, + summary: true, + title: true, + }, + with: { + source: { + with: { + account: { + with: { + actor: { + columns: { + handleHost: true, + }, + }, + avatarMedium: true, + emails: true, + }, + }, + post: { + columns: { censored: true, actorId: true }, + with: { actor: sanctionActorSelection }, + }, + }, + }, + }, + }, + async resolve(content, _, ctx) { + if (isArticleContentCensoredForViewer(content, ctx)) return null; + const account = content.source.account; + const rendered = await renderMarkup(ctx.fedCtx, content.content, { + kv: ctx.kv, + mediumUrls: await getArticleSourceMediumUrls( + ctx.db, + ctx.disk, + content.sourceId, + ), + missingMediumLabel: getMissingArticleMediumLabel(content.language), + }); + const avatarUrl = await getAvatarUrl(ctx.disk, account); + const key = await putArticleOgImage(ctx.disk, content.ogImageKey, { + authorName: account.name, + avatarKey: account.avatarMedium?.key ?? avatarUrl, + avatarUrl, + excerpt: content.summary ?? rendered.text, + handle: `@${account.username}@${account.actor.handleHost}`, + language: content.language, + sourceId: content.sourceId, + title: content.title, + }); + if (key !== content.ogImageKey) { + await ctx.db.update(articleContentTable) + .set({ ogImageKey: key }) + .where( + and( + eq(articleContentTable.sourceId, content.sourceId), + eq(articleContentTable.language, content.language), + ), + ); + } + return new URL(await ctx.disk.getUrl(key)); + }, + }), + url: t.field({ + type: "URL", + description: + "Canonical URL for this language version. For the article's " + + "primary language this is `/@username/year/slug`; for other " + + "language versions it appends `/{language}` to that path.", + select: { + with: { + source: { + columns: { + publishedYear: true, + slug: true, + }, + with: { + account: { + columns: { + username: true, + }, + }, + post: { + columns: { + language: true, + }, + }, + }, + }, + }, + }, + resolve(content, _, ctx) { + if ( + content.originalLanguage != null || + content.language !== content.source.post.language + ) { + return new URL( + `/@${content.source.account.username}/${content.source.publishedYear}/${content.source.slug}/${content.language}`, + ctx.fedCtx.canonicalOrigin, + ); + } + return new URL( + `/@${content.source.account.username}/${content.source.publishedYear}/${content.source.slug}`, + ctx.fedCtx.canonicalOrigin, + ); + }, + }), + }), +}); diff --git a/graphql/post/core.ts b/graphql/post/core.ts new file mode 100644 index 000000000..34172c322 --- /dev/null +++ b/graphql/post/core.ts @@ -0,0 +1,2381 @@ +import * as vocab from "@fedify/vocab"; +import { getLogger } from "@logtape/logtape"; +import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; +import { + resolveArrayConnection, + resolveOffsetConnection, +} from "@pothos/plugin-relay"; +import { unreachable } from "@std/assert"; +import { assertNever } from "@std/assert/unstable-never"; +import DataLoader from "dataloader"; +import { and, eq, gt, inArray, isNotNull, isNull, lte, or } from "drizzle-orm"; +import { createGraphQLError } from "graphql-yoga"; +import { generateAltText } from "@hackerspub/ai/alttext"; +import { + arePostsBookmarkedBy, + getBookmarkCountsForPosts, +} from "@hackerspub/models/bookmark"; +import type { Database, Transaction } from "@hackerspub/models/db"; +import { renderCustomEmojis } from "@hackerspub/models/emoji"; +import { + addExternalLinkTargets, + removeQuoteInlineFallback, + sanitizeExcerptHtml, + stripHtml, + transformMentions, + truncateHtml, +} from "@hackerspub/models/html"; +import { assertAccountActorNotSuspended } from "@hackerspub/models/moderation"; +import { recordOrganizationPostAuthor } from "@hackerspub/models/organization"; +import { + getCensoredPostExclusionFilter, + getPostInteractionPolicies, + getPostVisibilityFilter, + getSanctionVisibleActorFilter, + isActorSanctionHidden, + type PostInteractionPolicy, +} from "@hackerspub/models/post/visibility"; +import { actorTable, pinTable, postTable } from "@hackerspub/models/schema"; +import type * as schema from "@hackerspub/models/schema"; +import { + DESCENDANT_TREE_MAX_DEPTH, + getAncestorChain, + getDescendantPage, +} from "@hackerspub/models/thread"; +import type { Uuid } from "@hackerspub/models/uuid"; +import { Account } from "../account.ts"; +import { resolveActingAccountForMutation } from "../acting-account.ts"; +import { + Actor, + actorProfilePostRelations, + getActorById, + isActorProfileHidden, + loadActorProfilePostPage, +} from "../actor.ts"; +import { builder, Node, type UserContext } from "../builder.ts"; +import { InvalidInputError, NotAuthorizedError } from "../error.ts"; +import { isMediumOwner, isMediumUploadWindowActive } from "../medium-upload.ts"; +import { PostVisibility, toPostVisibility } from "../postvisibility.ts"; +import { + QuotePolicy, + QuoteTargetState, + toQuotePolicy, + toQuoteTargetState, +} from "../quotepolicy.ts"; +import { Reactable } from "../reactable.ts"; +import { NotAuthenticatedError } from "../session.ts"; +import { + type ActingAccountIdArg, + actingAccountIdArgDescription, + resolveViewerActorId, +} from "../viewer-actor.ts"; + +export const articleContentOgImageComplexity = 2_000; +export const logger = getLogger(["hackerspub", "graphql", "post"]); + +export class SharedPostDeletionNotAllowedError extends Error { + public constructor(public readonly inputPath: string) { + super("Shared posts cannot be deleted. Use unsharePost instead."); + } +} + +export type LlmTranslationNotAllowedReason = "DISABLED" | "SAME_LANGUAGE"; + +export class LlmTranslationNotAllowedError extends Error { + public constructor(public readonly reason: LlmTranslationNotAllowedReason) { + super(`LLM translation not allowed: ${reason}`); + } +} + +export const PostType = builder.enumType("PostType", { + description: + "Discriminant used to filter a connection to a single post type. " + + "This enum does not appear on the `Post` interface itself; use " + + "__typename` or inline fragments to distinguish concrete types.", + values: { + ARTICLE: { + description: "Long-form article with a title, year-based slug URL, and " + + "optional multi-language translations.", + }, + NOTE: { + description: "Short microblog post (equivalent to a Mastodon Status or " + + "ActivityPub Note).", + }, + QUESTION: { + description: + "ActivityPub `Question` poll. Questions may originate locally via " + + "`createQuestion` or remotely through federation.", + }, + } as const, +}); + +export const PostAttributionMode = builder.enumType("PostAttributionMode", { + description: + "How a post written through an organization account should display " + + "the personal member who created it.", + values: { + ACTING_ACCOUNT_ONLY: { + value: "acting_account_only", + description: "Show only the acting account as the public author. For " + + "organization posts, the member remains recorded for audit and " + + "management but is not shown as a co-author.", + }, + ACTING_ACCOUNT_WITH_VIEWER: { + value: "acting_account_with_viewer", + description: + "Show the organization account as the primary author and the " + + "personal member as a co-author.", + }, + } as const, +}); + +export const OrganizationPostAuthor = builder.objectRef< + schema.OrganizationPostAuthor +>( + "OrganizationPostAuthor", +); + +export const LlmTranslationNotAllowedReasonRef = builder.enumType( + "LlmTranslationNotAllowedReason", + { + values: { + DISABLED: { + description: + "The article's author has opted out of LLM-based translation " + + "for this article.", + }, + SAME_LANGUAGE: { + description: + "The requested target language matches the article's existing " + + "language; translation is a no-op.", + }, + } as const, + }, +); + +builder.objectType(SharedPostDeletionNotAllowedError, { + name: "SharedPostDeletionNotAllowedError", + fields: (t) => ({ + inputPath: t.expose("inputPath", { type: "String" }), + }), +}); + +builder.objectType(LlmTranslationNotAllowedError, { + name: "LlmTranslationNotAllowedError", + fields: (t) => ({ + reason: t.expose("reason", { type: LlmTranslationNotAllowedReasonRef }), + }), +}); + +export async function loadOrganizationPostAuthorAccount( + ctx: UserContext, + id: Uuid, +) { + const account = await ctx.db.query.accountTable.findFirst({ + where: { id }, + with: { actor: true }, + }); + if (account == null || account.actor == null) { + throw createGraphQLError("Organization post attribution is broken.", { + extensions: { code: "INTERNAL_SERVER_ERROR" }, + }); + } + return account; +} + +OrganizationPostAuthor.implement({ + description: + "Attribution metadata for a post written through an organization " + + "account. The `Post.actor` remains the organization; this object " + + "records which personal member created it and whether that member " + + "should be shown as a co-author.", + fields: (t) => ({ + attributionMode: t.field({ + type: PostAttributionMode, + description: + "Whether clients should display only the organization or include " + + "the member as a co-author.", + resolve: (author) => author.attributionMode, + }), + organization: t.field({ + type: Account, + description: "The organization account that authored the post.", + async resolve(author, _, ctx) { + return await loadOrganizationPostAuthorAccount( + ctx, + author.organizationAccountId, + ); + }, + }), + member: t.field({ + type: Account, + nullable: true, + description: + "The personal member account to show as a co-author. `null` when " + + "`attributionMode` is `ACTING_ACCOUNT_ONLY`.", + async resolve(author, _, ctx) { + if (author.attributionMode !== "acting_account_with_viewer") { + return null; + } + return await loadOrganizationPostAuthorAccount( + ctx, + author.memberAccountId, + ); + }, + }), + created: t.expose("created", { + type: "DateTime", + description: "When this organization attribution record was created.", + }), + }), +}); + +export interface PostActingAccountInput { + actingAccountId?: { id: string } | null; + attributionMode?: schema.PostAttributionMode | null; +} + +export interface PostManagementActingAccountInput { + actingAccountId?: { id: string } | null; +} + +export interface ResolvedPostActingAccount { + account: schema.Account & { actor: schema.Actor }; + memberAccountId: Uuid; + attributionMode: schema.PostAttributionMode | null; +} + +export async function resolvePostActingAccount( + ctx: UserContext, + input: PostActingAccountInput, +): Promise { + if (ctx.account == null) throw new NotAuthenticatedError(); + const account = await resolveActingAccountForMutation(ctx, input); + if (account.kind !== "organization") { + if (input.attributionMode != null) { + throw new InvalidInputError("attributionMode"); + } + return { + account, + memberAccountId: ctx.account.id, + attributionMode: null, + }; + } + return { + account, + memberAccountId: ctx.account.id, + attributionMode: input.attributionMode ?? "acting_account_only", + }; +} + +export async function recordPostActingAccount( + db: Database | Transaction, + postId: Uuid, + resolved: ResolvedPostActingAccount, +): Promise { + if (resolved.attributionMode == null) return; + await recordOrganizationPostAuthor( + db, + postId, + resolved.account.id, + resolved.memberAccountId, + resolved.attributionMode, + ); +} + +export async function assertActingAccountNotSuspended( + db: Database, + authenticatedAccountId: Uuid, + actingAccountId: Uuid, +): Promise { + await assertAccountActorNotSuspended(db, authenticatedAccountId); + if (actingAccountId !== authenticatedAccountId) { + await assertAccountActorNotSuspended(db, actingAccountId); + } +} + +export async function resolvePostManagementActingAccount( + ctx: UserContext, + input: PostManagementActingAccountInput, + ownerAccountId: Uuid, + inputPath: string, +): Promise> { + const account = await resolveActingAccountForMutation(ctx, input); + if (account.id !== ownerAccountId) { + throw new InvalidInputError(inputPath); + } + return account; +} + +/** + * Whether the post's content must be redacted for the current viewer: it + * is censored, or its author is hidden by a moderation sanction (a banned + * local actor, or a remote actor under an active federation block), and + * the viewer is neither its author nor a moderator. The permalink itself + * stays reachable (so a censorship notice can render); only the + * content-bearing fields are emptied. List queries exclude such posts + * entirely; this redaction covers direct `node(id:)` lookups and nested + * relations. + * + * Share wrappers carry denormalized copies of the boosted post's title, + * content, and URL, so when the (loaded) `sharedPost` is censored or its + * author is sanction-hidden the wrapper's content is redacted too; there + * the exemption follows the boosted post's author, not the booster. + */ +export function isCensoredForViewer( + post: { + censored: Date | null; + actorId: Uuid; + actor?: SanctionActorColumns | null; + sharedPost?: { + censored: Date | null; + actorId: Uuid; + actor?: SanctionActorColumns | null; + } | null; + }, + ctx: UserContext, +): boolean { + return isRowCensoredForViewer(post, ctx) || + post.sharedPost != null && isRowCensoredForViewer(post.sharedPost, ctx); +} + +export type SanctionActorColumns = Pick< + schema.Actor, + "accountId" | "suspended" | "suspendedUntil" +>; + +/** + * The actor columns the redaction helpers need to evaluate the author's + * sanction state; merged into the field selections that call them. + */ +export const sanctionActorSelection = { + columns: { + accountId: true, + suspended: true, + suspendedUntil: true, + }, +} as const; + +/** + * Like {@link isCensoredForViewer}, but considers only the row itself, + * ignoring any loaded `sharedPost`. Used for the `sharedPost` relation + * field: a wrapper of a censored post must keep the relation (the boosted + * post redacts itself and exposes `censored` for the notice), while a + * censored wrapper hides it entirely. + */ +export function isRowCensoredForViewer( + row: { + censored: Date | null; + actorId: Uuid; + actor?: SanctionActorColumns | null; + }, + ctx: UserContext, +): boolean { + if (ctx.account?.moderator) return false; + if (ctx.account?.actor.id === row.actorId) return false; + if (row.censored != null) return true; + return row.actor != null && isActorSanctionHidden(row.actor); +} + +export const Post = builder.drizzleInterface("postTable", { + variant: "Post", + description: + "Abstract base for all content types: `Note` (short microblog posts), " + + "`Article` (long-form blog posts), and `Question` (polls from federated " + + "instances). Most timeline and feed queries return this interface; " + + "use `__typename` or inline fragments to access type-specific fields. " + + "Content-bearing fields are redacted (empty or `null`) when the post " + + "is censored or its author is hidden by a moderation sanction (a " + + "banned local actor, or a remote actor under an active federation " + + "block), unless the viewer is the author or a moderator; list queries " + + "exclude such posts entirely, so this matters for direct `node(id:)` " + + "lookups and nested relations.", + interfaces: [Reactable, Node], + resolveType(post): string { + switch (post.type) { + case "Article": + return "Article"; + case "Note": + return "Note"; + case "Question": + return "Question"; + default: + return assertNever(post.type); + } + }, + fields: (t) => ({ + uuid: t.expose("id", { + type: "UUID", + description: + "The post row's primary key, stable for the lifetime of the post. " + + "⚠️ This is **not** the UUID embedded in `Post.url` for source-backed " + + "local posts: local notes that originate here use `Note.sourceId` " + + "(= `noteSourceTable.id`), local questions use `Question.sourceId`, " + + "and local articles use `Article.publishedYear` + `Article.slug`. " + + "The row PK is the right token for posts with no local source row: " + + "federated remote posts, local share wrappers (boosts, which carry " + + "no source and copy the shared post's URL), and remote `Question`s. " + + "`actorByHandle.postByUuid` accepts either the row PK or a source " + + "UUID, but resolving by `uuid` for a source-backed local post yields " + + "a URL that differs from `Post.url`.", + }), + iri: t.field({ + type: "URL", + description: + "The post's ActivityPub IRI, used as its canonical identifier in " + + "federation. For local posts this is an `/ap/…` endpoint; for " + + "remote posts it is whatever IRI the originating instance assigned. " + + "Prefer `url` for human-readable links. When the post is censored " + + "or its author is hidden by a moderation sanction, and the viewer " + + "is neither the author nor a moderator, a remote IRI (or a boost " + + "wrapper's, whose `url` is also nulled) is replaced with the local " + + "permalink that renders the notice, so a `url ?? iri` fallback never " + + "leaks the uncensored origin. A local non-wrapper post keeps its " + + "own `/ap/…` IRI (it does not point outside this instance).", + select: { + columns: { + id: true, + iri: true, + noteSourceId: true, + type: true, + censored: true, + actorId: true, + sharedPostId: true, + }, + with: { + actor: { + columns: { + accountId: true, + suspended: true, + suspendedUntil: true, + handle: true, + }, + }, + sharedPost: { + columns: { censored: true, actorId: true }, + with: { actor: sanctionActorSelection }, + }, + }, + }, + resolve: (post, _, ctx) => { + // A hidden post's own IRI (when remote) points at the uncensored + // copy on its origin instance; a boost wrapper's `url` is already + // nulled, so the `url ?? iri` fallback web-next uses for links would + // leak through `iri`. Mirror the `url` field: return the local + // permalink, which renders the notice, for the same hidden + // remote-or-wrapper case. (A local non-wrapper post keeps its own + // local `/ap/…` IRI, which never leaves this instance.) + if ( + isCensoredForViewer(post, ctx) && + post.actor != null && + (post.sharedPostId != null || post.actor.accountId == null) + ) { + return new URL( + `/${post.actor.handle}/${post.id}`, + ctx.fedCtx.canonicalOrigin, + ); + } + if (post.type === "Question" && post.noteSourceId != null) { + return ctx.fedCtx.getObjectUri(vocab.Question, { + id: post.noteSourceId, + }); + } + return new URL(post.iri); + }, + }), + visibility: t.field({ + type: PostVisibility, + description: + "The audience allowed to view this post and whether it appears in " + + "public timelines. The value cannot be changed after the post has " + + "been federated.", + select: { + columns: { visibility: true }, + }, + resolve(post) { + return toPostVisibility(post.visibility); + }, + }), + quotePolicy: t.field({ + type: QuotePolicy, + description: + "Who may embed this post as a quote. Posts with `FOLLOWERS`, `DIRECT`, " + + "or `NONE` visibility always use `SELF` regardless of a broader " + + "received policy.", + select: { + columns: { quotePolicy: true }, + }, + resolve(post) { + return toQuotePolicy(post.quotePolicy); + }, + }), + quoteTargetState: t.field({ + type: QuoteTargetState, + nullable: true, + description: + "The cross-instance approval state when this post quotes another " + + "post. `null` means no request is outstanding or the request was " + + "approved.", + select: { + columns: { quoteTargetState: true }, + }, + resolve(post) { + return toQuoteTargetState(post.quoteTargetState); + }, + }), + censored: t.expose("censored", { + type: "DateTime", + nullable: true, + description: + "When a moderator censored this post, or `null` when it is not " + + "censored. Censored posts disappear from timelines, search, and " + + "recommendations, but their permalinks stay reachable so a " + + "censorship notice can be shown. For everyone but the author " + + "and moderators, all content-bearing fields are redacted: " + + "`content`, the excerpt fields, `name`, `summary`, `hashtags`, " + + "`Article.tags`, `media`, `link`, `mentions`, `sharedPost`, " + + "`quotedPost`, article contents, and poll data.", + }), + name: t.field({ + type: "String", + nullable: true, + description: "The post's title. Non-null for `Article`s and local poll " + + "`Question`s; `null` for `Note`s and boost wrappers. `null` when " + + "the post is censored or its author is hidden by a moderation " + + "sanction (or it is a boost wrapper of such a post, whose title " + + "it copies) and the viewer is neither the content's author nor " + + "a moderator.", + select: { + columns: { name: true, censored: true, actorId: true }, + with: { + actor: sanctionActorSelection, + sharedPost: { + columns: { censored: true, actorId: true }, + with: { actor: sanctionActorSelection }, + }, + }, + }, + resolve: (post, _, ctx) => + isCensoredForViewer(post, ctx) ? null : post.name, + }), + summary: t.field({ + type: "String", + nullable: true, + description: + "Author-provided or LLM-generated summary of the post. `null` " + + "when no summary has been set. For LLM summaries, check " + + "`ArticleContent.summary` and `summaryStarted` instead, as those " + + "are tracked per language on articles. `null` when the post is " + + "censored or its author is hidden by a moderation sanction (or it boosts such a post), and the viewer is " + + "neither the content's author nor a moderator.", + select: { + columns: { summary: true, censored: true, actorId: true }, + with: { + actor: sanctionActorSelection, + sharedPost: { + columns: { censored: true, actorId: true }, + with: { actor: sanctionActorSelection }, + }, + }, + }, + resolve: (post, _, ctx) => + isCensoredForViewer(post, ctx) ? null : post.summary, + }), + content: t.field({ + type: "HTML", + description: + "The post's full HTML content, with custom emoji shortcodes " + + "rendered as `` elements and external links annotated with " + + '`target="_blank"`. Boost wrappers copy the boosted post\'s ' + + "content; prefer `sharedPost.content`. Empty when the post is " + + "censored or its author is hidden by a moderation sanction (or it boosts such a post), and the viewer is " + + "neither the content's author nor a moderator.", + select: { + columns: { + actorId: true, + censored: true, + contentHtml: true, + emojis: true, + quotedPostId: true, + tags: true, + }, + with: { + actor: sanctionActorSelection, + mentions: { + with: { actor: true }, + }, + sharedPost: { + columns: { censored: true, actorId: true }, + with: { actor: sanctionActorSelection }, + }, + }, + }, + resolve: (post, _, ctx) => { + if (isCensoredForViewer(post, ctx)) return ""; + let html = renderCustomEmojis(post.contentHtml, post.emojis); + html = transformMentions(html, post.mentions, post.tags); + html = addExternalLinkTargets( + html, + new URL(ctx.fedCtx.canonicalOrigin), + ); + if (post.quotedPostId != null) html = removeQuoteInlineFallback(html); + return html; + }, + }), + excerpt: t.string({ + description: + "Plain-text excerpt of the post. Returns `summary` when set; " + + "otherwise falls back to the HTML content stripped of tags. " + + "For a truncated HTML preview, use `excerptHtml` instead. " + + "Empty when the post is censored or its author is hidden by a " + + "moderation sanction (or it boosts such a post) " + + "and the viewer is neither the content's author nor a moderator.", + select: { + columns: { + actorId: true, + censored: true, + summary: true, + contentHtml: true, + quotedPostId: true, + }, + with: { + actor: sanctionActorSelection, + sharedPost: { + columns: { censored: true, actorId: true }, + with: { actor: sanctionActorSelection }, + }, + }, + }, + resolve(post, _, ctx) { + if (isCensoredForViewer(post, ctx)) return ""; + if (post.summary != null) return post.summary; + let html = post.contentHtml; + if (post.quotedPostId != null) html = removeQuoteInlineFallback(html); + return stripHtml(html); + }, + }), + excerptHtml: t.field({ + type: "HTML", + description: + "A sanitized, truncated HTML preview of this post's content, " + + "clipped to roughly `maxChars` visible characters with valid tag " + + "structure preserved. Use this on feed cards instead of `content` " + + "to keep the rendered DOM small. Anchor tags are stripped — the " + + "surrounding card is expected to be the link to the full post. " + + "This does NOT fall back to `summary`; query `summary` separately " + + "when you want to display a real (e.g. LLM-generated) summary " + + "with its own affordances. Empty when the post is censored or " + + "its author is hidden by a moderation sanction (or it boosts " + + "such a post) and the viewer is neither the " + + "content's author nor a moderator.", + args: { + maxChars: t.arg.int({ required: true }), + }, + select: { + columns: { + actorId: true, + censored: true, + contentHtml: true, + emojis: true, + quotedPostId: true, + }, + with: { + actor: sanctionActorSelection, + sharedPost: { + columns: { censored: true, actorId: true }, + with: { actor: sanctionActorSelection }, + }, + }, + }, + resolve(post, args, ctx) { + if (isCensoredForViewer(post, ctx)) return ""; + // Remove quote-inline fallback first so the truncation budget isn't + // wasted on text the user will never see. + // + // Sanitize BEFORE rendering custom emojis. The reverse order would + // strip the inline `style` `renderCustomEmojis` puts on the emoji + // `` (which sets `height: 1em` and inline alignment), so emoji + // images would render at their intrinsic size instead of inline with + // the surrounding text. Emoji `src` is admin-uploaded and the alt is + // bounded to `[a-z0-9_-]+` by `CUSTOM_EMOJI_REGEXP`, so the post- + // sanitization injection doesn't open a new XSS surface. + let html = post.contentHtml; + if (post.quotedPostId != null) html = removeQuoteInlineFallback(html); + return truncateHtml( + renderCustomEmojis(sanitizeExcerptHtml(html), post.emojis), + args.maxChars, + ); + }, + }), + language: t.exposeString("language", { + nullable: true, + description: + "BCP 47 language tag of the post's primary content (e.g., `en`, " + + "`ja`). `null` when the language is unknown or not specified by " + + "the author.", + }), + hashtags: t.field({ + type: [Hashtag], + description: + "Hashtags mentioned in the post, extracted from the post's tag " + + "map. Each entry includes the tag name and its canonical hashtag " + + "search URL. Empty when the post is censored or its author is " + + "hidden by a moderation sanction (or it boosts such a post) and " + + "the viewer is neither the content's author nor a moderator, " + + "since hashtags are derived from the hidden " + + "content.", + select: { + columns: { tags: true, censored: true, actorId: true }, + with: { + actor: sanctionActorSelection, + sharedPost: { + columns: { censored: true, actorId: true }, + with: { actor: sanctionActorSelection }, + }, + }, + }, + resolve(post, _, ctx) { + if (isCensoredForViewer(post, ctx)) return []; + return Object.entries(post.tags).map(([name, href]) => ({ + name, + href: new URL(href), + })); + }, + }), + sensitive: t.exposeBoolean("sensitive", { + description: + "Whether the post is marked as sensitive (NSFW). Clients should " + + "hide the content behind a content warning when this is `true`.", + }), + engagementStats: t.variant(PostEngagementStats), + url: t.field({ + type: "URL", + nullable: true, + description: + "The canonical, human-readable URL of this post. For source-backed " + + "local posts the path encodes the local source identifier: " + + "`Note.sourceId` for notes, `Article.publishedYear` + `Article.slug` " + + "for articles, and `Question.sourceId` for questions. It does not " + + "encode `Post.uuid`. For federated remote posts and " + + "local share wrappers (boosts) this is whatever URL the originating " + + "instance advertised (copied from the shared post in the boost case) " + + "and is unrelated to the wrapper's own row PK. Prefer this field " + + "over hand-building a path from `Post.uuid`: `uuid` is the row PK and " + + "does not match the path here for source-backed local posts. " + + "`null` when the post is censored or its author is hidden by a " + + "moderation sanction, and the viewer is neither the content's " + + "author nor a moderator, EXCEPT for a local post (whose own " + + "permalink renders the notice): a boost wrapper's URL mirrors the " + + "boosted post's, and a remote post's URL points at the " + + "uncensored copy on its origin instance, so both are hidden.", + select: { + columns: { + url: true, + censored: true, + actorId: true, + sharedPostId: true, + }, + with: { + actor: sanctionActorSelection, + sharedPost: { + columns: { censored: true, actorId: true }, + with: { actor: sanctionActorSelection }, + }, + }, + }, + resolve: (post, _, ctx) => { + if (post.url == null) return null; + // When the content is hidden, a boost wrapper's URL mirrors the + // boosted post's, and a remote post's URL points at the + // uncensored copy on its origin instance. Only a local post's + // own permalink leads to a page that renders the notice, so it + // is the only URL kept. + if ( + isCensoredForViewer(post, ctx) && + (post.sharedPostId != null || post.actor?.accountId == null) + ) { + return null; + } + return new URL(post.url); + }, + }), + updated: t.expose("updated", { type: "DateTime" }), + published: t.expose("published", { type: "DateTime" }), + actor: t.relation("actor", { + description: "The actor who authored or boosted this post.", + }), + organizationAuthor: t.field({ + type: OrganizationPostAuthor, + nullable: true, + description: + "Organization attribution metadata when this post was created " + + "through an organization account. `null` for ordinary personal " + + "posts and for federated posts without local organization metadata.", + select: { + with: { organizationAuthor: true }, + }, + resolve(post) { + return post.organizationAuthor ?? null; + }, + }), + media: t.field({ + type: [PostMediumRef], + description: + "Media attachments on this post, in display order. For federated " + + "posts the URLs point to the originating instance. Empty when " + + "the post is censored or its author is hidden by a moderation " + + "sanction, and the viewer is neither the author nor a " + + "moderator: attachments are part of the hidden content.", + select: { + columns: { censored: true, actorId: true }, + with: { actor: sanctionActorSelection, media: true }, + }, + resolve: (post, _, ctx) => + isCensoredForViewer(post, ctx) ? [] : post.media, + }), + link: t.field({ + type: PostLink, + nullable: true, + description: + "OpenGraph / oEmbed preview for the first link in the post. " + + "`null` when the post has no links or the metadata has not been " + + "fetched yet, and also when the post is censored or its author " + + "is hidden by a moderation sanction, and the viewer " + + "is neither its author nor a moderator (the linked URL is part " + + "of the censored content).", + select: { + columns: { censored: true, actorId: true }, + with: { actor: sanctionActorSelection, link: true }, + }, + resolve: (post, _, ctx) => + isCensoredForViewer(post, ctx) ? null : post.link, + }), + linkPreviewUrl: t.field({ + type: "URL", + nullable: true, + description: + "The exact first external URL shared in this post, including its " + + "query string and fragment, for link-preview navigation. `null` " + + "when no preview metadata is attached, and hidden under the same " + + "moderation rules as `Post.link`. Use `PostLink.url` only as the " + + "resolved identity used to share preview metadata and news scores.", + select: { + columns: { censored: true, actorId: true, linkUrl: true }, + with: { actor: sanctionActorSelection }, + }, + resolve: (post, _, ctx) => + isCensoredForViewer(post, ctx) || post.linkUrl == null + ? null + : new URL(post.linkUrl), + }), + viewerHasShared: t.loadable({ + type: "Boolean", + description: + "Whether the selected viewer account has boosted this post. Always " + + "`false` for unauthenticated requests. Pass `actingAccountId` for " + + "an organization perspective.", + args: { + actingAccountId: t.arg.globalID({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + // cache: false so a mutation that flips share state in the same + // request (e.g., share + read viewerHasShared) re-queries instead + // of returning the pre-mutation value. + loaderOptions: { cache: false }, + load: loadViewerHasShared, + resolve: postViewerActorKey, + }), + viewerHasBookmarked: t.loadable({ + type: "Boolean", + description: + "Whether the authenticated viewer has bookmarked this post. " + + "Always `false` for unauthenticated requests.", + loaderOptions: { cache: false }, + load: async (postIds: Uuid[], ctx: UserContext): Promise => { + if (ctx.account == null) return postIds.map(() => false); + const bookmarked = await arePostsBookmarkedBy( + ctx.db, + postIds, + ctx.account, + ); + return postIds.map((id) => bookmarked.has(id)); + }, + resolve: (post) => post.id, + }), + viewerHasPinned: t.loadable({ + type: "Boolean", + description: + "Whether the selected viewer account has pinned this post to their " + + "profile. Always `false` for unauthenticated requests. Pass " + + "`actingAccountId` for an organization perspective.", + args: { + actingAccountId: t.arg.globalID({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + loaderOptions: { cache: false }, + load: loadViewerHasPinned, + resolve: postViewerActorKey, + }), + viewerCanReply: t.loadable({ + type: "Boolean", + description: + "Whether the selected viewer account is allowed to reply to this " + + "post, based on visibility and block state. Always `false` for " + + "unauthenticated requests. Pass `actingAccountId` for an " + + "organization perspective.", + args: { + actingAccountId: t.arg.globalID({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + loaderOptions: { cache: false }, + load: async ( + keys: ViewerActorPostKey[], + ctx: UserContext, + ): Promise => { + const policies = await loadViewerActionPolicies(ctx, keys); + return keys.map((key) => + policies.get(viewerActorPostKeyCacheKey(key))?.canReply ?? false + ); + }, + resolve: postViewerActorKey, + }), + viewerCanQuote: t.loadable({ + type: "Boolean", + description: + "Whether the selected viewer account is allowed to quote this post, " + + "based on `quotePolicy`, visibility, and block state. A censored " + + "post cannot be quoted by anyone (including its author or a " + + "moderator), so this is `false` for censored posts. Always `false` " + + "for unauthenticated requests. Pass `actingAccountId` for an " + + "organization perspective.", + args: { + actingAccountId: t.arg.globalID({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + loaderOptions: { cache: false }, + load: async ( + keys: ViewerActorPostKey[], + ctx: UserContext, + ): Promise => { + const policies = await loadViewerActionPolicies(ctx, keys); + return keys.map((key) => + policies.get(viewerActorPostKeyCacheKey(key))?.canQuote ?? false + ); + }, + resolve: postViewerActorKey, + }), + viewerCanRevokeQuote: t.boolean({ + description: + "Whether the authenticated viewer (as the quoted post's author) " + + "can revoke a quote of their post. `true` only when the viewer " + + "is the author of `quotedPost` and the quoting post is either " + + "local or has an authorization IRI. Pass `actingAccountId` for an " + + "organization perspective.", + args: { + actingAccountId: t.arg.globalID({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + select: { + columns: { + id: true, + quotedPostId: true, + quoteAuthorizationIri: true, + }, + with: { + actor: { + columns: { accountId: true }, + }, + quotedPost: { + columns: { actorId: true }, + }, + }, + }, + async resolve(post, args, ctx) { + const viewerActorId = await resolveViewerActorId(ctx, args); + return viewerActorId != null && post.quotedPost != null && + post.quotedPost.actorId === viewerActorId && + (post.actor.accountId != null || post.quoteAuthorizationIri != null); + }, + }), + viewerCanShare: t.loadable({ + type: "Boolean", + description: + "Whether the selected viewer account is allowed to boost this post, " + + "based on visibility and block state. A censored post cannot be " + + "boosted by anyone (including its author or a moderator), so this " + + "is `false` for censored posts. Always `false` for unauthenticated " + + "requests. Pass `actingAccountId` for an organization perspective.", + args: { + actingAccountId: t.arg.globalID({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + loaderOptions: { cache: false }, + load: async ( + keys: ViewerActorPostKey[], + ctx: UserContext, + ): Promise => { + const policies = await loadViewerActionPolicies(ctx, keys); + return keys.map((key) => + policies.get(viewerActorPostKeyCacheKey(key))?.canShare ?? false + ); + }, + resolve: postViewerActorKey, + }), + }), +}); + +export const DENY_ALL_POLICY: PostInteractionPolicy = { + canReply: false, + canQuote: false, + canShare: false, +}; + +export interface ViewerActorPostKey { + postId: Uuid; + viewerActorId: Uuid | null; +} + +export function viewerActorPostKeyCacheKey(key: ViewerActorPostKey): string { + return `${key.viewerActorId ?? ""}:${key.postId}`; +} + +export async function postViewerActorKey( + post: { id: Uuid }, + args: ActingAccountIdArg, + ctx: UserContext, +): Promise { + return { + postId: post.id, + viewerActorId: await resolveViewerActorId(ctx, args), + }; +} + +export async function loadViewerHasShared( + keys: ViewerActorPostKey[], + ctx: UserContext, +): Promise { + const postIdsByViewer = new Map>(); + for (const key of keys) { + if (key.viewerActorId == null) continue; + let postIds = postIdsByViewer.get(key.viewerActorId); + if (postIds == null) { + postIds = new Set(); + postIdsByViewer.set(key.viewerActorId, postIds); + } + postIds.add(key.postId); + } + + const sharedKeys = new Set(); + for (const [viewerActorId, postIds] of postIdsByViewer) { + const rows = await ctx.db.select({ sharedPostId: postTable.sharedPostId }) + .from(postTable) + .where( + and( + eq(postTable.actorId, viewerActorId), + inArray(postTable.sharedPostId, [...postIds]), + ), + ); + for (const row of rows) { + if (row.sharedPostId != null) { + sharedKeys.add(`${viewerActorId}:${row.sharedPostId}`); + } + } + } + + return keys.map((key) => + key.viewerActorId != null && + sharedKeys.has(`${key.viewerActorId}:${key.postId}`) + ); +} + +export async function loadViewerHasPinned( + keys: ViewerActorPostKey[], + ctx: UserContext, +): Promise { + const postIdsByViewer = new Map>(); + for (const key of keys) { + if (key.viewerActorId == null) continue; + let postIds = postIdsByViewer.get(key.viewerActorId); + if (postIds == null) { + postIds = new Set(); + postIdsByViewer.set(key.viewerActorId, postIds); + } + postIds.add(key.postId); + } + + const pinnedKeys = new Set(); + for (const [viewerActorId, postIds] of postIdsByViewer) { + const rows = await ctx.db.select({ postId: pinTable.postId }) + .from(pinTable) + .where( + and( + eq(pinTable.actorId, viewerActorId), + inArray(pinTable.postId, [...postIds]), + ), + ); + for (const row of rows) { + pinnedKeys.add(`${viewerActorId}:${row.postId}`); + } + } + + return keys.map((key) => + key.viewerActorId != null && + pinnedKeys.has(`${key.viewerActorId}:${key.postId}`) + ); +} + +export async function loadViewerActionPolicies( + ctx: UserContext, + keys: readonly ViewerActorPostKey[], +): Promise> { + const cache = ctx.viewerActionPoliciesCache ??= new Map(); + // Dedupe missing ids so a batch with `cache: false` (which may surface + // duplicate keys) cannot overwrite an already-registered promise — the + // overwritten promise would still reject on a batch failure but be + // un-awaited, producing an unhandled rejection. + const missingByViewer = new Map>(); + for (const key of keys) { + const cacheKey = viewerActorPostKeyCacheKey(key); + if (cache.has(cacheKey)) continue; + if (key.viewerActorId == null) { + cache.set(cacheKey, Promise.resolve(DENY_ALL_POLICY)); + continue; + } + let missing = missingByViewer.get(key.viewerActorId); + if (missing == null) { + missing = new Set(); + missingByViewer.set(key.viewerActorId, missing); + } + missing.add(key.postId); + } + for (const [viewerActorId, missing] of missingByViewer) { + // Kick off the batch lookup synchronously and register a derived promise + // per post id before awaiting so that concurrent dispatch from the three + // viewerCan* loaders deduplicates instead of each firing its own query. + // Once the batch settles, drop the cached entries we registered so a + // subsequent resolve pass (e.g., after a follow/block/visibility-changing + // mutation in the same operation) re-queries and observes fresh state — + // matching the `loaderOptions: { cache: false }` semantics on the + // viewer-state fields. + const missingIds = [...missing]; + const batch = getPostInteractionPolicies( + ctx.db, + missingIds, + { id: viewerActorId } as schema.Actor, + ); + const cleanup = () => { + for (const id of missingIds) { + cache.delete(viewerActorPostKeyCacheKey({ + postId: id, + viewerActorId, + })); + } + }; + batch.then(cleanup, cleanup); + for (const id of missingIds) { + const cacheKey = viewerActorPostKeyCacheKey({ + postId: id, + viewerActorId, + }); + cache.set( + cacheKey, + batch.then((policies) => policies.get(id) ?? DENY_ALL_POLICY), + ); + } + } + const entries = await Promise.all( + keys.map(async (key) => { + const cacheKey = viewerActorPostKeyCacheKey(key); + return [cacheKey, await cache.get(cacheKey)!] as const; + }), + ); + return new Map(entries); +} + +export function selectPostRelationWithActor( + nestedSelection: () => unknown, +): Record { + const selection = nestedSelection(); + if (selection == null || typeof selection !== "object") { + return { with: { actor: true } }; + } + const withSelection = "with" in selection && + selection.with != null && + typeof selection.with === "object" + ? selection.with as Record + : {}; + return { + ...selection, + with: { + ...withSelection, + actor: withSelection.actor ?? true, + }, + }; +} + +export function hidePostRelationWithoutActor( + post: T | null | undefined, +): T | null { + if (post == null || typeof post !== "object") return null; + if (!("actor" in post) || post.actor == null) return null; + return post; +} + +// Raw rows to scan per round once the `descendants` page is already full and +// the resolver only needs to confirm one more visible reply exists (for +// `hasNextPage`). Kept small so a probe that finds a survivor early does not +// over-fetch a whole `first`-sized page; a longer run of hidden rows is +// stepped over across the bounded rounds instead. +export const DESCENDANT_PROBE_BATCH = 20; + +// A descendants cursor is base64 of the model layer's DFS path: fixed-width +// `~` elements joined by `/` (the timestamp +// is the node's UTC publish time to microsecond precision). The uuid parts +// double as the entry's strict ancestor chain below the focused post, which +// the resolver uses to drop entries whose subtree root got filtered out on an +// earlier page. +export const descendantPathElement = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}~[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/; + +export function encodeDescendantCursor(path: string): string { + return btoa(path); +} + +export function decodeDescendantCursor(cursor: string): string { + let path: string; + try { + path = atob(cursor); + } catch { + throw createGraphQLError("Malformed `descendants` cursor."); + } + if ( + !path.split("/").every((element) => descendantPathElement.test(element)) + ) { + throw createGraphQLError("Malformed `descendants` cursor."); + } + return path; +} + +export function descendantPathAncestorIds(path: string): Uuid[] { + const elements = path.split("/"); + // Each element is `~`; the uuid (36 chars, no `~`) is the id. + return elements.slice(0, -1).map((element) => + element.slice(element.indexOf("~") + 1) as Uuid + ); +} + +// Whether the given post is visible to the authenticated viewer: per-post +// visibility plus the author's sanction state. Censorship is deliberately +// not part of this check; censored posts stay reachable and self-redact +// their content-bearing fields instead. +export function isPostVisibleToViewer( + ctx: UserContext, + postId: Uuid, + viewerActorId: Uuid | null, +): Promise { + ctx.postVisibleLoader ??= new Map(); + let loader = ctx.postVisibleLoader.get(viewerActorId ?? ""); + if (loader == null) { + loader = new DataLoader( + async (ids) => { + const idList = ids as Uuid[]; + const viewerActor = viewerActorId == null + ? null + : await getActorById(ctx, viewerActorId); + const rows = await ctx.db.query.postTable.findMany({ + columns: { id: true }, + where: { + AND: [ + { id: { in: idList } }, + { actor: getSanctionVisibleActorFilter(ctx.now ??= new Date()) }, + getPostVisibilityFilter(viewerActor), + ], + }, + }); + const visible = new Set(rows.map((row) => row.id)); + return idList.map((id) => visible.has(id)); + }, + ); + ctx.postVisibleLoader.set(viewerActorId ?? "", loader); + } + return loader.load(postId); +} + +// Whether the given post has at least one direct reply visible to the viewer, +// under the same sanction + censorship + visibility filter as the `replies` +// connection. Thread views use this instead of the raw +// `engagementStats.replies` counter: a node whose only replies are hidden +// (followers-only to a stranger, censored, or by a sanctioned author) reports +// `false`, so a "continue this thread" affordance cannot reveal that hidden +// replies exist. Batched (one query per reply page) and keyed by viewer. +export function postHasVisibleReplies( + ctx: UserContext, + postId: Uuid, + viewerActorId: Uuid | null, +): Promise { + ctx.postHasVisibleRepliesLoader ??= new Map(); + let loader = ctx.postHasVisibleRepliesLoader.get(viewerActorId ?? ""); + if (loader == null) { + loader = new DataLoader( + async (ids) => { + const idList = ids as Uuid[]; + const viewerActor = viewerActorId == null + ? null + : await getActorById(ctx, viewerActorId); + const rows = await ctx.db.query.postTable.findMany({ + columns: { replyTargetId: true }, + where: { + AND: [ + { replyTargetId: { in: idList } }, + { actor: getSanctionVisibleActorFilter(ctx.now ??= new Date()) }, + getCensoredPostExclusionFilter(viewerActorId), + getPostVisibilityFilter(viewerActor), + ], + }, + }); + const withReplies = new Set(rows.map((row) => row.replyTargetId)); + return idList.map((id) => withReplies.has(id)); + }, + ); + ctx.postHasVisibleRepliesLoader.set(viewerActorId ?? "", loader); + } + return loader.load(postId); +} + +// Whether the given post has at least one quote visible to the viewer, under +// the same sanction + censorship + visibility filter as the `quotes` +// connection. The news-discussion view uses this instead of the raw +// `engagementStats.quotes` counter so a "show quotes" affordance (which then +// loads an empty list) cannot reveal that hidden quotes exist. Batched (one +// query per page) and keyed by viewer, mirroring `postHasVisibleReplies`. +export function postHasVisibleQuotes( + ctx: UserContext, + postId: Uuid, + viewerActorId: Uuid | null, +): Promise { + ctx.postHasVisibleQuotesLoader ??= new Map(); + let loader = ctx.postHasVisibleQuotesLoader.get(viewerActorId ?? ""); + if (loader == null) { + loader = new DataLoader( + async (ids) => { + const idList = ids as Uuid[]; + const viewerActor = viewerActorId == null + ? null + : await getActorById(ctx, viewerActorId); + const rows = await ctx.db.query.postTable.findMany({ + columns: { quotedPostId: true }, + where: { + AND: [ + { quotedPostId: { in: idList } }, + { actor: getSanctionVisibleActorFilter(ctx.now ??= new Date()) }, + getCensoredPostExclusionFilter(viewerActorId), + getPostVisibilityFilter(viewerActor), + ], + }, + }); + const withQuotes = new Set(rows.map((row) => row.quotedPostId)); + return idList.map((id) => withQuotes.has(id)); + }, + ); + ctx.postHasVisibleQuotesLoader.set(viewerActorId ?? "", loader); + } + return loader.load(postId); +} + +// Backs the `Post.replies` / `Post.quotes` / `Post.shares` connections: +// posts related to `targetId` through `column` (`replyTargetId`, +// `quotedPostId`, or `sharedPostId`), newest first, filtered to those +// visible to the selected viewer account. Resolves the acting account from +// `actingAccountId` (like `ancestors`/`descendants` and `Actor.posts`), so an +// organization perspective sees followers-only interactions the org can see +// but the viewer's personal actor cannot. Censored and sanction-hidden are +// excluded here (these are lists, not the self-redacting permalink). +export async function visibleRelatedPostsPage( + ctx: UserContext, + args: ActingAccountIdArg, + column: "replyTargetId" | "quotedPostId" | "sharedPostId", + targetId: Uuid, + offset: number, + limit: number, +) { + const viewerActorId = await resolveViewerActorId(ctx, args); + const viewerActor = viewerActorId == null + ? null + : await getActorById(ctx, viewerActorId); + const page = await ctx.db.query.postTable.findMany({ + columns: { id: true }, + where: { + AND: [ + { [column]: targetId }, + { actor: getSanctionVisibleActorFilter(ctx.now ??= new Date()) }, + getCensoredPostExclusionFilter(viewerActorId), + getPostVisibilityFilter(viewerActor), + ], + }, + // `id` breaks `published` ties so offset pagination is stable (no + // duplicated or skipped rows across pages when timestamps collide). The + // callback form guarantees both columns order deterministically. + orderBy: (post, { desc }) => [desc(post.published), desc(post.id)], + limit, + offset, + }); + return await loadActorProfilePostPage(ctx, page, viewerActorId); +} + +// Exact count of everything `visibleRelatedPostsPage` would return across all +// pages, for a connection `totalCount` that is not capped by the page size. +// Direct replies/quotes/shares of one post are bounded, so counting ids is +// acceptable; the relational visibility filter cannot be expressed as a plain +// SQL `$count` predicate. `resolveViewerActorId`/`getActorById` are +// per-request cached, so re-resolving them here is free. +export async function countVisibleRelatedPosts( + ctx: UserContext, + args: ActingAccountIdArg, + column: "replyTargetId" | "quotedPostId" | "sharedPostId", + targetId: Uuid, +): Promise { + const viewerActorId = await resolveViewerActorId(ctx, args); + const viewerActor = viewerActorId == null + ? null + : await getActorById(ctx, viewerActorId); + const rows = await ctx.db.query.postTable.findMany({ + columns: { id: true }, + where: { + AND: [ + { [column]: targetId }, + { actor: getSanctionVisibleActorFilter(ctx.now ??= new Date()) }, + getCensoredPostExclusionFilter(viewerActorId), + getPostVisibilityFilter(viewerActor), + ], + }, + }); + return rows.length; +} + +// The id-only companion of loadVisibleThreadPosts, for checking path +// ancestors that never become connection nodes themselves: same filters, +// no relation hydration. +export async function loadVisibleThreadPostIds( + ctx: UserContext, + ids: readonly Uuid[], + viewerActorId: Uuid | null, +): Promise> { + if (ids.length < 1) return new Set(); + const viewerActor = viewerActorId == null + ? null + : await getActorById(ctx, viewerActorId); + const rows = await ctx.db.query.postTable.findMany({ + columns: { id: true }, + where: { + AND: [ + { id: { in: [...ids] } }, + { actor: getSanctionVisibleActorFilter(ctx.now ??= new Date()) }, + getCensoredPostExclusionFilter(viewerActorId), + getPostVisibilityFilter(viewerActor), + ], + }, + }); + return new Set(rows.map((row) => row.id)); +} + +// Loads the given posts with the canonical thread filters (sanction, +// censorship, visibility) applied, keyed by id; absent ids are not visible +// to the viewer. The eager relation set matches what profile/timeline post +// loading uses, since these rows bypass Pothos's nested selection machinery. +export async function loadVisibleThreadPosts( + ctx: UserContext, + ids: readonly Uuid[], + viewerActorId: Uuid | null, +) { + const viewerActor = viewerActorId == null + ? null + : await getActorById(ctx, viewerActorId); + const rows = ids.length < 1 ? [] : await ctx.db.query.postTable.findMany({ + where: { + AND: [ + { id: { in: [...ids] } }, + { actor: getSanctionVisibleActorFilter(ctx.now ??= new Date()) }, + getCensoredPostExclusionFilter(viewerActorId), + getPostVisibilityFilter(viewerActor), + ], + }, + with: actorProfilePostRelations(viewerActorId), + }); + return new Map(rows.map((row) => [row.id, row])); +} + +export type ThreadPostRow = Awaited< + ReturnType +> extends Map ? R : never; + +builder.drizzleInterfaceFields(Post, (t) => ({ + sharedPost: t.field({ + type: Post, + nullable: true, + description: + "The post being boosted. Non-null only for boost wrapper rows. " + + "When this is non-null, `content` is empty and `url` mirrors the " + + "shared post's URL. `null` when the boost wrapper itself is " + + "censored, or its author is hidden by a moderation sanction, and " + + "the viewer is neither the author nor a moderator (what was boosted " + + "is the censored content), and also when the boosted post is not " + + "visible to the viewer (e.g., a followers-only post the viewer does " + + "not follow), so a boost cannot leak its private target.", + args: { + actingAccountId: t.arg.id({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + select: (_, __, nestedSelection) => ({ + columns: { censored: true, actorId: true }, + with: { + actor: sanctionActorSelection, + sharedPost: selectPostRelationWithActor(nestedSelection), + }, + }), + // Timeline model helpers already sanitize nullable post relations whose + // actor disappeared during Drizzle's multi-SELECT hydration, but Pothos + // Drizzle can re-fetch these relations later while resolving nested + // GraphQL fields. If the related post row survives that re-fetch without + // its required actor, hide the nullable relation instead of letting the + // non-null `Post.actor` field fail the whole query. + resolve: async (post, args, ctx) => { + if (isRowCensoredForViewer(post, ctx)) return null; + const sharedPost = hidePostRelationWithoutActor(post.sharedPost); + if (sharedPost == null) return null; + const viewerActorId = await resolveViewerActorId(ctx, args); + return await isPostVisibleToViewer(ctx, sharedPost.id, viewerActorId) + ? sharedPost + : null; + }, + }), + replyTarget: t.field({ + type: Post, + nullable: true, + description: + "The post this post is a reply to. `null` for top-level posts, and " + + "also when the parent is not visible to the authenticated viewer " + + "(e.g., a followers-only post by an actor the viewer does not " + + "follow, or a post whose author is hidden by a moderation " + + "sanction), so a public reply cannot leak its private parent. A " + + "censored parent is still returned and self-redacts its " + + "content-bearing fields. Pass `actingAccountId` for an " + + "organization perspective, matching the perspective of the " + + "surrounding query.", + args: { + actingAccountId: t.arg.id({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + select: (_, __, nestedSelection) => ({ + with: { + replyTarget: selectPostRelationWithActor(nestedSelection), + }, + }), + resolve: async (post, args, ctx) => { + const replyTarget = hidePostRelationWithoutActor(post.replyTarget); + if (replyTarget == null) return null; + const viewerActorId = await resolveViewerActorId(ctx, args); + return await isPostVisibleToViewer(ctx, replyTarget.id, viewerActorId) + ? replyTarget + : null; + }, + }), + quotedPost: t.field({ + type: Post, + nullable: true, + description: + "The post being quoted inline. `null` for posts that are not " + + "quotes, when the quoting post is censored or its author " + + "is hidden by a moderation sanction and the viewer " + + "is neither its author nor a moderator (the quoted target is part " + + "of the censored content), and also when the quoted post is not " + + "visible to the viewer (e.g., a followers-only post the viewer does " + + "not follow), so a public quote cannot leak its private target.", + args: { + actingAccountId: t.arg.id({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + select: (_, __, nestedSelection) => ({ + columns: { censored: true, actorId: true }, + with: { + actor: sanctionActorSelection, + quotedPost: selectPostRelationWithActor(nestedSelection), + }, + }), + resolve: async (post, args, ctx) => { + if (isCensoredForViewer(post, ctx)) return null; + const quotedPost = hidePostRelationWithoutActor(post.quotedPost); + if (quotedPost == null) return null; + const viewerActorId = await resolveViewerActorId(ctx, args); + return await isPostVisibleToViewer(ctx, quotedPost.id, viewerActorId) + ? quotedPost + : null; + }, + }), + replies: t.connection({ + type: Post, + description: + "Posts that are direct replies to this post, newest first. Censored " + + "replies, replies by actors whose content is hidden by a moderation " + + "sanction, and replies not visible to the selected viewer account " + + "(e.g., followers-only replies by actors the viewer does not " + + "follow) are excluded. Pass `actingAccountId` for an organization " + + "perspective.", + args: { + actingAccountId: t.arg.id({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + resolve: async (post, args, ctx) => { + const { edges, pageInfo } = await resolveOffsetConnection( + { args }, + ({ offset, limit }) => + visibleRelatedPostsPage( + ctx, + args, + "replyTargetId", + post.id, + offset, + limit, + ), + ); + return { + edges: [...edges], + pageInfo: { + hasNextPage: pageInfo.hasNextPage, + hasPreviousPage: pageInfo.hasPreviousPage, + startCursor: pageInfo.startCursor, + endCursor: pageInfo.endCursor, + }, + // Carried for the lazy `totalCount` field below. + countTargetId: post.id, + countArgs: args, + }; + }, + }, { + fields: (t) => ({ + totalCount: t.int({ + description: + "Total number of direct replies visible to the selected viewer " + + "account, independent of the current page size. Unlike counting " + + "the fetched edges, this is not capped by `first`, and excludes " + + "the same censored, sanction-hidden, and not-visible replies as " + + "the edges.", + resolve: (connection, _args, ctx) => + countVisibleRelatedPosts( + ctx, + connection.countArgs, + "replyTargetId", + connection.countTargetId, + ), + }), + }), + }), + hasVisibleReplies: t.boolean({ + description: + "Whether this post has at least one direct reply the selected viewer " + + "can see, under the same filter as the `replies` connection (author " + + "sanction state, censorship, and per-post visibility). Prefer this " + + "over `engagementStats.replies > 0` when deciding whether to show a " + + '"continue this thread" affordance in a thread view: the raw counter ' + + "includes replies hidden from the viewer (followers-only, direct, " + + "censored, or by a sanctioned author), so branching on it would " + + "reveal that hidden replies exist. Pass `actingAccountId` for an " + + "organization perspective.", + args: { + actingAccountId: t.arg.id({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + resolve: async (post, args, ctx) => { + const viewerActorId = await resolveViewerActorId(ctx, args); + return await postHasVisibleReplies(ctx, post.id, viewerActorId); + }, + }), + hasVisibleQuotes: t.boolean({ + description: + "Whether this post has at least one quote the selected viewer can see, " + + "under the same filter as the `quotes` connection (author sanction " + + "state, censorship, and per-post visibility). Prefer this over " + + "`engagementStats.quotes > 0` when deciding whether to show a " + + '"show quotes" affordance: the raw counter includes quotes hidden from ' + + "the viewer (followers-only, direct, censored, or by a sanctioned " + + "author), so branching on it would surface an affordance that then " + + "loads an empty list, revealing that hidden quotes exist. Pass " + + "`actingAccountId` for an organization perspective.", + args: { + actingAccountId: t.arg.id({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + resolve: async (post, args, ctx) => { + const viewerActorId = await resolveViewerActorId(ctx, args); + return await postHasVisibleQuotes(ctx, post.id, viewerActorId); + }, + }), + ancestors: t.connection({ + type: Post, + description: + "The chain of posts this post replies to, from the nearest parent " + + "toward the thread root: the first node is the same post as " + + "`replyTarget`, the last is the oldest reachable ancestor. " + + "Ancestors that are censored, whose author is hidden by a " + + "moderation sanction, or that are not visible to the viewer are " + + "omitted from the chain. To detect such gaps, compare a node's " + + "`replyTarget` with the next node: a mismatching id (censored " + + "parent) or a `null` `replyTarget` on a node that is not the last " + + "one (invisible parent) marks a gap, and a last node with a " + + "non-`null` `replyTarget` means the chain continues past what was " + + "returned. The walk is bounded to 200 hops server-side. Pass " + + "`actingAccountId` for an organization perspective.", + args: { + actingAccountId: t.arg.id({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + resolve: async (post, args, ctx) => { + const viewerActorId = await resolveViewerActorId(ctx, args); + const chain = await getAncestorChain(ctx.db, post.id); + const visible = await loadVisibleThreadPosts( + ctx, + chain.map((entry) => entry.id), + viewerActorId, + ); + const nodes = chain.flatMap((entry) => { + const row = visible.get(entry.id); + return row == null ? [] : [row]; + }); + return resolveArrayConnection({ args }, nodes); + }, + }), + descendants: t.connection({ + type: Post, + description: + "Every reply below this post (replies, replies to replies, and so " + + "on), flattened in depth-first order with siblings ordered by " + + "`published`. A node's parent (`replyTarget`) always appears " + + "before the node itself, including across pages, so clients can " + + "rebuild the tree from `replyTarget` ids alone. Subtrees rooted at " + + "a censored post, a post by a sanction-hidden actor, or a post " + + "invisible to the viewer are pruned along with that post. " + + "Traversal depth is capped at `maxDepth`; fetch a deeper branch " + + "from the deepest returned post's own `descendants`. Only forward " + + "pagination (`first`/`after`) is supported. Pass `actingAccountId` " + + "for an organization perspective.", + args: { + maxDepth: t.arg.int({ + description: "Maximum tree depth to traverse below this post (direct " + + "replies are depth 1). Defaults to 20 and is clamped " + + "server-side to 40.", + }), + actingAccountId: t.arg.id({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + resolve: async (post, args, ctx) => { + if (args.last != null || args.before != null) { + throw createGraphQLError( + "`descendants` only supports forward pagination " + + "(`first`/`after`).", + ); + } + const first = Math.min(Math.max(args.first ?? 60, 1), 200); + const maxDepth = Math.min( + Math.max(args.maxDepth ?? 20, 1), + DESCENDANT_TREE_MAX_DEPTH, + ); + const viewerActorId = await resolveViewerActorId(ctx, args); + const edges: { cursor: string; node: ThreadPostRow }[] = []; + let after = args.after == null + ? null + : decodeDescendantCursor(args.after); + // Whether a reply visible to this viewer exists past the emitted page. + // Derived from actually finding one more visible survivor, never from the + // raw `hasMore`: the raw tail can be entirely invisible to this viewer, + // and reporting `hasNextPage: true` off it would leak that hidden replies + // exist (the client would then load a phantom, empty next page). + let sawExtraVisible = false; + // Bound the work per request so a subtree padded with replies hidden + // from this viewer cannot make one request scan without limit. `after` + // (the raw scan position) advances through hidden runs within a request, + // but the returned `endCursor` never does: see below. + const maxRounds = 10; + for (let round = 0; round < maxRounds; round++) { + const remaining = first - edges.length; + const page = await getDescendantPage(ctx.db, post.id, { + after, + // While filling, fetch what is left plus one so a single dense page + // both fills and reveals the next survivor. Once full, only one more + // visible survivor is needed, so fetch a small fixed batch (enough to + // step over a short run of hidden rows) rather than another `first`. + limit: remaining > 0 ? remaining + 1 : DESCENDANT_PROBE_BATCH, + maxDepth, + viewerActorId, + }); + if (page.entries.length < 1) break; + const idsToCheck = new Set(); + for (const entry of page.entries) { + idsToCheck.add(entry.id); + for (const ancestorId of descendantPathAncestorIds(entry.cursor)) { + idsToCheck.add(ancestorId); + } + } + const visibleIds = await loadVisibleThreadPostIds( + ctx, + [...idsToCheck], + viewerActorId, + ); + const survivors = page.entries.filter((entry) => + visibleIds.has(entry.id) && + descendantPathAncestorIds(entry.cursor).every((id) => + visibleIds.has(id) + ) + ); + const rows = await loadVisibleThreadPosts( + ctx, + survivors.map((entry) => entry.id), + viewerActorId, + ); + for (const entry of survivors) { + const row = rows.get(entry.id); + if (row == null) continue; + if (edges.length < first) { + edges.push({ + cursor: encodeDescendantCursor(entry.cursor), + node: row, + }); + } else { + // One visible survivor beyond the emitted page is enough to know a + // real next page exists; do not emit it, the next request will. + sawExtraVisible = true; + break; + } + } + if (sawExtraVisible) break; + after = page.entries[page.entries.length - 1].cursor; + if (!page.hasMore) break; + } + // `endCursor` is only ever a visible edge we emitted, never a hidden + // row: a hidden row's cursor is base64 of its path (its id and publish + // time), so exposing it would disclose that hidden descendants exist and + // leak their identity. A page that emits nothing returns a `null` + // cursor, indistinguishable from a post that has no descendants at all. + const endCursor = edges.length > 0 + ? edges[edges.length - 1].cursor + : null; + // Offer a next page only when the probe actually found one more visible + // reply, never off unread rows alone. Once the page is full, a bounded + // run of replies hidden from this viewer must not surface a "load more" + // that then yields an empty page: that would disclose that hidden + // descendants exist. A visible reply buried under a run of hidden ones + // longer than the probe budget stays reachable through its own permalink. + const hasNextPage = sawExtraVisible; + return { + edges, + pageInfo: { + hasNextPage, + hasPreviousPage: args.after != null, + startCursor: edges.length > 0 ? edges[0].cursor : null, + endCursor, + }, + }; + }, + }), + shares: t.connection({ + type: Post, + description: + "Boost wrapper posts that reshare this post, newest first. Each edge " + + "represents a single boost by a specific actor. Censored boosts " + + "(including boosts of a censored post), boosts by actors whose " + + "content is hidden by a moderation sanction, and boosts not visible " + + "to the selected viewer account (e.g., followers-only boosts by " + + "actors the viewer does not follow) are excluded. Pass " + + "`actingAccountId` for an organization perspective.", + args: { + actingAccountId: t.arg.id({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + resolve: async (post, args, ctx) => { + const { edges, pageInfo } = await resolveOffsetConnection( + { args }, + ({ offset, limit }) => + visibleRelatedPostsPage( + ctx, + args, + "sharedPostId", + post.id, + offset, + limit, + ), + ); + return { + edges: [...edges], + pageInfo: { + hasNextPage: pageInfo.hasNextPage, + hasPreviousPage: pageInfo.hasPreviousPage, + startCursor: pageInfo.startCursor, + endCursor: pageInfo.endCursor, + }, + }; + }, + }), + quotes: t.connection({ + type: Post, + description: + "Posts that quote this post inline, newest first. Censored quotes, " + + "quotes by actors whose content is hidden by a moderation sanction, " + + "and quotes not visible to the selected viewer account (e.g., " + + "followers-only quotes by actors the viewer does not follow) are " + + "excluded. Pass `actingAccountId` for an organization perspective.", + args: { + actingAccountId: t.arg.id({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + resolve: async (post, args, ctx) => { + const { edges, pageInfo } = await resolveOffsetConnection( + { args }, + ({ offset, limit }) => + visibleRelatedPostsPage( + ctx, + args, + "quotedPostId", + post.id, + offset, + limit, + ), + ); + return { + edges: [...edges], + pageInfo: { + hasNextPage: pageInfo.hasNextPage, + hasPreviousPage: pageInfo.hasPreviousPage, + startCursor: pageInfo.startCursor, + endCursor: pageInfo.endCursor, + }, + }; + }, + }), + mentions: t.connection({ + type: Actor, + description: + "Actors explicitly @-mentioned in this post. Does not include " + + "implicit mentions (e.g., the author of the post being replied to). " + + "Empty when the post is censored or its author is hidden by a " + + "moderation sanction, and the viewer is neither the " + + "author nor a moderator, since the mention targets are part of the " + + "censored content.", + select: (args, ctx, nestedSelection) => ({ + columns: { censored: true, actorId: true }, + with: { + actor: sanctionActorSelection, + mentions: mentionConnectionHelpers.getQuery(args, ctx, nestedSelection), + }, + }), + resolve: (post, args, ctx) => + mentionConnectionHelpers.resolve( + isCensoredForViewer(post, ctx) ? [] : post.mentions, + args, + ctx, + ), + }), +})); + +export const Hashtag = builder.simpleObject("Hashtag", { + fields: (t) => ({ + name: t.string(), + href: t.field({ type: "URL" }), + }), +}); + +export const PostEngagementStats = builder.drizzleObject("postTable", { + variant: "PostEngagementStats", + description: + "Cached engagement counters for a post. Updated asynchronously; may " + + "be slightly stale. Query the live connections (`replies`, `shares`, " + + "etc.) directly when exact real-time counts matter.", + fields: (t) => ({ + replies: t.exposeInt("repliesCount"), + shares: t.exposeInt("sharesCount"), + quotes: t.exposeInt("quotesCount"), + reactions: t.exposeInt("reactionsCount"), + bookmarks: t.loadable({ + type: "Int", + // cache: false so a mutation that flips bookmark state in the same + // request (bookmark + read bookmarks + unbookmark + read bookmarks) + // re-queries instead of returning the pre-mutation count. + loaderOptions: { cache: false }, + load: async (postIds: Uuid[], ctx: UserContext): Promise => { + const counts = await getBookmarkCountsForPosts(ctx.db, postIds); + return postIds.map((id) => counts.get(id) ?? 0); + }, + resolve: (post) => post.id, + }), + }), +}); + +builder.drizzleObjectField(PostEngagementStats, "post", (t) => t.variant(Post)); + +export const mentionConnectionHelpers = drizzleConnectionHelpers( + builder, + "mentionTable", + { + select: (nodeSelection) => ({ + with: { + actor: nodeSelection(), + }, + }), + resolveNode: (mention) => mention.actor, + }, +); + +export const PostMediumRef = builder.drizzleNode("postMediumTable", { + name: "PostMedium", + description: + "A media attachment on a post. For local posts this refers to an " + + "uploaded `Medium` stored on this instance; for federated posts the " + + "`url` points to the remote media URL on the originating instance. " + + "Attachments of a censored post, or of a post whose author is hidden " + + "by a moderation sanction, are part of the moderation-hidden content " + + "and are only resolvable by the author and moderators, even through " + + "direct `node(id:)` lookups.", + authScopes: async (medium, ctx) => { + const post = await ctx.db.query.postTable.findFirst({ + where: { id: medium.postId }, + columns: { censored: true, actorId: true }, + with: { actor: sanctionActorSelection }, + }); + if ( + post == null || + post.censored == null && !isActorSanctionHidden(post.actor) + ) { + return true; + } + if (ctx.account?.actor.id === post.actorId) return true; + return { moderator: true }; + }, + runScopesOnType: true, + id: { + column: (medium) => [medium.postId, medium.index], + }, + fields: (t) => ({ + type: t.expose("type", { type: "MediaType" }), + url: t.field({ type: "URL", resolve: (medium) => new URL(medium.url) }), + alt: t.exposeString("alt", { nullable: true }), + width: t.exposeInt("width", { nullable: true }), + height: t.exposeInt("height", { nullable: true }), + sensitive: t.exposeBoolean("sensitive"), + thumbnailUrl: t.string({ + nullable: true, + resolve(medium, _, ctx) { + if (medium.thumbnailKey == null) return; + return ctx.disk.getUrl(medium.thumbnailKey); + }, + }), + }), +}); + +export const Medium = builder.drizzleNode("mediumTable", { + name: "Medium", + description: "A stored media object (image). Two-step upload flow: call " + + "`startMediumUpload` to get a pre-signed upload URL, PUT the image " + + "to that URL, then call `finishMediumUpload` to complete the transaction. " + + "Alternatively, call `createMedium` with a remote URL to import an " + + "image directly. Unreferenced media older than the grace period are " + + "deleted by the `deleteOrphanMedia` mutation. Resolvable via " + + "`node(id:)` when it has at least one reference visible to the viewer: " + + "the avatar of an account that is not banned, or a published post that " + + "is neither censored nor authored by a sanction-hidden actor (the " + + "viewer's own account/posts and moderators always count as visible). " + + "Hidden when it has references but every avatar and post reference is " + + "moderation-hidden for the viewer; fresh, orphan, and draft-only media " + + "(with no such references) remain resolvable.", + authScopes: async (medium, ctx) => { + if (ctx.account?.moderator) return true; + const viewerActorId = ctx.account?.actor.id; + // A medium stays resolvable when it has at least one reference visible + // to this viewer: the avatar of a non-hidden account, or a published + // post that is not censored and whose author is not sanction-hidden + // (or that the viewer authored). A freshly uploaded / orphan / + // draft-only medium has no references and passes (preserving the + // prior unscoped behavior); it is denied only when it has references + // and every one of them is moderation-hidden for the viewer. + const postSelection = { + columns: { censored: true, actorId: true }, + with: { + actor: { + columns: { + accountId: true, + suspended: true, + suspendedUntil: true, + }, + }, + }, + } as const; + const avatarAccounts = await ctx.db.query.accountTable.findMany({ + where: { avatarMediumId: medium.id }, + columns: { id: true }, + with: { + actor: { + columns: { id: true, suspended: true, suspendedUntil: true }, + }, + }, + }); + for (const account of avatarAccounts) { + if (account.actor == null || !isActorProfileHidden(account.actor, ctx)) { + return true; + } + } + const noteMedia = await ctx.db.query.noteSourceMediumTable.findMany({ + where: { mediumId: medium.id }, + columns: { sourceId: true }, + with: { + source: { columns: { id: true }, with: { post: postSelection } }, + }, + }); + for (const { source } of noteMedia) { + const post = source?.post; + if ( + post != null && + (post.actorId === viewerActorId || + (post.censored == null && !isActorSanctionHidden(post.actor))) + ) { + return true; + } + } + const articleMedia = await ctx.db.query.articleSourceMediumTable.findMany({ + where: { mediumId: medium.id }, + columns: { articleSourceId: true }, + with: { + articleSource: { + columns: { id: true }, + with: { post: postSelection }, + }, + }, + }); + for (const { articleSource } of articleMedia) { + const post = articleSource?.post; + if ( + post != null && + (post.actorId === viewerActorId || + (post.censored == null && !isActorSanctionHidden(post.actor))) + ) { + return true; + } + } + const hasReference = avatarAccounts.length > 0 || + noteMedia.length > 0 || articleMedia.length > 0; + return !hasReference; + }, + // Run the scope when the node itself is resolved, so a cached avatar + // medium id cannot bypass the redacted Account.avatarMediumId. + runScopesOnType: true, + id: { + column: (medium) => medium.id, + }, + fields: (t) => ({ + uuid: t.expose("id", { type: "UUID" }), + url: t.field({ + type: "URL", + description: "Public URL for the stored medium.", + resolve: async (medium, _, ctx) => + new URL(await ctx.disk.getUrl(medium.key)), + }), + type: t.expose("type", { + type: "MediaType", + description: "The medium's media type. Local uploads are stored as WebP.", + }), + contentHash: t.expose("contentHash", { + type: "Sha256", + nullable: true, + description: "SHA-256 hash of the normalized stored content, if known.", + }), + width: t.exposeInt("width", { nullable: true }), + height: t.exposeInt("height", { nullable: true }), + created: t.expose("created", { type: "DateTime" }), + }), +}); + +builder.drizzleObjectField(Medium, "generatedAltText", (t) => + t.string({ + nullable: true, + description: "AI-generated alternative text for this medium. " + + "Requires authentication. " + + "Within the 2-hour upload window only the uploader may call this " + + "field; after the window expires any authenticated user may call it " + + "(the medium is either publicly referenced or pending orphan cleanup). " + + "Multiple uploaders of identical content each get independent " + + "ownership entries, so content-hash deduplication does not grant " + + "the later uploader access to the earlier one's window. " + + "High-complexity operation (cost 1000). " + + "The context argument is truncated server-side to 1000 characters.", + complexity: 1000, + args: { + language: t.arg({ type: "Locale", required: true }), + context: t.arg({ type: "String", required: false }), + }, + async resolve(medium, args, ctx) { + const session = await ctx.session; + if (session == null) throw new NotAuthenticatedError(); + const owner = await isMediumOwner(ctx.kv, medium.id, session.accountId); + if (!owner) { + const windowActive = await isMediumUploadWindowActive( + ctx.kv, + medium.id, + ); + if (windowActive) throw new NotAuthorizedError(); + } + const imageUrl = await ctx.disk.getUrl(medium.key); + return await generateAltText({ + model: ctx.altTextGenerator, + imageUrl, + language: (args.language as Intl.Locale).baseName, + context: args.context ?? undefined, + }); + }, + })); + +export const MediumUploadHeader = builder.simpleObject("MediumUploadHeader", { + fields: (t) => ({ + name: t.string(), + value: t.string(), + }), +}); + +export const PostLink = builder.drizzleNode("postLinkTable", { + variant: "PostLink", + description: "OpenGraph / oEmbed metadata for a link embedded in a post. " + + "Populated asynchronously after the post is created; individual " + + "fields may be `null` until the metadata fetch completes or if the " + + "linked page does not expose the corresponding tag. Not resolvable " + + "via `node(id:)` when every post referencing the link is censored or " + + "authored by a sanction-hidden actor, for this viewer: the linked " + + "URL is part of the moderation-hidden content.", + authScopes: (link, ctx) => { + if (ctx.account?.moderator) return true; + // Link rows are shared across posts referencing the same URL, so the + // link counts as hidden only when no referencing post still shows it + // to this viewer: a referencing post must be uncensored AND have a + // sanction-visible author (the same rule post visibility applies), + // or be the viewer's own. Orphan rows with no referencing post + // (e.g. news-only links) pass. Batched through a request-scoped + // loader so link-heavy pages (the news list) stay free of per-link + // lookups. + ctx.postLinkVisibleLoader ??= new DataLoader( + async (linkIds) => { + const ids = [...linkIds]; + const viewerActorId = ctx.account?.actor.id; + // Bind the sanction-activeness comparison to a request-time `Date`, + // the same application clock the write path and `isActorSanctionHidden` + // use, NOT SQL `now()`: inside a transaction `now()` is frozen at the + // transaction start, so a ban recorded later (with `new Date()`) would + // read as not-yet-active and leak the hidden link. + const now = new Date(); + // NULL-safe mirror of isActorSanctionHidden's complement, built with + // drizzle operators so the `now` Date binds as a parameter (a remote + // actor's content is hidden by an active federation block; a local + // actor's only by a permanent ban). `lte`/`gt` on a `null` + // `suspendedUntil` yield `null` (not matched), which is the intended + // NULL-safe behavior. + const shows = and( + isNull(postTable.censored), + or( + isNull(actorTable.suspended), + gt(actorTable.suspended, now), + lte(actorTable.suspendedUntil, now), + and( + isNotNull(actorTable.accountId), + gt(actorTable.suspendedUntil, now), + ), + ), + )!; + const visible = await ctx.db + .selectDistinct({ linkId: postTable.linkId }) + .from(postTable) + .innerJoin(actorTable, eq(postTable.actorId, actorTable.id)) + .where(and( + inArray(postTable.linkId, ids), + viewerActorId == null + ? shows + : or(eq(postTable.actorId, viewerActorId), shows), + )); + const visibleSet = new Set(visible.map((row) => row.linkId)); + if (visibleSet.size === ids.length) return ids.map(() => true); + const referenced = await ctx.db + .selectDistinct({ linkId: postTable.linkId }) + .from(postTable) + .where(inArray(postTable.linkId, ids)); + const referencedSet = new Set(referenced.map((row) => row.linkId)); + return ids.map((id) => visibleSet.has(id) || !referencedSet.has(id)); + }, + ); + return ctx.postLinkVisibleLoader.load(link.id); + }, + // Run the scope when the node itself is resolved, so a cached link + // node id cannot bypass the Post.link redaction via node(id:). + runScopesOnType: true, + id: { + column: (link) => link.id, + }, + fields: (t) => ({ + url: t.field({ + type: "URL", + resolve: (link) => new URL(link.url), + }), + title: t.exposeString("title", { nullable: true }), + siteName: t.exposeString("siteName", { nullable: true }), + type: t.exposeString("type", { nullable: true }), + description: t.exposeString("description", { nullable: true }), + author: t.exposeString("author", { nullable: true }), + image: t.variant(PostLinkImage, { + isNull: (link) => link.imageUrl == null, + }), + creator: t.relation("creator", { nullable: true }), + }), +}); + +export const PostLinkImage = builder.drizzleObject("postLinkTable", { + variant: "PostLinkImage", + fields: (t) => ({ + url: t.field({ + type: "URL", + resolve(link) { + if (link.imageUrl == null) { + unreachable("Expected imageUrl to be not null"); + } + return new URL(link.imageUrl); + }, + }), + alt: t.exposeString("imageAlt", { nullable: true }), + type: t.expose("imageType", { type: "MediaType", nullable: true }), + width: t.exposeInt("imageWidth", { nullable: true }), + height: t.exposeInt("imageHeight", { nullable: true }), + }), +}); + +builder.drizzleObjectField(PostLinkImage, "post", (t) => t.variant(PostLink)); diff --git a/graphql/post/mutations.ts b/graphql/post/mutations.ts new file mode 100644 index 000000000..4f2b845bd --- /dev/null +++ b/graphql/post/mutations.ts @@ -0,0 +1,2958 @@ +// Mutation and query registration for posts. +import { assertNever } from "@std/assert/unstable-never"; +import { and, eq } from "drizzle-orm"; +import { createGraphQLError } from "graphql-yoga"; +import { + createArticle, + deleteArticleDraft, + getOriginalArticleContent, + LanguageChangeWithTranslationsError, + startArticleContentTranslation, + updateArticle, + updateArticleDraft, +} from "@hackerspub/models/article"; +import { + arePostsBookmarkedBy, + createBookmark, + deleteBookmark, +} from "@hackerspub/models/bookmark"; +import { isReactionEmoji, type ReactionEmoji } from "@hackerspub/models/emoji"; +import { normalizeLocale } from "@hackerspub/models/i18n"; +import { + createMediumFromBytes, + createMediumFromUrl, + MAX_STREAMING_MEDIUM_IMAGE_SIZE, + SUPPORTED_MEDIUM_IMAGE_TYPES, + UnsafeMediumUrlError, +} from "@hackerspub/models/medium"; +import { + createNote, + QuotePolicyDeniedError, + updateNote, +} from "@hackerspub/models/note"; +import { OrganizationPermissionError } from "@hackerspub/models/organization"; +import { + pinPost as pinPostModel, + unpinPost as unpinPostModel, +} from "@hackerspub/models/pin"; +import { revokeQuote as revokeQuoteModel } from "@hackerspub/models/post/engagement"; +import { deletePost } from "@hackerspub/models/post/lifecycle"; +import { sharePost, unsharePost } from "@hackerspub/models/post/sharing"; +import { + canActorRequestQuotePost, + getPostVisibilityFilter, + isPostVisibleTo, + normalizeQuotePolicyForVisibility, +} from "@hackerspub/models/post/visibility"; +import { InvalidPollInputError } from "@hackerspub/models/poll"; +import { createQuestion } from "@hackerspub/models/question"; +import { react, undoReaction } from "@hackerspub/models/reaction"; +import { + articleDraftMediumTable, + articleDraftTable, + articleSourceMediumTable, +} from "@hackerspub/models/schema"; +import type * as schema from "@hackerspub/models/schema"; +import { withTransaction } from "@hackerspub/models/tx"; +import { + generateUuidV7, + type Uuid, + validateUuid, +} from "@hackerspub/models/uuid"; +import { Account } from "../account.ts"; +import { + resolveActingAccountForGlobalIdArg, + resolveActingAccountForMutation, +} from "../acting-account.ts"; +import { getActorById } from "../actor.ts"; +import { builder } from "../builder.ts"; +import { + ActorSuspendedError, + InvalidInputError, + NotAuthorizedError, +} from "../error.ts"; +import { lookupPostByUrl, parseHttpUrl } from "../lookup.ts"; +import { + createMediumUploadSession, + deleteMediumUploadSession, + getMediumUploadSession, + isMediumOwner, + isMediumUploadWindowActive, + MEDIUM_UPLOAD_TTL_MS, + setMediumOwner, +} from "../medium-upload.ts"; +import { PostVisibility } from "../postvisibility.ts"; +import { fromQuotePolicy, QuotePolicy } from "../quotepolicy.ts"; +import { CustomEmoji, Reaction } from "../reactable.ts"; +import { NotAuthenticatedError } from "../session.ts"; +import { + actingAccountIdArgDescription, + resolveViewerActorId, +} from "../viewer-actor.ts"; +import { + assertActingAccountNotSuspended, + isPostVisibleToViewer, + LlmTranslationNotAllowedError, + logger, + Medium, + MediumUploadHeader, + Post, + PostAttributionMode, + recordPostActingAccount, + resolvePostActingAccount, + resolvePostManagementActingAccount, + SharedPostDeletionNotAllowedError, +} from "./core.ts"; +import { Note, Question } from "./note.ts"; +import { Article, ArticleDraft } from "./article.ts"; + +export { + hidePostRelationWithoutActor, + isPostVisibleToViewer, + Medium, + Post, + PostLink, + PostType, +} from "./core.ts"; +export { Note, Question } from "./note.ts"; +export { Article, ArticleContent, ArticleDraft } from "./article.ts"; + +const CreateNoteMediumInput = builder.inputType("CreateNoteMediumInput", { + fields: (t) => ({ + mediumId: t.field({ + type: "UUID", + required: true, + description: "UUID of a Medium to attach to the note.", + }), + alt: t.string({ + required: true, + description: "Alternative text for this note's use of the medium.", + }), + }), +}); + +const CreateQuestionPollInput = builder.inputType("CreateQuestionPollInput", { + description: + "Immutable poll settings for `createQuestion`. These settings cannot " + + "be edited after the Question is published.", + fields: (t) => ({ + title: t.string({ + required: true, + description: + "Poll title used as the ActivityPub `Question.name`. Must be " + + "between `1` and `200` characters after trimming.", + }), + multiple: t.boolean({ + required: true, + description: + "Whether voters may choose more than one option. `false` creates " + + "an ActivityPub Question with exclusive options.", + }), + options: t.stringList({ + required: true, + description: + "Poll option labels in display order. Must contain between `2` " + + "and `20` unique, non-empty entries after trimming.", + }), + ends: t.field({ + type: "DateTime", + required: true, + description: + "Voting deadline. Must be at least `1` minute and at most `1` " + + "year in the future.", + }), + }), +}); + +builder.relayMutationField( + "createNote", + { + description: + "Publish a new short-form note. Sends an ActivityPub `Create` " + + "activity to relevant inboxes based on `visibility`. Requires " + + "authentication.", + inputFields: (t) => ({ + visibility: t.field({ type: PostVisibility, required: true }), + content: t.field({ type: "Markdown", required: true }), + language: t.field({ type: "Locale", required: true }), + quotePolicy: t.field({ type: QuotePolicy, required: false }), + actingAccountId: t.globalID({ + for: Account, + required: false, + description: + "Optional `Account` id to publish as. Omit to publish as the " + + "authenticated personal account; pass an organization account " + + "where the viewer is an accepted member to publish as that " + + "organization.", + }), + attributionMode: t.field({ + type: PostAttributionMode, + required: false, + description: + "How to display the personal member when `actingAccountId` is " + + "an organization. Defaults to `ACTING_ACCOUNT_ONLY`; invalid " + + "when publishing as a personal account.", + }), + media: t.field({ + type: [CreateNoteMediumInput], + required: false, + defaultValue: [], + description: "Media to attach to the note, in display order.", + }), + replyTargetId: t.globalID({ + for: [Note, Article, Question], + required: false, + }), + quotedPostId: t.globalID({ + for: [Note, Article, Question], + required: false, + }), + }), + }, + { + errors: { + types: [ + NotAuthenticatedError, + InvalidInputError, + ActorSuspendedError, + OrganizationPermissionError, + ], + }, + async resolve(_root, args, ctx) { + const session = await ctx.session; + if (session == null || ctx.account == null) { + throw new NotAuthenticatedError(); + } + const authenticatedAccountId = ctx.account.id; + const { + visibility, + content, + language, + quotePolicy, + media, + replyTargetId, + quotedPostId, + } = args.input; + const actingAccount = await resolvePostActingAccount(ctx, args.input); + const attachedMedia = media ?? []; + if (attachedMedia.length > 20) { + throw new InvalidInputError("media"); + } + let replyTarget: schema.Post & { actor: schema.Actor } | undefined; + if (replyTargetId != null) { + const post = await ctx.db.query.postTable.findFirst({ + with: { + actor: { + with: { + followers: { + where: { followerId: actingAccount.account.actor.id }, + }, + blockees: { + where: { blockeeId: actingAccount.account.actor.id }, + }, + blockers: { + where: { blockerId: actingAccount.account.actor.id }, + }, + }, + }, + mentions: { where: { actorId: actingAccount.account.actor.id } }, + sharedPost: { + with: { + actor: { + with: { + followers: { + where: { followerId: actingAccount.account.actor.id }, + }, + blockees: { + where: { blockeeId: actingAccount.account.actor.id }, + }, + blockers: { + where: { blockerId: actingAccount.account.actor.id }, + }, + }, + }, + mentions: { + where: { actorId: actingAccount.account.actor.id }, + }, + }, + }, + }, + where: { id: replyTargetId.id }, + }); + if (post == null) { + throw new InvalidInputError("replyTargetId"); + } + const effectivePost = post.sharedPost ?? post; + if ( + !isPostVisibleTo(post, actingAccount.account.actor) || + !isPostVisibleTo(effectivePost, actingAccount.account.actor) + ) { + throw new InvalidInputError("replyTargetId"); + } + replyTarget = post; + } + let quotedPost: schema.Post & { actor: schema.Actor } | undefined; + if (quotedPostId != null) { + const post = await ctx.db.query.postTable.findFirst({ + with: { + actor: { + with: { + followers: { + where: { followerId: actingAccount.account.actor.id }, + }, + blockees: { + where: { blockeeId: actingAccount.account.actor.id }, + }, + blockers: { + where: { blockerId: actingAccount.account.actor.id }, + }, + }, + }, + mentions: { where: { actorId: actingAccount.account.actor.id } }, + sharedPost: { + with: { + actor: { + with: { + followers: { + where: { followerId: actingAccount.account.actor.id }, + }, + blockees: { + where: { blockeeId: actingAccount.account.actor.id }, + }, + blockers: { + where: { blockerId: actingAccount.account.actor.id }, + }, + }, + }, + mentions: { + where: { actorId: actingAccount.account.actor.id }, + }, + }, + }, + }, + where: { id: quotedPostId.id }, + }); + if ( + post == null || !isPostVisibleTo(post, actingAccount.account.actor) + ) { + throw new InvalidInputError("quotedPostId"); + } + // Validate against the effective original post to prevent bypassing + // via a public share wrapper of a non-quotable original. + const effectivePost = post.sharedPost ?? post; + if (effectivePost.sharedPostId != null) { + throw new InvalidInputError("quotedPostId"); + } + if (!isPostVisibleTo(effectivePost, actingAccount.account.actor)) { + throw new InvalidInputError("quotedPostId"); + } + // Neither a censored post nor a censored share wrapper can be + // quoted; the model revalidates, but the submitted row is + // unwrapped here, so the wrapper must be checked here too. + if (post.censored != null || effectivePost.censored != null) { + throw new InvalidInputError("quotedPostId"); + } + if ( + !canActorRequestQuotePost(effectivePost, actingAccount.account.actor) + ) { + throw new InvalidInputError("quotedPostId"); + } + quotedPost = effectivePost; + } + return await withTransaction(ctx.fedCtx, async (context) => { + const noteMedia = await Promise.all( + attachedMedia.map(async (medium, i) => { + const alt = medium.alt.trim(); + if (alt === "") throw new InvalidInputError(`media.${i}.alt`); + const storedMedium = await context.db.query.mediumTable + .findFirst({ + where: { id: medium.mediumId }, + }); + if (storedMedium == null) { + throw new InvalidInputError(`media.${i}.mediumId`); + } + return { mediumId: medium.mediumId, alt }; + }), + ); + let note: Awaited>; + await assertActingAccountNotSuspended( + ctx.db, + authenticatedAccountId, + actingAccount.account.id, + ); + try { + note = await createNote( + context, + { + accountId: actingAccount.account.id, + visibility: visibility === "PUBLIC" + ? "public" + : visibility === "UNLISTED" + ? "unlisted" + : visibility === "FOLLOWERS" + ? "followers" + : visibility === "DIRECT" + ? "direct" + : visibility === "NONE" + ? "none" + : assertNever( + visibility, + `Unknown value in Post.visibility: "${visibility}"`, + ), + quotePolicy: normalizeQuotePolicyForVisibility( + visibility === "PUBLIC" + ? "public" + : visibility === "UNLISTED" + ? "unlisted" + : visibility === "FOLLOWERS" + ? "followers" + : visibility === "DIRECT" + ? "direct" + : visibility === "NONE" + ? "none" + : assertNever( + visibility, + `Unknown value in Post.visibility: "${visibility}"`, + ), + quotePolicy == null ? undefined : fromQuotePolicy(quotePolicy), + ), + content, + language: language.baseName, + media: noteMedia, + }, + { replyTarget, quotedPost }, + { + afterPostCreated: (post, db) => + recordPostActingAccount(db, post.id, actingAccount), + }, + ); + } catch (error) { + if (error instanceof QuotePolicyDeniedError) { + throw new InvalidInputError("quotedPostId"); + } + throw error; + } + if (note == null) { + throw createGraphQLError("Failed to create note.", { + originalError: new Error("Failed to create note."), + extensions: { code: "INTERNAL_SERVER_ERROR" }, + }); + } + return note; + }); + }, + }, + { + outputFields: (t) => ({ + note: t.field({ + type: Note, + resolve(result) { + return result; + }, + }), + }), + }, +); + +builder.relayMutationField( + "createQuestion", + { + description: + "Publish a new short-form post with an immutable ActivityPub " + + "`Question` poll. Sends an ActivityPub `Create` activity to relevant " + + "inboxes based on `visibility`. Requires authentication.", + inputFields: (t) => ({ + visibility: t.field({ + type: PostVisibility, + required: true, + description: "Audience for the new Question post.", + }), + content: t.field({ + type: "Markdown", + required: true, + description: + "Markdown body shown above the poll. The body remains editable " + + "only through future note-editing support; poll settings are " + + "immutable.", + }), + language: t.field({ + type: "Locale", + required: true, + description: "BCP 47 language tag for the Question body.", + }), + quotePolicy: t.field({ + type: QuotePolicy, + required: false, + description: + "Who may quote this Question. Omit to use the default policy for " + + "the selected `visibility`.", + }), + actingAccountId: t.globalID({ + for: Account, + required: false, + description: + "Optional `Account` id to publish as. Omit to publish as the " + + "authenticated personal account; pass an organization account " + + "where the viewer is an accepted member to publish as that " + + "organization.", + }), + attributionMode: t.field({ + type: PostAttributionMode, + required: false, + description: + "How to display the personal member when `actingAccountId` is " + + "an organization. Defaults to `ACTING_ACCOUNT_ONLY`; invalid " + + "when publishing as a personal account.", + }), + poll: t.field({ + type: CreateQuestionPollInput, + required: true, + description: + "Poll title, options, selection mode, and deadline. These values " + + "cannot be changed after publishing.", + }), + media: t.field({ + type: [CreateNoteMediumInput], + required: false, + defaultValue: [], + description: "Media to attach to the Question body, in display order.", + }), + replyTargetId: t.globalID({ + for: [Note, Article, Question], + required: false, + description: + "Optional post to reply to. The target must be visible to the " + + "authenticated account.", + }), + quotedPostId: t.globalID({ + for: [Note, Article, Question], + required: false, + description: + "Optional post to quote. Share wrappers are resolved to their " + + "original post before quote policy checks.", + }), + }), + }, + { + errors: { + types: [ + NotAuthenticatedError, + InvalidInputError, + ActorSuspendedError, + OrganizationPermissionError, + ], + }, + async resolve(_root, args, ctx) { + const session = await ctx.session; + if (session == null || ctx.account == null) { + throw new NotAuthenticatedError(); + } + const authenticatedAccountId = ctx.account.id; + const { + visibility, + content, + language, + quotePolicy, + poll, + media, + replyTargetId, + quotedPostId, + } = args.input; + const actingAccount = await resolvePostActingAccount(ctx, args.input); + const attachedMedia = media ?? []; + if (attachedMedia.length > 20) { + throw new InvalidInputError("media"); + } + if (visibility === "NONE") { + throw new InvalidInputError("visibility"); + } + let replyTarget: schema.Post & { actor: schema.Actor } | undefined; + if (replyTargetId != null) { + const post = await ctx.db.query.postTable.findFirst({ + with: { + actor: { + with: { + followers: { + where: { followerId: actingAccount.account.actor.id }, + }, + blockees: { + where: { blockeeId: actingAccount.account.actor.id }, + }, + blockers: { + where: { blockerId: actingAccount.account.actor.id }, + }, + }, + }, + mentions: { where: { actorId: actingAccount.account.actor.id } }, + sharedPost: { + with: { + actor: { + with: { + followers: { + where: { followerId: actingAccount.account.actor.id }, + }, + blockees: { + where: { blockeeId: actingAccount.account.actor.id }, + }, + blockers: { + where: { blockerId: actingAccount.account.actor.id }, + }, + }, + }, + mentions: { + where: { actorId: actingAccount.account.actor.id }, + }, + }, + }, + }, + where: { id: replyTargetId.id }, + }); + if (post == null) { + throw new InvalidInputError("replyTargetId"); + } + const effectivePost = post.sharedPost ?? post; + if ( + !isPostVisibleTo(post, actingAccount.account.actor) || + !isPostVisibleTo(effectivePost, actingAccount.account.actor) + ) { + throw new InvalidInputError("replyTargetId"); + } + replyTarget = post; + } + let quotedPost: schema.Post & { actor: schema.Actor } | undefined; + if (quotedPostId != null) { + const post = await ctx.db.query.postTable.findFirst({ + with: { + actor: { + with: { + followers: { + where: { followerId: actingAccount.account.actor.id }, + }, + blockees: { + where: { blockeeId: actingAccount.account.actor.id }, + }, + blockers: { + where: { blockerId: actingAccount.account.actor.id }, + }, + }, + }, + mentions: { where: { actorId: actingAccount.account.actor.id } }, + sharedPost: { + with: { + actor: { + with: { + followers: { + where: { followerId: actingAccount.account.actor.id }, + }, + blockees: { + where: { blockeeId: actingAccount.account.actor.id }, + }, + blockers: { + where: { blockerId: actingAccount.account.actor.id }, + }, + }, + }, + mentions: { + where: { actorId: actingAccount.account.actor.id }, + }, + }, + }, + }, + where: { id: quotedPostId.id }, + }); + if ( + post == null || !isPostVisibleTo(post, actingAccount.account.actor) + ) { + throw new InvalidInputError("quotedPostId"); + } + const effectivePost = post.sharedPost ?? post; + if (effectivePost.sharedPostId != null) { + throw new InvalidInputError("quotedPostId"); + } + if (!isPostVisibleTo(effectivePost, actingAccount.account.actor)) { + throw new InvalidInputError("quotedPostId"); + } + // Neither a censored post nor a censored share wrapper can be + // quoted; the model revalidates, but the submitted row is + // unwrapped here, so the wrapper must be checked here too. + if (post.censored != null || effectivePost.censored != null) { + throw new InvalidInputError("quotedPostId"); + } + if ( + !canActorRequestQuotePost(effectivePost, actingAccount.account.actor) + ) { + throw new InvalidInputError("quotedPostId"); + } + quotedPost = effectivePost; + } + return await withTransaction(ctx.fedCtx, async (context) => { + const noteMedia = await Promise.all( + attachedMedia.map(async (medium, i) => { + const alt = medium.alt.trim(); + if (alt === "") throw new InvalidInputError(`media.${i}.alt`); + const storedMedium = await context.db.query.mediumTable + .findFirst({ + where: { id: medium.mediumId }, + }); + if (storedMedium == null) { + throw new InvalidInputError(`media.${i}.mediumId`); + } + return { mediumId: medium.mediumId, alt }; + }), + ); + const modelVisibility = visibility === "PUBLIC" + ? "public" + : visibility === "UNLISTED" + ? "unlisted" + : visibility === "FOLLOWERS" + ? "followers" + : visibility === "DIRECT" + ? "direct" + : visibility === "NONE" + ? "none" + : assertNever( + visibility, + `Unknown value in Post.visibility: "${visibility}"`, + ); + let question: Awaited>; + await assertActingAccountNotSuspended( + ctx.db, + authenticatedAccountId, + actingAccount.account.id, + ); + try { + question = await createQuestion( + context, + { + accountId: actingAccount.account.id, + visibility: modelVisibility, + quotePolicy: normalizeQuotePolicyForVisibility( + modelVisibility, + quotePolicy == null ? undefined : fromQuotePolicy(quotePolicy), + ), + content, + language: language.baseName, + media: noteMedia, + poll: { + title: poll.title, + multiple: poll.multiple, + options: poll.options, + ends: poll.ends, + }, + }, + { replyTarget, quotedPost }, + { + afterPostCreated: (post, db) => + recordPostActingAccount(db, post.id, actingAccount), + }, + ); + } catch (error) { + if (error instanceof QuotePolicyDeniedError) { + throw new InvalidInputError("quotedPostId"); + } + if (error instanceof InvalidPollInputError) { + throw new InvalidInputError(error.inputPath); + } + throw error; + } + if (question == null) { + throw createGraphQLError("Failed to create question.", { + originalError: new Error("Failed to create question."), + extensions: { code: "INTERNAL_SERVER_ERROR" }, + }); + } + return question; + }); + }, + }, + { + outputFields: (t) => ({ + question: t.field({ + type: Question, + description: "The newly published `Question` post.", + resolve(result) { + return result; + }, + }), + }), + }, +); + +builder.relayMutationField( + "updateNote", + { + description: + "Edit the content, language, or quote policy of an existing local " + + "note. Visibility cannot be changed after creation. Only the note's " + + "author may call this. Pass `actingAccountId` when editing a note " + + "authored by an organization account you belong to. Sends an " + + "ActivityPub `Update` activity to the appropriate recipients. " + + "Requires authentication.", + inputFields: (t) => ({ + noteId: t.globalID({ + for: [Note], + required: true, + description: "Global ID of the note to update.", + }), + actingAccountId: t.globalID({ + for: [Account], + required: false, + description: actingAccountIdArgDescription, + }), + content: t.field({ + type: "Markdown", + required: false, + description: "New Markdown body. Omit to keep the existing content.", + }), + language: t.field({ + type: "Locale", + required: false, + description: "New language. Omit to keep the existing language.", + }), + quotePolicy: t.field({ + type: QuotePolicy, + required: false, + description: "New quote policy. Omit to keep the existing policy.", + }), + }), + }, + { + errors: { + types: [ + NotAuthenticatedError, + InvalidInputError, + OrganizationPermissionError, + ], + }, + async resolve(_root, args, ctx) { + if (ctx.account == null) throw new NotAuthenticatedError(); + + const post = await ctx.db.query.postTable.findFirst({ + where: { id: args.input.noteId.id }, + with: { noteSource: true }, + }); + if (post?.noteSource == null) throw new InvalidInputError("noteId"); + await resolvePostManagementActingAccount( + ctx, + args.input, + post.noteSource.accountId, + "noteId", + ); + + const patch = { + ...(args.input.content != null ? { content: args.input.content } : {}), + ...(args.input.language != null + ? { language: args.input.language.baseName } + : {}), + ...(args.input.quotePolicy != null + ? { quotePolicy: fromQuotePolicy(args.input.quotePolicy) } + : {}), + }; + if (Object.keys(patch).length === 0) { + throw new InvalidInputError("input"); + } + const updated = await updateNote(ctx.fedCtx, post.noteSource.id, patch); + if (updated == null) throw new InvalidInputError("noteId"); + return updated; + }, + }, + { + outputFields: (t) => ({ + note: t.field({ + type: Note, + description: "The note after the update has been applied.", + resolve: (post) => post, + }), + }), + }, +); + +builder.relayMutationField( + "saveArticleDraft", + { + description: + "Create or update an article draft. Omit `id` to create a new draft. " + + "Requires authentication.", + inputFields: (t) => ({ + id: t.globalID({ for: [ArticleDraft], required: false }), + uuid: t.field({ + type: "UUID", + required: false, + description: "Draft UUID to use when creating a new draft.", + }), + title: t.string({ required: true }), + content: t.field({ type: "Markdown", required: true }), + tags: t.stringList({ required: true }), + }), + }, + { + errors: { + types: [ + NotAuthenticatedError, + InvalidInputError, + ], + }, + async resolve(_root, args, ctx) { + const session = await ctx.session; + if (session == null) { + throw new NotAuthenticatedError(); + } + const { id, title, content, tags } = args.input; + if (id != null && args.input.uuid != null) { + throw new InvalidInputError("uuid"); + } + + const draft = await updateArticleDraft(ctx.db, { + id: id?.id ?? args.input.uuid ?? generateUuidV7(), + accountId: session.accountId, + title, + content, + tags, + }); + if (draft == null) { + throw new InvalidInputError(args.input.uuid == null ? "id" : "uuid"); + } + + return draft; + }, + }, + { + outputFields: (t) => ({ + draft: t.field({ + type: ArticleDraft, + resolve(result) { + return result; + }, + }), + }), + }, +); + +builder.relayMutationField( + "deleteArticleDraft", + { + description: + "Permanently delete an article draft. Only the draft's owner may " + + "delete it. Requires authentication.", + inputFields: (t) => ({ + id: t.globalID({ for: [ArticleDraft], required: true }), + }), + }, + { + errors: { + types: [ + NotAuthenticatedError, + InvalidInputError, + ], + }, + async resolve(_root, args, ctx) { + const session = await ctx.session; + if (session == null) { + throw new NotAuthenticatedError(); + } + + const deleted = await deleteArticleDraft( + ctx.db, + session.accountId, + args.input.id.id, + ); + + if (!deleted) { + throw new InvalidInputError("id"); + } + + return { deletedDraftId: args.input.id.id }; + }, + }, + { + outputFields: (t) => ({ + deletedDraftId: t.globalID({ + resolve(result) { + return { type: "ArticleDraft", id: result.deletedDraftId }; + }, + }), + }), + }, +); + +builder.relayMutationField( + "deletePost", + { + description: "Delete a post and send an ActivityPub `Delete` activity to " + + "federated instances. Boost wrappers cannot be deleted this way; " + + "use `unsharePost` instead (returns `SharedPostDeletionNotAllowedError`). " + + "Pass `actingAccountId` to delete a post authored by an organization " + + "account you belong to.", + inputFields: (t) => ({ + id: t.globalID({ + for: [Note, Article, Question], + required: true, + description: "Global ID of the post to delete.", + }), + actingAccountId: t.globalID({ + for: [Account], + required: false, + description: actingAccountIdArgDescription, + }), + }), + }, + { + errors: { + types: [ + NotAuthenticatedError, + InvalidInputError, + SharedPostDeletionNotAllowedError, + OrganizationPermissionError, + ], + }, + async resolve(_root, args, ctx) { + if (ctx.account == null) { + throw new NotAuthenticatedError(); + } + + const post = await ctx.db.query.postTable.findFirst({ + with: { actor: true, replyTarget: true }, + where: { id: args.input.id.id }, + }); + + if (post == null || post.actor.accountId == null) { + throw new InvalidInputError("id"); + } + await resolvePostManagementActingAccount( + ctx, + args.input, + post.actor.accountId, + "id", + ); + + if (post.sharedPostId != null) { + throw new SharedPostDeletionNotAllowedError("id"); + } + + await deletePost(ctx.fedCtx, post); + + return { deletedPostId: args.input.id }; + }, + }, + { + outputFields: (t) => ({ + deletedPostId: t.globalID({ + resolve(result) { + return { + type: result.deletedPostId.typename, + id: result.deletedPostId.id, + }; + }, + }), + }), + }, +); + +builder.relayMutationField( + "revokeQuote", + { + description: + "As the quoted post's author, revoke permission for a quote of " + + "your post. Sends a revocation activity to the quoting instance. " + + "Only the `quotedPost`'s author may call this. Pass `actingAccountId` " + + "when the quoted post was authored by an organization account you " + + "belong to.", + inputFields: (t) => ({ + quotePostId: t.globalID({ + for: [Note, Article, Question], + required: true, + description: "Global ID of the quote post to revoke.", + }), + actingAccountId: t.globalID({ + for: [Account], + required: false, + description: actingAccountIdArgDescription, + }), + }), + }, + { + errors: { + types: [ + NotAuthenticatedError, + InvalidInputError, + OrganizationPermissionError, + ], + }, + async resolve(_root, args, ctx) { + if (ctx.account == null) { + throw new NotAuthenticatedError(); + } + const actingAccount = await resolveActingAccountForMutation( + ctx, + args.input, + ); + const quote = await ctx.db.query.postTable.findFirst({ + with: { + actor: true, + quotedPost: true, + }, + where: { id: args.input.quotePostId.id }, + }); + if (quote?.quotedPost == null) { + throw new InvalidInputError("quotePostId"); + } + if (quote.quotedPost.actorId !== actingAccount.actor.id) { + throw new InvalidInputError("quotePostId"); + } + if ( + quote.actor.accountId == null && quote.quoteAuthorizationIri == null + ) { + throw new InvalidInputError("quotePostId"); + } + const updatedQuote = await withTransaction( + ctx.fedCtx, + async (context) => + await revokeQuoteModel( + context, + actingAccount, + quote, + quote.quotedPost!, + ), + ); + const quotedPost = await ctx.db.query.postTable.findFirst({ + where: { id: quote.quotedPost.id }, + }); + if (quotedPost == null) throw new InvalidInputError("quotePostId"); + return { quote: updatedQuote, quotedPost }; + }, + }, + { + outputFields: (t) => ({ + quote: t.field({ + type: Post, + resolve(result) { + return result.quote; + }, + }), + quotedPost: t.field({ + type: Post, + resolve(result) { + return result.quotedPost; + }, + }), + }), + }, +); + +builder.relayMutationField( + "publishArticleDraft", + { + description: + "Publish an article draft, converting it to a live `Article` post " + + "and deleting the draft. Sends an ActivityPub `Create` activity. " + + "Requires authentication.", + inputFields: (t) => ({ + id: t.globalID({ for: [ArticleDraft], required: true }), + slug: t.string({ required: true }), + language: t.field({ type: "Locale", required: true }), + allowLlmTranslation: t.boolean({ required: false }), + quotePolicy: t.field({ type: QuotePolicy, required: false }), + actingAccountId: t.globalID({ + for: Account, + required: false, + description: + "Optional `Account` id to publish as. The draft must still be " + + "owned by the authenticated personal account; this only changes " + + "the published article's author.", + }), + attributionMode: t.field({ + type: PostAttributionMode, + required: false, + description: + "How to display the personal member when `actingAccountId` is " + + "an organization. Defaults to `ACTING_ACCOUNT_ONLY`; invalid " + + "when publishing as a personal account.", + }), + }), + }, + { + errors: { + types: [ + NotAuthenticatedError, + InvalidInputError, + ActorSuspendedError, + OrganizationPermissionError, + ], + }, + async resolve(_root, args, ctx) { + const session = await ctx.session; + if (session == null || ctx.account == null) { + throw new NotAuthenticatedError(); + } + const authenticatedAccountId = ctx.account.id; + const actingAccount = await resolvePostActingAccount(ctx, args.input); + + // Get draft + const drafts = await ctx.db + .select() + .from(articleDraftTable) + .where( + and( + eq(articleDraftTable.id, args.input.id.id), + eq(articleDraftTable.accountId, session.accountId), + ), + ) + .limit(1); + const draft = drafts[0]; + + if (!draft) { + throw new InvalidInputError("id"); + } + + const { slug, language, allowLlmTranslation, quotePolicy } = args.input; + + await assertActingAccountNotSuspended( + ctx.db, + authenticatedAccountId, + actingAccount.account.id, + ); + + // Create article from draft + const article = await withTransaction(ctx.fedCtx, async (context) => { + const media = await context.db.query.articleDraftMediumTable + .findMany({ + where: { articleDraftId: draft.id }, + }); + const created = await createArticle(context, { + accountId: actingAccount.account.id, + publishedYear: new Date().getFullYear(), + slug, + tags: draft.tags, + allowLlmTranslation: allowLlmTranslation ?? true, + quotePolicy: quotePolicy == null + ? "everyone" + : fromQuotePolicy(quotePolicy), + title: draft.title, + content: draft.content, + language: language.baseName, + media, + }, { + afterPostCreated: (post, db) => + recordPostActingAccount(db, post.id, actingAccount), + }); + return created; + }); + + if (!article) { + throw createGraphQLError("Failed to publish article.", { + originalError: new Error("Failed to publish article."), + extensions: { code: "INTERNAL_SERVER_ERROR" }, + }); + } + // Delete draft after successful publish + await deleteArticleDraft(ctx.db, session.accountId, draft.id); + + return { article, deletedDraftId: draft.id }; + }, + }, + { + outputFields: (t) => ({ + article: t.field({ + type: Article, + resolve(result) { + return result.article; + }, + }), + deletedDraftId: t.globalID({ + resolve(result) { + return { type: "ArticleDraft", id: result.deletedDraftId }; + }, + }), + }), + }, +); + +builder.drizzleObjectField( + Reaction, + "post", + (t) => t.relation("post", { type: Post }), +); + +builder.relayMutationField( + "addReactionToPost", + { + description: + "Add an emoji reaction to a post. Sends an ActivityPub `Like` " + + "or `EmojiReact` activity. Idempotent: adding the same emoji twice " + + "has no effect. Exactly one of `emoji` or `customEmojiId` must be " + + "provided. Requires authentication.", + inputFields: (t) => ({ + postId: t.globalID({ + for: [Note, Article, Question], + required: true, + }), + actingAccountId: t.globalID({ + for: Account, + required: false, + description: + "Optional `Account` id to react as. Omit to react as the " + + "authenticated personal account; pass an organization account " + + "where the viewer is an accepted member to react as that " + + "organization.", + }), + emoji: t.string({ required: false }), + customEmojiId: t.globalID({ for: CustomEmoji, required: false }), + }), + }, + { + errors: { + types: [ + NotAuthenticatedError, + InvalidInputError, + ActorSuspendedError, + OrganizationPermissionError, + ], + }, + async resolve(_root, args, ctx) { + if (ctx.account == null) { + throw new NotAuthenticatedError(); + } + const authenticatedAccountId = ctx.account.id; + const actingAccount = await resolveActingAccountForMutation( + ctx, + args.input, + ); + + const { postId, emoji, customEmojiId } = args.input; + + if (emoji == null && customEmojiId == null) { + throw new InvalidInputError("emoji"); + } + if (emoji != null && customEmojiId != null) { + throw new InvalidInputError("emoji"); + } + if (emoji != null && !isReactionEmoji(emoji)) { + throw new InvalidInputError("emoji"); + } + + const post = await ctx.db.query.postTable.findFirst({ + with: { + actor: { + with: { + followers: true, + blockees: true, + blockers: true, + }, + }, + replyTarget: { + with: { actor: true }, + }, + // Load the boosted post's author so a wrapper of a sanction-hidden + // actor's post is correctly hidden by isPostVisibleTo (which now + // fails closed when a wrapper's sharedPost is not loaded). + sharedPost: { with: { actor: true } }, + mentions: true, + }, + where: { id: postId.id }, + }); + + if (post == null) { + throw new InvalidInputError("postId"); + } + + if (!isPostVisibleTo(post, actingAccount.actor)) { + throw new InvalidInputError("postId"); + } + + await assertActingAccountNotSuspended( + ctx.db, + authenticatedAccountId, + actingAccount.id, + ); + + const reaction = await react( + ctx.fedCtx, + actingAccount, + post, + emoji as ReactionEmoji | null ?? null, + customEmojiId?.id as Uuid | undefined, + ); + + if (reaction != null) { + return reaction; + } + + const existingReaction = await ctx.db.query.reactionTable.findFirst({ + where: emoji != null + ? { postId: post.id, actorId: actingAccount.actor.id, emoji } + : { + postId: post.id, + actorId: actingAccount.actor.id, + customEmojiId: customEmojiId!.id as Uuid, + }, + }); + + if (existingReaction != null) { + return existingReaction; + } + + throw createGraphQLError("Failed to react to the post.", { + originalError: new Error("Failed to react to the post."), + extensions: { code: "INTERNAL_SERVER_ERROR" }, + }); + }, + }, + { + outputFields: (t) => ({ + reaction: t.drizzleField({ + type: Reaction, + nullable: true, + resolve(_query, result) { + return result; + }, + }), + }), + }, +); + +builder.relayMutationField( + "removeReactionFromPost", + { + description: + "Remove an emoji reaction from a post. Sends an ActivityPub `Undo " + + "Like` activity. Idempotent: removing a reaction that doesn't exist " + + "returns `success: true`. Exactly one of `emoji` or `customEmojiId` " + + "must be provided. Requires authentication.", + inputFields: (t) => ({ + postId: t.globalID({ + for: [Note, Article, Question], + required: true, + }), + actingAccountId: t.globalID({ + for: Account, + required: false, + description: + "Optional `Account` id to remove the reaction as. Omit to use " + + "the authenticated personal account; pass an organization account " + + "where the viewer is an accepted member to remove that " + + "organization's reaction.", + }), + emoji: t.string({ required: false }), + customEmojiId: t.globalID({ for: CustomEmoji, required: false }), + }), + }, + { + errors: { + types: [ + NotAuthenticatedError, + InvalidInputError, + OrganizationPermissionError, + ], + }, + async resolve(_root, args, ctx) { + const actingAccount = await resolveActingAccountForMutation( + ctx, + args.input, + ); + + const { postId, emoji, customEmojiId } = args.input; + + if (emoji == null && customEmojiId == null) { + throw new InvalidInputError("emoji"); + } + if (emoji != null && customEmojiId != null) { + throw new InvalidInputError("emoji"); + } + if (emoji != null && !isReactionEmoji(emoji)) { + throw new InvalidInputError("emoji"); + } + + const post = await ctx.db.query.postTable.findFirst({ + with: { + actor: { + with: { + followers: true, + blockees: true, + blockers: true, + }, + }, + replyTarget: { + with: { actor: true }, + }, + mentions: true, + // A boost shows the wrapper id, so undoing a reaction on it must + // hydrate sharedPost: isPostVisibleTo() fails closed on a wrapper + // whose boosted post was not loaded. + sharedPost: { with: { actor: true } }, + }, + where: { id: postId.id }, + }); + + if (post == null) { + throw new InvalidInputError("postId"); + } + + if (!isPostVisibleTo(post, actingAccount.actor)) { + throw new InvalidInputError("postId"); + } + + await undoReaction( + ctx.fedCtx, + actingAccount, + post, + emoji as ReactionEmoji | null ?? null, + customEmojiId?.id as Uuid | undefined, + ); + + return { success: true }; + }, + }, + { + outputFields: (t) => ({ + success: t.boolean({ + resolve() { + return true; + }, + }), + }), + }, +); + +builder.relayMutationField( + "sharePost", + { + description: + "Boost (reshare) a post by creating a share wrapper post and " + + "sending an ActivityPub `Announce` activity. Returns the wrapper " + + "post as `share` and the original post as `originalPost`. " + + "Requires authentication.", + inputFields: (t) => ({ + postId: t.globalID({ + for: [Note, Article, Question], + required: true, + }), + actingAccountId: t.globalID({ + for: Account, + required: false, + description: + "Optional `Account` id to boost as. Omit to boost as the " + + "authenticated personal account; pass an organization account " + + "where the viewer is an accepted member to boost as that " + + "organization.", + }), + }), + }, + { + errors: { + types: [ + NotAuthenticatedError, + InvalidInputError, + ActorSuspendedError, + OrganizationPermissionError, + ], + }, + async resolve(_root, args, ctx) { + if (ctx.account == null) { + throw new NotAuthenticatedError(); + } + const authenticatedAccountId = ctx.account.id; + const actingAccount = await resolveActingAccountForMutation( + ctx, + args.input, + ); + + const { postId } = args.input; + + const post = await ctx.db.query.postTable.findFirst({ + with: { + actor: { + with: { + followers: true, + blockees: true, + blockers: true, + }, + }, + replyTarget: { + with: { actor: true }, + }, + mentions: true, + sharedPost: { + with: { + actor: { + with: { + followers: true, + blockees: true, + blockers: true, + }, + }, + mentions: true, + }, + }, + }, + where: { id: postId.id }, + }); + + if (post == null) { + throw new InvalidInputError("postId"); + } + + if (!isPostVisibleTo(post, actingAccount.actor)) { + throw new InvalidInputError("postId"); + } + + // Validate sharing eligibility against the effective original post. + // When the submitted post is itself a share wrapper, the sharing rules + // apply to the original post's visibility, not the wrapper's. + // Reject nested wrappers (share-of-share chains) outright; only + // direct originals (sharedPostId == null) are authoritative. + const effectivePost = post.sharedPost ?? post; + if (effectivePost.sharedPostId != null) { + throw new InvalidInputError("postId"); + } + if (!isPostVisibleTo(effectivePost, actingAccount.actor)) { + throw new InvalidInputError("postId"); + } + // A censored post cannot be boosted (by anyone, including its + // author): the wrapper would copy the censored content and + // federate an `Announce` re-amplifying moderation-hidden content. + if (post.censored != null || effectivePost.censored != null) { + throw new InvalidInputError("postId"); + } + if ( + effectivePost.visibility !== "public" && + effectivePost.visibility !== "unlisted" && + !(effectivePost.visibility === "followers" && + effectivePost.actorId === actingAccount.actor.id) + ) { + throw new InvalidInputError("postId"); + } + + await assertActingAccountNotSuspended( + ctx.db, + authenticatedAccountId, + actingAccount.id, + ); + + const share = await sharePost( + ctx.fedCtx, + actingAccount, + post, + ); + + return { + share, + originalPostId: postId.id, + }; + }, + }, + { + outputFields: (t) => ({ + share: t.field({ + type: Post, + resolve(result) { + return result.share; + }, + }), + originalPost: t.drizzleField({ + type: Post, + async resolve(query, result, _args, ctx) { + const post = await ctx.db.query.postTable.findFirst( + query({ where: { id: result.originalPostId } }), + ); + return post!; + }, + }), + }), + }, +); + +builder.relayMutationField( + "unsharePost", + { + description: + "Undo a boost by deleting the share wrapper post and sending an " + + "ActivityPub `Undo Announce` activity. Pass the original post's " + + "`id` (not the wrapper's). Requires authentication.", + inputFields: (t) => ({ + postId: t.globalID({ + for: [Note, Article, Question], + required: true, + }), + actingAccountId: t.globalID({ + for: Account, + required: false, + description: + "Optional `Account` id to undo a boost as. Omit to use the " + + "authenticated personal account; pass an organization account " + + "where the viewer is an accepted member to remove that " + + "organization's boost.", + }), + }), + }, + { + errors: { + types: [ + NotAuthenticatedError, + InvalidInputError, + OrganizationPermissionError, + ], + }, + async resolve(_root, args, ctx) { + const actingAccount = await resolveActingAccountForMutation( + ctx, + args.input, + ); + + const { postId } = args.input; + + const post = await ctx.db.query.postTable.findFirst({ + with: { + actor: { + with: { + followers: true, + blockees: true, + blockers: true, + }, + }, + replyTarget: { + with: { actor: true }, + }, + mentions: true, + // Hydrate sharedPost so a boost wrapper passed by id is judged by + // isPostVisibleTo() instead of failing closed on the unloaded + // boosted post. + sharedPost: { with: { actor: true } }, + }, + where: { id: postId.id }, + }); + + if (post == null) { + throw new InvalidInputError("postId"); + } + + if (!isPostVisibleTo(post, actingAccount.actor)) { + throw new InvalidInputError("postId"); + } + + const unshared = await unsharePost( + ctx.fedCtx, + actingAccount, + post, + ); + + if (unshared == null) { + throw new InvalidInputError("postId"); + } + + return { success: true, originalPostId: postId.id }; + }, + }, + { + outputFields: (t) => ({ + originalPost: t.drizzleField({ + type: Post, + async resolve(query, result, _args, ctx) { + const post = await ctx.db.query.postTable.findFirst( + query({ where: { id: result.originalPostId } }), + ); + return post!; + }, + }), + }), + }, +); + +builder.relayMutationField( + "bookmarkPost", + { + description: + "Save a post to the viewer's bookmarks. Bookmarks are private and " + + "not federated. Idempotent. Requires authentication.", + inputFields: (t) => ({ + postId: t.globalID({ + for: [Note, Article, Question], + required: true, + }), + }), + }, + { + errors: { + types: [ + NotAuthenticatedError, + InvalidInputError, + ], + }, + async resolve(_root, args, ctx) { + if (ctx.account == null) { + throw new NotAuthenticatedError(); + } + + const { postId } = args.input; + + const post = await ctx.db.query.postTable.findFirst({ + with: { + actor: { + with: { + followers: true, + blockees: true, + blockers: true, + }, + }, + mentions: true, + // A boost shows the wrapper id, so bookmarking one must hydrate + // sharedPost: isPostVisibleTo() fails closed on a wrapper whose + // boosted post was not loaded. + sharedPost: { with: { actor: true } }, + }, + where: { id: postId.id }, + }); + + if (post == null) { + throw new InvalidInputError("postId"); + } + + if (!isPostVisibleTo(post, ctx.account.actor)) { + throw new InvalidInputError("postId"); + } + + await createBookmark(ctx.db, ctx.account, post); + + return { postId: postId.id }; + }, + }, + { + outputFields: (t) => ({ + post: t.drizzleField({ + type: Post, + async resolve(query, result, _args, ctx) { + const post = await ctx.db.query.postTable.findFirst( + query({ where: { id: result.postId } }), + ); + return post!; + }, + }), + }), + }, +); + +builder.relayMutationField( + "unbookmarkPost", + { + description: + "Remove a post from the viewer's bookmarks. Idempotent. Requires authentication.", + inputFields: (t) => ({ + postId: t.globalID({ + for: [Note, Article, Question], + required: true, + }), + }), + }, + { + errors: { + types: [ + NotAuthenticatedError, + InvalidInputError, + ], + }, + async resolve(_root, args, ctx) { + if (ctx.account == null) { + throw new NotAuthenticatedError(); + } + + const { postId } = args.input; + + const alreadyBookmarked = + (await arePostsBookmarkedBy(ctx.db, [postId.id], ctx.account)) + .has(postId.id); + + const post = await ctx.db.query.postTable.findFirst({ + with: { + actor: { + with: { + followers: true, + blockees: true, + blockers: true, + }, + }, + mentions: true, + // Mirror `bookmarkPost`: a boost wrapper's boosted post must be + // hydrated so `isPostVisibleTo` does not fail closed on it. + sharedPost: { with: { actor: true } }, + }, + where: { id: postId.id }, + }); + + if (post == null) { + throw new InvalidInputError("postId"); + } + + // Removing an existing bookmark is always allowed, even if the post has + // since become invisible to the viewer (they bookmarked it while it was + // visible). The visibility gate applies only when there is no bookmark + // to remove, so this mutation cannot be used as an oracle to probe a + // post the viewer cannot see via its `post` output field + // (`deleteBookmark` is otherwise a silent no-op). + if (!alreadyBookmarked && !isPostVisibleTo(post, ctx.account.actor)) { + throw new InvalidInputError("postId"); + } + + await deleteBookmark(ctx.db, ctx.account, post); + + return { postId: postId.id, unbookmarkedPostId: postId }; + }, + }, + { + outputFields: (t) => ({ + post: t.drizzleField({ + type: Post, + // Nullable and visibility-gated: removing an owned bookmark is + // allowed even after the post became invisible to the viewer, but + // the payload must not then re-expose that post's content. Returns + // `null` when the post is no longer visible; the client can still + // reconcile its cache from `unbookmarkedPostId`. + nullable: true, + async resolve(query, result, _args, ctx) { + const viewerActorId = ctx.account?.actor.id ?? null; + if (!await isPostVisibleToViewer(ctx, result.postId, viewerActorId)) { + return null; + } + const post = await ctx.db.query.postTable.findFirst( + query({ where: { id: result.postId } }), + ); + return post ?? null; + }, + }), + unbookmarkedPostId: t.globalID({ + resolve(result) { + return { + type: result.unbookmarkedPostId.typename, + id: result.unbookmarkedPostId.id, + }; + }, + }), + }), + }, +); + +builder.relayMutationField( + "pinPost", + { + description: + "Pin a post to the top of the viewer's profile. Only the post's " + + "author may pin it. Pass `actingAccountId` to pin an organization " + + "post to that organization's profile. Requires authentication.", + inputFields: (t) => ({ + postId: t.globalID({ + for: [Note, Article, Question], + required: true, + description: "Global ID of the post to pin.", + }), + actingAccountId: t.globalID({ + for: [Account], + required: false, + description: actingAccountIdArgDescription, + }), + }), + }, + { + errors: { + types: [ + NotAuthenticatedError, + InvalidInputError, + OrganizationPermissionError, + ], + }, + async resolve(_root, args, ctx) { + const actingAccount = await resolveActingAccountForMutation( + ctx, + args.input, + ); + + const { postId } = args.input; + + const post = await ctx.db.query.postTable.findFirst({ + where: { id: postId.id }, + }); + + if (post == null) { + throw new InvalidInputError("postId"); + } + + const pin = await pinPostModel(ctx.fedCtx, actingAccount.actor, post); + if (pin == null) { + throw new InvalidInputError("postId"); + } + + return { postId: postId.id }; + }, + }, + { + outputFields: (t) => ({ + post: t.drizzleField({ + type: Post, + async resolve(query, result, _args, ctx) { + const post = await ctx.db.query.postTable.findFirst( + query({ where: { id: result.postId } }), + ); + return post!; + }, + }), + }), + }, +); + +builder.relayMutationField( + "unpinPost", + { + description: + "Remove a pin from the viewer's profile. Pass `actingAccountId` to " + + "remove a pin from an organization profile. Requires authentication.", + inputFields: (t) => ({ + postId: t.globalID({ + for: [Note, Article, Question], + required: true, + description: "Global ID of the post to unpin.", + }), + actingAccountId: t.globalID({ + for: [Account], + required: false, + description: actingAccountIdArgDescription, + }), + }), + }, + { + errors: { + types: [ + NotAuthenticatedError, + InvalidInputError, + OrganizationPermissionError, + ], + }, + async resolve(_root, args, ctx) { + const actingAccount = await resolveActingAccountForMutation( + ctx, + args.input, + ); + + const { postId } = args.input; + + const post = await ctx.db.query.postTable.findFirst({ + where: { id: postId.id }, + }); + + if (post == null) { + throw new InvalidInputError("postId"); + } + + const pin = await unpinPostModel(ctx.fedCtx, actingAccount.actor, post); + if (pin == null) { + throw new InvalidInputError("postId"); + } + + return { postId: postId.id, unpinnedPostId: postId }; + }, + }, + { + outputFields: (t) => ({ + post: t.drizzleField({ + type: Post, + async resolve(query, result, _args, ctx) { + const post = await ctx.db.query.postTable.findFirst( + query({ where: { id: result.postId } }), + ); + return post!; + }, + }), + unpinnedPostId: t.globalID({ + resolve(result) { + return { + type: result.unpinnedPostId.typename, + id: result.unpinnedPostId.id, + }; + }, + }), + }), + }, +); + +builder.queryField("articleDraft", (t) => + t.field({ + type: ArticleDraft, + nullable: true, + description: "Look up an article draft by its global `id` or its `uuid`. " + + "Requires authentication; only returns drafts owned by the " + + "authenticated viewer.", + args: { + id: t.arg.globalID({ for: [ArticleDraft], required: false }), + uuid: t.arg({ type: "UUID", required: false }), + }, + async resolve(_root, args, ctx) { + if (ctx.account == null) return null; + + // At least one of id or uuid must be provided + if (!args.id && !args.uuid) { + throw createGraphQLError("Either id or uuid must be provided.", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + // Use uuid if provided, otherwise use id + const draftId = args.uuid ?? args.id!.id; + + const drafts = await ctx.db + .select() + .from(articleDraftTable) + .where( + and( + eq(articleDraftTable.id, draftId), + eq(articleDraftTable.accountId, ctx.account.id), + ), + ) + .limit(1); + + return drafts[0] ?? null; + }, + })); + +builder.queryField("postByUrl", (t) => + t.field({ + type: Post, + nullable: true, + description: + "Resolve a post by its URL, fetching it from the originating " + + "instance via ActivityPub if it is not already cached. Requires " + + "authentication (unauthenticated callers always receive `null`). " + + "Returns `null` if the post is not found or not visible to the " + + "selected viewer account. Pass `actingAccountId` when validating a " + + "quote target for an organization account.", + args: { + url: t.arg.string({ required: true }), + actingAccountId: t.arg.id({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + async resolve(_root, args, ctx) { + if (ctx.account == null) return null; + const parsed = parseHttpUrl(args.url.trim()); + if (parsed == null) return null; + const actingAccount = await resolveActingAccountForGlobalIdArg( + ctx, + args, + ); + const viewerActor = actingAccount.actor; + const looked = await lookupPostByUrl(ctx, parsed); + if (looked == null) return null; + const postId = looked.id; + const withRelations = { + actor: { + with: { + followers: { + where: { followerId: viewerActor.id }, + }, + blockees: { + where: { blockeeId: viewerActor.id }, + }, + blockers: { + where: { blockerId: viewerActor.id }, + }, + }, + }, + mentions: true, + // A boost URL resolves to the wrapper, so hydrate sharedPost: + // isPostVisibleTo() fails closed on a wrapper whose boosted post was + // not loaded (and this keeps a boost of a sanction-hidden author + // hidden here too). + sharedPost: { with: { actor: true } }, + } as const; + const post = await ctx.db.query.postTable.findFirst({ + with: withRelations, + where: { id: postId }, + }); + if (post == null) return null; + if (!isPostVisibleTo(post, viewerActor)) return null; + return post; + }, + })); + +builder.queryField("articleByYearAndSlug", (t) => + t.drizzleField({ + type: Article, + nullable: true, + description: "Look up a locally-authored article by the author's handle, " + + "publication year, and URL slug. This is the resolver for the " + + "canonical article permalink path `/@{handle}/{year}/{slug}`.", + args: { + handle: t.arg.string({ required: true }), + idOrYear: t.arg.string({ required: true }), + slug: t.arg.string({ required: true }), + actingAccountId: t.arg.id({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + async resolve(query, _, args, ctx) { + if (!/^\d+$/.test(args.idOrYear)) return null; + const year = parseInt(args.idOrYear, 10); + if (!Number.isFinite(year)) return null; + + let handle = args.handle; + if (handle.startsWith("@")) handle = handle.substring(1); + const split = handle.split("@"); + + let actor; + if (split.length === 2) { + const [username, host] = split; + actor = await ctx.db.query.actorTable.findFirst({ + where: { + username, + OR: [{ instanceHost: host }, { handleHost: host }], + }, + }); + } else if (split.length === 1) { + actor = await ctx.db.query.actorTable.findFirst({ + where: { username: split[0], accountId: { isNotNull: true } }, + }); + } + if (actor == null) return null; + + // Only local actors have articles with sources + if (actor.accountId == null) return null; + + const account = await ctx.db.query.accountTable.findFirst({ + where: { id: actor.accountId }, + }); + if (account == null) return null; + + const source = await ctx.db.query.articleSourceTable.findFirst({ + where: { + accountId: account.id, + publishedYear: year, + slug: args.slug, + }, + }); + if (source == null) return null; + + const viewerActorId = await resolveViewerActorId(ctx, args); + const viewerActor = viewerActorId == null + ? null + : await getActorById(ctx, viewerActorId); + const visibility = getPostVisibilityFilter(viewerActor); + return await ctx.db.query.postTable.findFirst( + query({ + where: { + AND: [ + { + type: "Article", + actorId: actor.id, + articleSourceId: source.id, + }, + visibility, + ], + }, + }), + ) ?? null; + }, + })); + +const UpdateArticleMediumInput = builder.inputType("UpdateArticleMediumInput", { + fields: (t) => ({ + mediumId: t.field({ + type: "UUID", + required: true, + description: "UUID of a Medium to make available to the article source.", + }), + key: t.string({ + required: false, + description: + "Key used in article markdown as hp-medium:KEY. Defaults to mediumId.", + }), + }), +}); + +builder.relayMutationField( + "updateArticle", + { + description: + "Edit an existing article's content, title, or tags. Only the " + + "article's author may update it. Sends an ActivityPub `Update` " + + "activity. Pass `actingAccountId` when updating an article authored " + + "by an organization account you belong to. Requires authentication.", + inputFields: (t) => ({ + articleId: t.globalID({ + for: [Article], + required: true, + description: "Global ID of the article to update.", + }), + actingAccountId: t.globalID({ + for: [Account], + required: false, + description: actingAccountIdArgDescription, + }), + title: t.string({ required: false }), + content: t.field({ type: "Markdown", required: false }), + tags: t.stringList({ required: false }), + language: t.field({ type: "Locale", required: false }), + allowLlmTranslation: t.boolean({ required: false }), + media: t.field({ + type: [UpdateArticleMediumInput], + required: false, + description: + "Media to make available to hp-medium:KEY references in the updated article markdown.", + }), + }), + }, + { + errors: { + types: [ + NotAuthenticatedError, + InvalidInputError, + OrganizationPermissionError, + ], + }, + async resolve(_root, args, ctx) { + if (ctx.account == null) { + throw new NotAuthenticatedError(); + } + + const articleId = args.input.articleId.id; + // Find the post and its articleSource + const post = await ctx.db.query.postTable.findFirst({ + where: { id: articleId }, + with: { articleSource: true }, + }); + if (post == null || post.articleSource == null) { + throw new InvalidInputError("articleId"); + } + + await resolvePostManagementActingAccount( + ctx, + args.input, + post.articleSource.accountId, + "articleId", + ); + + const media: { key: string; mediumId: Uuid }[] = []; + for (const [i, mediumInput] of (args.input.media ?? []).entries()) { + const medium = await ctx.db.query.mediumTable.findFirst({ + where: { id: mediumInput.mediumId }, + }); + if (medium == null) { + throw new InvalidInputError(`media.${i}.mediumId`); + } + const key = mediumInput.key?.trim() || medium.id; + if (!key.match(/^[A-Za-z0-9._:/-]+$/)) { + throw new InvalidInputError(`media.${i}.key`); + } + media.push({ key, mediumId: medium.id }); + } + + let updated; + try { + updated = await updateArticle(ctx.fedCtx, post.articleSource.id, { + title: args.input.title ?? undefined, + content: args.input.content ?? undefined, + tags: args.input.tags ?? undefined, + language: args.input.language?.baseName ?? undefined, + allowLlmTranslation: args.input.allowLlmTranslation ?? undefined, + media: args.input.media == null ? undefined : media, + }); + } catch (e) { + if (e instanceof LanguageChangeWithTranslationsError) { + throw new InvalidInputError("language"); + } + throw e; + } + if (updated == null) { + throw new InvalidInputError("articleId"); + } + + return updated; + }, + }, + { + outputFields: (t) => ({ + article: t.field({ + type: Article, + resolve: (post) => post, + }), + }), + }, +); + +builder.relayMutationField( + "requestArticleTranslation", + { + description: + "Request an LLM translation of an article into the given target " + + "language. Returns immediately; the translated `ArticleContent` " + + "appears asynchronously (check `beingTranslated`). Returns " + + "`LlmTranslationNotAllowedError` if translation is disabled on " + + "the article or the target language matches the source language.", + inputFields: (t) => ({ + articleId: t.globalID({ for: [Article], required: true }), + targetLanguage: t.field({ type: "Locale", required: true }), + }), + }, + { + errors: { + types: [ + NotAuthenticatedError, + InvalidInputError, + LlmTranslationNotAllowedError, + ], + }, + async resolve(_root, args, ctx) { + const session = await ctx.session; + if (session == null || ctx.account == null) { + throw new NotAuthenticatedError(); + } + + const post = await ctx.db.query.postTable.findFirst({ + where: { id: args.input.articleId.id }, + with: { + actor: { + with: { + followers: true, + blockees: true, + blockers: true, + }, + }, + mentions: true, + articleSource: { + with: { contents: true }, + }, + }, + }); + if ( + post == null || + post.type !== "Article" || + post.articleSource == null || + !isPostVisibleTo(post, ctx.account.actor) + ) { + throw new InvalidInputError("articleId"); + } + if (!post.articleSource.allowLlmTranslation) { + throw new LlmTranslationNotAllowedError("DISABLED"); + } + const original = getOriginalArticleContent(post.articleSource); + if (original == null) { + throw new InvalidInputError("articleId"); + } + // The `Locale` scalar accepts any syntactically valid BCP 47 + // tag, but the `[lang]` route only serves locales that pass + // `normalizeLocale` (i.e. the `POSSIBLE_LOCALES` whitelist + // used across the project). Run the same check here so an + // API client cannot enqueue a translation for a tag the + // canonical article URL flow will never display. + const targetLanguage = normalizeLocale( + args.input.targetLanguage.baseName, + ); + if (targetLanguage == null) { + throw new InvalidInputError("targetLanguage"); + } + // Reject targets that share the source's *language and script* + // subtags after maximization (so `en` -> `en-US` and `ko` -> + // `ko-KR` are blocked because they both maximize to the same + // `language`+`script` pair, but `zh-CN` -> `zh-TW` is allowed + // because Simplified vs Traditional script genuinely produces a + // different translation output). `Article.contents` negotiates + // among available locales rather than requiring exact tags, so + // permitting a same-script variant would create a redundant + // placeholder row whose canonical URL would negotiate back to + // the existing source content and leave the newly inserted row + // unreachable; a different-script variant has its own canonical + // URL slot in the negotiation result. + const targetMax = new Intl.Locale(targetLanguage).maximize(); + const originalMax = new Intl.Locale(original.language).maximize(); + if ( + targetMax.language === originalMax.language && + targetMax.script === originalMax.script + ) { + throw new LlmTranslationNotAllowedError("SAME_LANGUAGE"); + } + + // Skip enqueueing if a *completed* translation for the target + // locale already exists. `startArticleContentTranslation` is + // already idempotent against this case (it returns early without + // calling the translator), but checking here against the + // already-fetched `articleSource.contents` lets the resolver + // avoid the extra DB round-trip and makes the no-op intent + // explicit at the resolver layer. In-progress and stale rows + // are intentionally not short-circuited here so the model layer + // can re-queue them per its 30-minute staleness window. + const alreadyTranslated = post.articleSource.contents.some( + (c) => + !c.beingTranslated && normalizeLocale(c.language) === targetLanguage, + ); + if (!alreadyTranslated) { + await startArticleContentTranslation(ctx.fedCtx, { + content: original, + targetLanguage, + requester: ctx.account, + }); + } + + return post; + }, + }, + { + outputFields: (t) => ({ + article: t.field({ + type: Article, + resolve: (post) => post, + }), + }), + }, +); + +builder.relayMutationField( + "createMedium", + { + description: + "Import a media object from a remote URL and store it locally. " + + "Use this instead of the two-step `startMediumUpload` / " + + "`finishMediumUpload` flow when the image is already accessible " + + "via URL. Requires authentication.", + inputFields: (t) => ({ + url: t.field({ + type: "URL", + required: true, + description: + "Image URL to import. Data URLs, HTTP, and HTTPS are supported.", + }), + }), + }, + { + errors: { + types: [ + NotAuthenticatedError, + InvalidInputError, + ], + }, + async resolve(_root, args, ctx) { + const session = await ctx.session; + if (session == null) { + throw new NotAuthenticatedError(); + } + let medium: schema.Medium | undefined; + try { + medium = await createMediumFromUrl( + ctx.db, + ctx.disk, + args.input.url, + { userAgentUrl: new URL(ctx.fedCtx.canonicalOrigin) }, + ); + } catch (error) { + if (!(error instanceof UnsafeMediumUrlError)) throw error; + } + if (medium == null) { + throw new InvalidInputError("url"); + } + // Record the importing account as the medium owner during the upload + // window. Without this, content-hash deduplication can return a row + // owned by another account, and downstream owner-gated operations + // (e.g. `attachArticleSourceMedium`) would then reject this user + // even though they performed the import themselves. Mirrors the + // ownership marker that `finishMediumUpload` sets. + await setMediumOwner(ctx.kv, medium.id, session.accountId); + return medium; + }, + }, + { + outputFields: (t) => ({ + medium: t.field({ + type: Medium, + resolve(result) { + return result; + }, + }), + }), + }, +); + +interface MediumUploadStart { + uploadId: Uuid; + uploadUrl: URL; + method: string; + headers: { name: string; value: string }[]; + expires: Date; +} + +builder.relayMutationField( + "startMediumUpload", + { + description: + "Step 1 of direct image upload: obtain a pre-signed URL and headers " + + "for a PUT request. After uploading, call `finishMediumUpload` with " + + "the returned `uploadId`. Requires authentication.", + inputFields: (t) => ({ + contentType: t.field({ + type: "MediaType", + required: true, + description: "Original image content type.", + }), + contentLength: t.int({ + required: true, + description: "Exact number of bytes the client will upload.", + }), + }), + }, + { + errors: { types: [NotAuthenticatedError, InvalidInputError] }, + async resolve(_root, args, ctx) { + const session = await ctx.session; + if (session == null) throw new NotAuthenticatedError(); + if ( + !SUPPORTED_MEDIUM_IMAGE_TYPES.includes( + args.input.contentType as typeof SUPPORTED_MEDIUM_IMAGE_TYPES[number], + ) + ) { + throw new InvalidInputError("contentType"); + } + if ( + args.input.contentLength < 1 || + args.input.contentLength > MAX_STREAMING_MEDIUM_IMAGE_SIZE + ) { + throw new InvalidInputError("contentLength"); + } + const upload = await createMediumUploadSession( + ctx.kv, + session.accountId, + args.input.contentType, + args.input.contentLength, + ); + let uploadUrl: URL; + try { + uploadUrl = new URL( + await ctx.disk.getSignedUploadUrl(upload.key, { + contentType: upload.contentType, + contentSize: upload.contentLength, + contentLength: upload.contentLength, + expiresIn: "30mins", + }), + ); + } catch { + uploadUrl = new URL(`/medium-uploads/${upload.id}`, ctx.request.url); + uploadUrl.searchParams.set("token", upload.token); + } + return { + uploadId: upload.id, + uploadUrl, + method: "PUT", + headers: [{ name: "Content-Type", value: upload.contentType }], + expires: new Date(Date.now() + MEDIUM_UPLOAD_TTL_MS), + } satisfies MediumUploadStart; + }, + }, + { + outputFields: (t) => ({ + uploadId: t.field({ + type: "UUID", + resolve: (result) => result.uploadId, + }), + uploadUrl: t.field({ + type: "URL", + resolve: (result) => result.uploadUrl, + }), + method: t.string({ resolve: (result) => result.method }), + headers: t.field({ + type: [MediumUploadHeader], + resolve: (result) => result.headers, + }), + expires: t.field({ + type: "DateTime", + resolve: (result) => result.expires, + }), + }), + }, +); + +builder.relayMutationField( + "finishMediumUpload", + { + description: "Step 2 of direct image upload: confirm that the PUT to the " + + "pre-signed URL from `startMediumUpload` has completed, returning " + + "the resulting `Medium`. Requires authentication.", + inputFields: (t) => ({ + uploadId: t.field({ type: "UUID", required: true }), + }), + }, + { + errors: { types: [NotAuthenticatedError, InvalidInputError] }, + async resolve(_root, args, ctx) { + const session = await ctx.session; + if (session == null) throw new NotAuthenticatedError(); + const upload = await getMediumUploadSession(ctx.kv, args.input.uploadId); + if (upload == null || upload.accountId !== session.accountId) { + throw new InvalidInputError("uploadId"); + } + try { + let metadata: { contentLength: number }; + try { + metadata = await ctx.disk.getMetaData(upload.key); + } catch { + throw new InvalidInputError("uploadId"); + } + if ( + metadata.contentLength !== upload.contentLength || + metadata.contentLength > MAX_STREAMING_MEDIUM_IMAGE_SIZE + ) { + throw new InvalidInputError("uploadId"); + } + let bytes: Uint8Array; + try { + bytes = await ctx.disk.getBytes(upload.key); + } catch { + throw new InvalidInputError("uploadId"); + } + if (bytes.byteLength !== upload.contentLength) { + throw new InvalidInputError("uploadId"); + } + const medium = await createMediumFromBytes(ctx.db, ctx.disk, bytes, { + maxSize: MAX_STREAMING_MEDIUM_IMAGE_SIZE, + contentType: upload.contentType, + }); + if (medium == null) throw new InvalidInputError("uploadId"); + await setMediumOwner(ctx.kv, medium.id, session.accountId); + return medium; + } finally { + try { + await ctx.disk.delete(upload.key); + } catch (error) { + logger.warn( + "Failed to delete temporary medium upload {key}: {error}", + { + key: upload.key, + error, + }, + ); + } + try { + await deleteMediumUploadSession(ctx.kv, upload.id); + } catch (error) { + logger.warn("Failed to delete medium upload session {id}: {error}", { + id: upload.id, + error, + }); + } + } + }, + }, + { + outputFields: (t) => ({ + medium: t.field({ + type: Medium, + resolve(result) { + return result; + }, + }), + }), + }, +); + +interface AttachedArticleDraftMedium { + key: string; + medium: schema.Medium; +} + +builder.relayMutationField( + "attachArticleDraftMedium", + { + description: + "Associate an uploaded `Medium` with an article draft so it can be " + + "referenced in the draft's Markdown as `hp-medium:{key}`. Must be " + + "called before publishing if the draft's content uses `hp-medium:` " + + "references. Requires authentication.", + inputFields: (t) => ({ + draftId: t.field({ type: "UUID", required: true }), + mediumId: t.field({ type: "UUID", required: true }), + key: t.string({ + required: false, + description: + "Key used in article markdown as hp-medium:KEY. Defaults to mediumId.", + }), + }), + }, + { + errors: { types: [NotAuthenticatedError, InvalidInputError] }, + async resolve(_root, args, ctx) { + const session = await ctx.session; + if (session == null) throw new NotAuthenticatedError(); + let draft = await ctx.db.query.articleDraftTable.findFirst({ + where: { + id: args.input.draftId, + accountId: session.accountId, + }, + }); + if (draft == null) { + const inserted = await ctx.db.insert(articleDraftTable).values({ + id: args.input.draftId, + accountId: session.accountId, + title: "", + content: "", + tags: [], + }).onConflictDoNothing().returning(); + draft = inserted[0]; + } + if (draft == null) throw new InvalidInputError("draftId"); + const medium = await ctx.db.query.mediumTable.findFirst({ + where: { id: args.input.mediumId }, + }); + if (medium == null) throw new InvalidInputError("mediumId"); + const key = args.input.key?.trim() || medium.id; + if (!key.match(/^[A-Za-z0-9._:/-]+$/)) { + throw new InvalidInputError("key"); + } + await ctx.db.insert(articleDraftMediumTable).values({ + articleDraftId: draft.id, + key, + mediumId: medium.id, + }).onConflictDoUpdate({ + target: [ + articleDraftMediumTable.articleDraftId, + articleDraftMediumTable.key, + ], + set: { mediumId: medium.id }, + }); + return { key, medium } satisfies AttachedArticleDraftMedium; + }, + }, + { + outputFields: (t) => ({ + key: t.string({ resolve: (result) => result.key }), + medium: t.field({ + type: Medium, + resolve: (result) => result.medium, + }), + }), + }, +); + +interface AttachedArticleSourceMedium { + key: string; + medium: schema.Medium; +} + +builder.relayMutationField( + "attachArticleSourceMedium", + { + description: + "Associate an uploaded `Medium` with a published article so it can " + + "be referenced in the article's Markdown as `hp-medium:{key}`. Use " + + "this when adding new media to an article during editing: pair each " + + "attach call with an `hp-medium:` reference in the new content " + + "passed to `updateArticle`. The viewer must own the article. " + + "Requires authentication.", + inputFields: (t) => ({ + articleSourceId: t.field({ + type: "UUID", + required: true, + description: + "UUID of the `ArticleSource` to attach the medium to. The viewer " + + "must be the article's author.", + }), + actingAccountId: t.globalID({ + for: [Account], + required: false, + description: actingAccountIdArgDescription, + }), + mediumId: t.field({ + type: "UUID", + required: true, + description: "UUID of a `Medium` to make available to the article.", + }), + key: t.string({ + required: false, + description: + "Key used in article markdown as hp-medium:KEY. Defaults to mediumId.", + }), + }), + }, + { + errors: { + types: [ + NotAuthenticatedError, + NotAuthorizedError, + InvalidInputError, + OrganizationPermissionError, + ], + }, + async resolve(_root, args, ctx) { + const session = await ctx.session; + if (session == null) throw new NotAuthenticatedError(); + const actingAccount = await resolveActingAccountForMutation( + ctx, + args.input, + ); + if (!validateUuid(args.input.articleSourceId)) { + throw new InvalidInputError("articleSourceId"); + } + const source = await ctx.db.query.articleSourceTable.findFirst({ + where: { id: args.input.articleSourceId }, + columns: { id: true, accountId: true }, + }); + if (source == null || source.accountId !== actingAccount.id) { + throw new InvalidInputError("articleSourceId"); + } + const medium = await ctx.db.query.mediumTable.findFirst({ + where: { id: args.input.mediumId }, + }); + if (medium == null) throw new InvalidInputError("mediumId"); + // During the upload window the medium is private to its uploader. + // Block attaching a freshly-uploaded medium that belongs to someone + // else, matching the ownership check used by `Medium.generatedAltText`. + // After the window expires the medium is either publicly referenced + // or pending orphan cleanup, so any authenticated owner of the + // target article may attach it. + const owner = await isMediumOwner( + ctx.kv, + medium.id, + session.accountId, + ); + if (!owner) { + const windowActive = await isMediumUploadWindowActive( + ctx.kv, + medium.id, + ); + if (windowActive) throw new NotAuthorizedError(); + } + const key = args.input.key?.trim() || medium.id; + if (!key.match(/^[A-Za-z0-9._:/-]+$/)) { + throw new InvalidInputError("key"); + } + // Don't silently overwrite an existing row. A published article's + // rendered HTML resolves `hp-medium:KEY` against this table at + // request time, so changing the `mediumId` for an in-use key would + // change the live article without going through `updateArticle` + // (no timestamp bump, no ActivityPub `Update`). Treat re-attaching + // the same medium as idempotent; reject conflicting medium IDs. + const inserted = await ctx.db.insert(articleSourceMediumTable).values({ + articleSourceId: source.id, + key, + mediumId: medium.id, + }).onConflictDoNothing().returning(); + if (inserted.length === 0) { + const existing = await ctx.db.query.articleSourceMediumTable + .findFirst({ + where: { articleSourceId: source.id, key }, + }); + if (existing == null || existing.mediumId !== medium.id) { + throw new InvalidInputError("key"); + } + } + return { key, medium } satisfies AttachedArticleSourceMedium; + }, + }, + { + outputFields: (t) => ({ + key: t.string({ + description: + "The key the medium was attached under. Reference it in the " + + "article's Markdown as `hp-medium:KEY`. Equals the requested " + + "`key` input when provided, otherwise the medium's UUID.", + resolve: (result) => result.key, + }), + medium: t.field({ + type: Medium, + description: "The `Medium` that was attached to the article source.", + resolve: (result) => result.medium, + }), + }), + }, +); diff --git a/graphql/post/note.ts b/graphql/post/note.ts new file mode 100644 index 000000000..8a2659ac3 --- /dev/null +++ b/graphql/post/note.ts @@ -0,0 +1,85 @@ +import { resolveActingAccountForGlobalIdArg } from "../acting-account.ts"; +import { builder } from "../builder.ts"; +import { Reactable } from "../reactable.ts"; +import { actingAccountIdArgDescription } from "../viewer-actor.ts"; +import { Post } from "./core.ts"; + +export const Note = builder.drizzleNode("postTable", { + variant: "Note", + description: + "A short-form microblog post, equivalent to a Mastodon Status or " + + "ActivityPub Note. Notes can be composed locally or federated in from " + + "remote instances. Boost wrappers (`sharedPost` is non-null) have empty " + + "content and copy the shared post's URL.", + interfaces: [Post, Reactable], + id: { + column: (post) => post.id, + }, + fields: (t) => ({ + sourceId: t.expose("noteSourceId", { + type: "UUID", + nullable: true, + description: + "The local source UUID for this note — `noteSourceTable.id`, the " + + "identifier embedded in `Post.url` (`/@username/`). " + + "Non-null only for source-backed local notes (notes originally " + + "composed on this instance). Null for federated remote notes and for " + + "local share wrappers (boosts), since neither carries a " + + "`noteSourceTable` row; for those, fall back to `Post.uuid`.", + }), + rawContent: t.field({ + type: "Markdown", + nullable: true, + description: + "The raw Markdown source of this note. Non-null only when the " + + "viewer is the note's author. Pass `actingAccountId` to read the " + + "source of a note authored by an organization account the viewer " + + "belongs to. Returns `null` for federated remote notes, local share " + + "wrappers, and notes authored by someone else.", + args: { + actingAccountId: t.arg.id({ + required: false, + description: actingAccountIdArgDescription, + }), + }, + select: { + with: { noteSource: { columns: { content: true, accountId: true } } }, + }, + async resolve(post, args, ctx) { + if (post.noteSource == null) return null; + if (ctx.account == null) return null; + const actingAccount = await resolveActingAccountForGlobalIdArg( + ctx, + args, + ); + if (actingAccount.id !== post.noteSource.accountId) return null; + return post.noteSource.content; + }, + }), + }), +}); + +export const Question = builder.drizzleNode("postTable", { + variant: "Question", + description: + "An ActivityPub `Question` poll. Local Questions are source-backed " + + "short posts with immutable poll settings; remote Questions may have " + + "`null` for `sourceId`. Use `Question.sourceId` for source-backed local " + + "Question routes, and fall back to `Post.uuid` for federated remote " + + "Questions and local share wrappers.", + interfaces: [Post, Reactable], + id: { + column: (post) => post.id, + }, + fields: (t) => ({ + sourceId: t.expose("noteSourceId", { + type: "UUID", + nullable: true, + description: + "The local source UUID for this question (`noteSourceTable.id`), " + + "embedded in source-backed local Question URLs as " + + "`/@username/`. `null` for federated remote questions and " + + "local share wrappers; use `Post.uuid` as the fallback route token.", + }), + }), +}); diff --git a/graphql/reactable.ts b/graphql/reactable.ts index aef38029f..3fe426390 100644 --- a/graphql/reactable.ts +++ b/graphql/reactable.ts @@ -1,5 +1,5 @@ import type { RelationsFilter } from "@hackerspub/models/db"; -import { getSanctionVisibleActorFilter } from "@hackerspub/models/post"; +import { getSanctionVisibleActorFilter } from "@hackerspub/models/post/visibility"; import { getViewerReactionsForPosts } from "@hackerspub/models/reaction"; import { relations } from "@hackerspub/models/relations"; import { actorTable, reactionTable } from "@hackerspub/models/schema"; diff --git a/graphql/refresh.ts b/graphql/refresh.ts index db7bec0a9..29d0e4c6a 100644 --- a/graphql/refresh.ts +++ b/graphql/refresh.ts @@ -1,6 +1,7 @@ import { isActor } from "@fedify/vocab"; import { persistActor } from "@hackerspub/models/actor"; -import { isPostObject, persistPost } from "@hackerspub/models/post"; +import { isPostObject } from "@hackerspub/models/post/core"; +import { persistPost } from "@hackerspub/models/post/remote"; import type { Uuid } from "@hackerspub/models/uuid"; import { Actor } from "./actor.ts"; import { builder } from "./builder.ts"; diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 8b6419044..d2fd6cd19 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -1018,7 +1018,17 @@ type Article implements Node & Post & Reactable { """ All available language versions of this article's content. Pass `language` to get only the best-matching locale (BCP 47 negotiation). Pass `includeBeingTranslated: true` to also include language versions whose LLM translation is still in progress. Empty when the article is censored or its author is hidden by a moderation sanction, and the viewer is neither its author nor a moderator. """ - contents(includeBeingTranslated: Boolean = false, language: Locale): [ArticleContent!]! + contents( + """ + Whether to include language versions whose LLM translation is still in progress. Defaults to `false`. + """ + includeBeingTranslated: Boolean = false + + """ + Preferred BCP 47 locale for content negotiation. Omit it to return every eligible language version. + """ + language: Locale + ): [ArticleContent!]! """ Every reply below this post (replies, replies to replies, and so on), flattened in depth-first order with siblings ordered by `published`. A node's parent (`replyTarget`) always appears before the node itself, including across pages, so clients can rebuild the tree from `replyTarget` ids alone. Subtrees rooted at a censored post, a post by a sanction-hidden actor, or a post invisible to the viewer are pruned along with that post. Traversal depth is capped at `maxDepth`; fetch a deeper branch from the deepest returned post's own `descendants`. Only forward pagination (`first`/`after`) is supported. Pass `actingAccountId` for an organization perspective. @@ -1121,7 +1131,15 @@ type Article implements Node & Post & Reactable { The year the article was first published, used as part of its URL path (e.g., `/@alice/2024/my-article`). `null` for articles federated in from remote instances. """ publishedYear: Int + + """ + Who may embed this post as a quote. Posts with `FOLLOWERS`, `DIRECT`, or `NONE` visibility always use `SELF` regardless of a broader received policy. + """ quotePolicy: QuotePolicy! + + """ + The cross-instance approval state when this post quotes another post. `null` means no request is outstanding or the request was approved. + """ quoteTargetState: QuoteTargetState """ @@ -1298,6 +1316,10 @@ type Article implements Node & Post & Reactable { """ actingAccountId: ID ): Boolean! + + """ + The audience allowed to view this post and whether it appears in public timelines. The value cannot be changed after the post has been federated. + """ visibility: PostVisibility! } @@ -3374,7 +3396,15 @@ type Note implements Node & Post & Reactable { """ organizationAuthor: OrganizationPostAuthor published: DateTime! + + """ + Who may embed this post as a quote. Posts with `FOLLOWERS`, `DIRECT`, or `NONE` visibility always use `SELF` regardless of a broader received policy. + """ quotePolicy: QuotePolicy! + + """ + The cross-instance approval state when this post quotes another post. `null` means no request is outstanding or the request was approved. + """ quoteTargetState: QuoteTargetState """ @@ -3551,6 +3581,10 @@ type Note implements Node & Post & Reactable { """ actingAccountId: ID ): Boolean! + + """ + The audience allowed to view this post and whether it appears in public timelines. The value cannot be changed after the post has been federated. + """ visibility: PostVisibility! } @@ -4138,7 +4172,15 @@ interface Post implements Node & Reactable { """ organizationAuthor: OrganizationPostAuthor published: DateTime! + + """ + Who may embed this post as a quote. Posts with `FOLLOWERS`, `DIRECT`, or `NONE` visibility always use `SELF` regardless of a broader received policy. + """ quotePolicy: QuotePolicy! + + """ + The cross-instance approval state when this post quotes another post. `null` means no request is outstanding or the request was approved. + """ quoteTargetState: QuoteTargetState """ @@ -4300,6 +4342,10 @@ interface Post implements Node & Reactable { """ actingAccountId: ID ): Boolean! + + """ + The audience allowed to view this post and whether it appears in public timelines. The value cannot be changed after the post has been federated. + """ visibility: PostVisibility! } @@ -5214,7 +5260,15 @@ type Question implements Node & Post & Reactable { When this `Question` was published according to its local source or remote ActivityPub object. """ published: DateTime! + + """ + Who may embed this post as a quote. Posts with `FOLLOWERS`, `DIRECT`, or `NONE` visibility always use `SELF` regardless of a broader received policy. + """ quotePolicy: QuotePolicy! + + """ + The cross-instance approval state when this post quotes another post. `null` means no request is outstanding or the request was approved. + """ quoteTargetState: QuoteTargetState """ diff --git a/graphql/search.ts b/graphql/search.ts index fff2e6b3e..81469b689 100644 --- a/graphql/search.ts +++ b/graphql/search.ts @@ -4,9 +4,9 @@ import type { RelationsFilter } from "@hackerspub/models/db"; import { getCensoredPostExclusionFilter, getPostVisibilityFilter, - isPostObject, - persistPost, -} from "@hackerspub/models/post"; +} from "@hackerspub/models/post/visibility"; +import { isPostObject } from "@hackerspub/models/post/core"; +import { persistPost } from "@hackerspub/models/post/remote"; import { parseQuery } from "@hackerspub/models/search"; import { compileQuery } from "@hackerspub/models/search-query"; import { diff --git a/models/actor.ts b/models/actor.ts index cca2ec56e..ae3b2e0cd 100644 --- a/models/actor.ts +++ b/models/actor.ts @@ -35,7 +35,8 @@ import metadata from "./deno.json" with { type: "json" }; import { persistInstance } from "./instance.ts"; import { renderMarkup } from "./markup.ts"; import { isNewsBotActorType, refreshNewsScoresForActor } from "./news.ts"; -import { isPostObject, persistPost, persistSharedPost } from "./post.ts"; +import { isPostObject } from "./post/core.ts"; +import { persistPost, persistSharedPost } from "./post/remote.ts"; import { type Account, type AccountEmail, diff --git a/models/article-source.ts b/models/article-source.ts new file mode 100644 index 000000000..e344d7c9b --- /dev/null +++ b/models/article-source.ts @@ -0,0 +1,74 @@ +import { minBy } from "@std/collections/min-by"; +import type { StorageService } from "./context.ts"; +import type { Database } from "./db.ts"; +import type { ArticleContent, ArticleSource } from "./schema.ts"; +import type { Uuid } from "./uuid.ts"; + +export async function getArticleDraftMediumUrls( + db: Database, + disk: StorageService, + draftId: Uuid, +): Promise> { + const media = await db.query.articleDraftMediumTable.findMany({ + where: { articleDraftId: draftId }, + with: { medium: true }, + }); + return Object.fromEntries( + await Promise.all( + media.map(async (relation) => [ + relation.key, + await disk.getUrl(relation.medium.key), + ]), + ), + ); +} + +export async function getArticleSourceMediumUrls( + db: Database, + disk: StorageService, + sourceId: Uuid, +): Promise> { + const media = await db.query.articleSourceMediumTable.findMany({ + where: { articleSourceId: sourceId }, + with: { medium: true }, + }); + return Object.fromEntries( + await Promise.all( + media.map(async (relation) => [ + relation.key, + await disk.getUrl(relation.medium.key), + ]), + ), + ); +} + +export function getOriginalArticleContent( + source: ArticleSource & { contents: ArticleContent[] }, +): ArticleContent | undefined; +export function getOriginalArticleContent( + db: Database, + source: ArticleSource, +): Promise; +export function getOriginalArticleContent( + dbOrSrc: ArticleSource & { contents: ArticleContent[] } | Database, + source?: ArticleSource, +): ArticleContent | undefined | Promise { + if ("contents" in dbOrSrc) { + const contents = dbOrSrc.contents.filter((content) => + content.originalLanguage == null && + content.translatorId == null && + content.translationRequesterId == null + ); + return minBy(contents, (content) => +content.published); + } + if (source == null) return Promise.resolve(undefined); + return dbOrSrc.query.articleContentTable.findFirst({ + where: { + sourceId: source.id, + originalLanguage: { isNull: true }, + translatorId: { isNull: true }, + translationRequesterId: { isNull: true }, + }, + orderBy: { published: "asc" }, + }); +} diff --git a/models/article.ts b/models/article.ts index 9c4b04b17..192e7a45d 100644 --- a/models/article.ts +++ b/models/article.ts @@ -1,6 +1,5 @@ import * as vocab from "@fedify/vocab"; import { getLogger } from "@logtape/logtape"; -import { minBy } from "@std/collections/min-by"; import type { ApplicationModel } from "./context.ts"; import { and, @@ -12,12 +11,17 @@ import { or, sql, } from "drizzle-orm"; -import type { StorageService } from "./context.ts"; import postgres from "postgres"; +export { + getArticleDraftMediumUrls, + getArticleSourceMediumUrls, + getOriginalArticleContent, +} from "./article-source.ts"; +import { getOriginalArticleContent } from "./article-source.ts"; import type { ApplicationContext, Models } from "./context.ts"; import type { Database, Transaction } from "./db.ts"; import { assertAccountActorNotSuspended } from "./moderation.ts"; -import { syncPostFromArticleSource } from "./post.ts"; +import { syncPostFromArticleSource } from "./post/source.ts"; import { type Account, type AccountEmail, @@ -142,44 +146,6 @@ async function updateArticleSourceMedia( return true; } -export async function getArticleDraftMediumUrls( - db: Database, - disk: StorageService, - draftId: Uuid, -): Promise> { - const media = await db.query.articleDraftMediumTable.findMany({ - where: { articleDraftId: draftId }, - with: { medium: true }, - }); - return Object.fromEntries( - await Promise.all( - media.map(async (relation) => [ - relation.key, - await disk.getUrl(relation.medium.key), - ]), - ), - ); -} - -export async function getArticleSourceMediumUrls( - db: Database, - disk: StorageService, - sourceId: Uuid, -): Promise> { - const media = await db.query.articleSourceMediumTable.findMany({ - where: { articleSourceId: sourceId }, - with: { medium: true }, - }); - return Object.fromEntries( - await Promise.all( - media.map(async (relation) => [ - relation.key, - await disk.getUrl(relation.medium.key), - ]), - ), - ); -} - /** * Counts the number of user-perceived characters (extended grapheme * clusters) in a string. @@ -766,37 +732,6 @@ export async function updateArticle( return post; } -export function getOriginalArticleContent( - source: ArticleSource & { contents: ArticleContent[] }, -): ArticleContent | undefined; -export function getOriginalArticleContent( - db: Database, - source: ArticleSource, -): Promise; -export function getOriginalArticleContent( - dbOrSrc: ArticleSource & { contents: ArticleContent[] } | Database, - source?: ArticleSource, -): ArticleContent | undefined | Promise { - if ("contents" in dbOrSrc) { - const contents = dbOrSrc.contents.filter((content) => - content.originalLanguage == null && - content.translatorId == null && - content.translationRequesterId == null - ); - return minBy(contents, (content) => +content.published); - } - if (source == null) return Promise.resolve(undefined); - return dbOrSrc.query.articleContentTable.findFirst({ - where: { - sourceId: source.id, - originalLanguage: { isNull: true }, - translatorId: { isNull: true }, - translationRequesterId: { isNull: true }, - }, - orderBy: { published: "asc" }, - }); -} - export async function startArticleContentSummary( db: Database, model: ApplicationModel, diff --git a/models/deno.json b/models/deno.json index ee8b2380e..39b89e581 100644 --- a/models/deno.json +++ b/models/deno.json @@ -38,6 +38,13 @@ "./pin": "./pin.ts", "./poll": "./poll.ts", "./post": "./post.ts", + "./post/core": "./post/core.ts", + "./post/engagement": "./post/engagement.ts", + "./post/lifecycle": "./post/lifecycle.ts", + "./post/remote": "./post/remote.ts", + "./post/sharing": "./post/sharing.ts", + "./post/source": "./post/source.ts", + "./post/visibility": "./post/visibility.ts", "./profile-interactions": "./profile-interactions.ts", "./push": "./push.ts", "./push-notification": "./push-notification.ts", diff --git a/models/link-preview.test.ts b/models/link-preview.test.ts index 8f49c2a0a..5c408bd1c 100644 --- a/models/link-preview.test.ts +++ b/models/link-preview.test.ts @@ -1,11 +1,12 @@ import assert from "node:assert"; import test from "node:test"; import { eq, inArray } from "drizzle-orm"; -import { repairBrokenLinkPreviews } from "./link-preview.ts"; +import { persistPostLink, repairBrokenLinkPreviews } from "./link-preview.ts"; import { NEWS_PENALTY_DEMOTE } from "./news.ts"; import { postLinkTable, postTable } from "./schema.ts"; import { generateUuidV7 } from "./uuid.ts"; import { + createFedCtx, insertAccountWithActor, insertNotePost, insertPostLink, @@ -13,6 +14,37 @@ import { withRollback, } from "../test/postgres.ts"; +test("persistPostLink() reuses redirected links by authored URL", async () => { + await withRollback(async (tx) => { + const author = await insertAccountWithActor(tx, { + username: "redirectcache", + name: "Redirect Cache", + email: "redirectcache@example.com", + }); + const authoredUrl = "https://short.example/story"; + const destinationLink = await insertPostLink(tx, { + url: "https://destination.example/article", + title: "Destination", + }); + await insertNotePost(tx, { + account: author.account, + link: { id: destinationLink.id, url: authoredUrl }, + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = () => { + throw new Error("The cached authored URL should not be fetched"); + }; + try { + const link = await persistPostLink(createFedCtx(tx), authoredUrl); + + assert.equal(link?.id, destinationLink.id); + assert.equal(link?.url, destinationLink.url); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + test("repairBrokenLinkPreviews() splits malformed canonical URLs", async () => { await withRollback(async (tx) => { const author = await insertAccountWithActor(tx, { diff --git a/models/link-preview.ts b/models/link-preview.ts index a57d03c8c..a77868b1a 100644 --- a/models/link-preview.ts +++ b/models/link-preview.ts @@ -1,10 +1,86 @@ +import { getUserAgent } from "@fedify/fedify"; +import { getLogger } from "@logtape/logtape"; import { eq, inArray, sql } from "drizzle-orm"; +import iconv from "iconv-lite"; +import { Buffer } from "node:buffer"; +import ogs from "open-graph-scraper"; +import { PDFDocument } from "pdf-lib"; +import sharp from "sharp"; +import { isSSRFSafeURL } from "ssrfcheck"; +import { persistActorsByHandles } from "./actor.ts"; +import type { ApplicationContext } from "./context.ts"; import type { Database } from "./db.ts"; import { extractExternalLinks } from "./html.ts"; import { recomputeNewsScores } from "./news.ts"; -import { postLinkTable, postTable } from "./schema.ts"; +import { + getRemoteFetchSignal, + readResponseBytesAtMost, +} from "./post/remote-fetch.ts"; +import { + type NewPostLink, + type PostLink, + postLinkTable, + postTable, +} from "./schema.ts"; import { generateUuidV7, type Uuid } from "./uuid.ts"; +const logger = getLogger(["hackerspub", "models", "link-preview"]); +const SCRAPE_IMAGE_METADATA_BYTES_LIMIT = 128 * 1024; +const MAX_REMOTE_REDIRECTS = 20; +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); + +class UnsafeRemoteUrlError extends TypeError {} + +function assertSafeRemoteUrl(url: URL): void { + if (!isSSRFSafeURL(url.href, { autoPrependProtocol: false })) { + throw new UnsafeRemoteUrlError(`Unsafe URL: ${url.href}`); + } +} + +async function fetchWithSafeRedirects( + url: URL, + init: Omit, +): Promise { + let currentUrl = url; + for (let redirectCount = 0;; redirectCount++) { + assertSafeRemoteUrl(currentUrl); + const response = await fetch(currentUrl, { ...init, redirect: "manual" }); + const responseUrl = response.url === "" + ? currentUrl + : new URL(response.url); + try { + assertSafeRemoteUrl(responseUrl); + } catch (error) { + await response.body?.cancel().catch(() => {}); + throw error; + } + if (!REDIRECT_STATUSES.has(response.status)) return response; + + const location = response.headers.get("Location"); + if (location == null) return response; + if (redirectCount >= MAX_REMOTE_REDIRECTS) { + await response.body?.cancel().catch(() => {}); + throw new TypeError(`Too many redirects from ${url.href}`); + } + await response.body?.cancel().catch(() => {}); + currentUrl = new URL(location, responseUrl); + } +} + +function clearPreviewImage(image: { + imageUrl?: string; + imageAlt?: string; + imageType?: string; + imageWidth?: number; + imageHeight?: number; +}): void { + image.imageUrl = undefined; + image.imageAlt = undefined; + image.imageType = undefined; + image.imageWidth = undefined; + image.imageHeight = undefined; +} + export interface RepairBrokenLinkPreviewsResult { readonly brokenLinks: number; readonly repairedPosts: number; @@ -164,3 +240,385 @@ export async function repairBrokenLinkPreviews( return { brokenLinks: brokenLinks.length, repairedPosts, unresolvedPosts }; } + +export async function scrapePostLink( + fedCtx: Pick, + url: string | URL, + handleToActorId: (handle: string) => Promise, + options: { signal?: AbortSignal } = {}, +): Promise { + const lg = logger.getChild("scrapePostLink"); + url = typeof url === "string" ? new URL(url) : url; + if (!isSSRFSafeURL(url.href)) { + lg.warn("Unsafe URL: {url}", { url: url.href }); + return undefined; + } + let response: Response; + try { + response = await fetchWithSafeRedirects(url, { + headers: { + "User-Agent": getUserAgent({ + software: "HackersPub", + url: new URL(fedCtx.canonicalOrigin), + }), + }, + signal: getRemoteFetchSignal(options.signal), + }); + } catch (error) { + // Best-effort link-preview scrape: a remote being unreachable (DNS, TLS, + // connection errors) is expected and not actionable, so log at `warn` to + // keep it out of error tracking. The post still persists without a preview. + lg.warn("Failed to fetch {url}: {error}", { url: url.href, error }); + return undefined; + } + const responseUrl = response.url == null || response.url === "" + ? url.href + : response.url; + if (!response.ok) { + // Best-effort: many sites refuse scrapers (403) or are briefly down (5xx). + // Not actionable, so `warn` rather than `error`. + lg.warn("Failed to scrape {url}: {status} {statusText}", { + url: responseUrl, + status: response.status, + statusText: response.statusText, + }); + await response.body?.cancel().catch(() => {}); + return undefined; + } + const fullContentType = response.headers.get("Content-Type"); + const contentType = fullContentType?.replace(/\s*;.*$/, ""); + if ( + contentType === "application/pdf" || contentType === "application/x-pdf" + ) { + try { + const pdf = await PDFDocument.load(await response.arrayBuffer(), { + updateMetadata: false, + }); + return { + id: generateUuidV7(), + url: responseUrl, + title: pdf.getTitle(), + description: pdf.getSubject(), + author: pdf.getAuthor(), + }; + } catch (error) { + lg.warn("Failed to read or parse PDF from {url}: {error}", { + url: responseUrl, + error, + }); + return undefined; + } + } + if (contentType !== "text/html" && contentType !== "application/xhtml+xml") { + lg.warn("Not an HTML page: {url} ({contentType})", { + url: responseUrl, + contentType, + }); + await response.body?.cancel().catch(() => {}); + return undefined; + } + const contentTypeParams = Object.fromEntries( + (fullContentType + ?.replace(/^[^;]*;\s*/, "") + ?.split(/\s*;\s*/g) ?? []).map((pair: string) => pair.split(/\s*=\s*/)) + .filter((pair) => pair.length === 2).map((pair) => + pair as [string, string] + ), + ); + let charset = contentTypeParams.charset + ?.replace(/^(["'])(.*)\1$/, "$2") + .toLowerCase(); + let bytes: Uint8Array; + try { + bytes = new Uint8Array(await response.arrayBuffer()); + } catch (error) { + lg.warn("Failed to read body from {url}: {error}", { + url: responseUrl, + error, + }); + return undefined; + } + if (!charset) { + // Try to find charset in meta tags if not specified in Content-Type + const decoder = new TextDecoder(); + const rawHtml = decoder.decode(bytes); + const charsetMatch = rawHtml.match(/>["result"]; + try { + const scraped = await ogs({ + html, + customMetaTags: [ + { + multiple: false, + property: "fediverse:creator", + fieldName: "fediverseCreator", + }, + ], + }); + if (scraped.error) { + // Best-effort: the page loaded but Open Graph parsing failed. Not + // actionable, so `warn` rather than `error`. + lg.warn("Failed to scrape {url}: {error}", { + url: responseUrl, + result: scraped.result, + }); + return undefined; + } + result = scraped.result; + } catch (error) { + // `open-graph-scraper` throws plain objects for parser setup failures. + // Link previews are best-effort, so do not fail ActivityPub ingestion. + lg.warn("Failed to scrape {url}: {error}", { url: responseUrl, error }); + return undefined; + } + lg.debug("Scraped {url}: {result}", { url: responseUrl, result }); + const ogImage = result.ogImage ?? []; + const twitterImage = result.twitterImage ?? []; + const image: { + imageUrl?: string; + imageAlt?: string; + imageType?: string; + imageWidth?: number; + imageHeight?: number; + } = ogImage.length > 0 + ? { + imageUrl: ogImage[0].url, + imageAlt: ogImage[0].alt, + imageType: ogImage[0].type === "png" + ? "image/png" + : ogImage[0].type === "jpg" || ogImage[0].type === "jpeg" + ? "image/jpeg" + : ogImage[0].type == null || + !ogImage[0].type.startsWith("image/") + ? undefined + : ogImage[0].type, + imageWidth: typeof ogImage[0].width === "string" + ? parseInt(ogImage[0].width) + : ogImage[0].width, + imageHeight: typeof ogImage[0].height === "string" + ? parseInt(ogImage[0].height) + : ogImage[0].height, + } + : twitterImage.length > 0 + ? { + imageUrl: twitterImage[0].url, + imageAlt: twitterImage[0].alt, + imageWidth: typeof twitterImage[0].width === "string" + ? parseInt(twitterImage[0].width) + : twitterImage[0].width, + imageHeight: typeof twitterImage[0].height === "string" + ? parseInt(twitterImage[0].height) + : twitterImage[0].height, + } + : {}; + if (image.imageUrl != null) { + try { + const imageUrl = new URL(image.imageUrl, responseUrl); + assertSafeRemoteUrl(imageUrl); + image.imageUrl = imageUrl.href; + } catch (error) { + lg.warn("Ignoring invalid preview image URL for {url}: {error}", { + url: responseUrl, + imageUrl: image.imageUrl, + error, + }); + clearPreviewImage(image); + } + } + if ( + image.imageUrl != null && + (image.imageWidth == null || image.imageHeight == null) + ) { + try { + const response = await fetchWithSafeRedirects(new URL(image.imageUrl), { + headers: { + "User-Agent": getUserAgent({ + software: "HackersPub", + url: new URL(fedCtx.canonicalOrigin), + }), + "Accept": "image/*", + "Range": `bytes=0-${SCRAPE_IMAGE_METADATA_BYTES_LIMIT - 1}`, + "Referer": responseUrl, + }, + signal: getRemoteFetchSignal(options.signal), + }); + logger.debug("Fetched image {url}: {status} {statusText}", { + url: response.url, + status: response.status, + statusText: response.statusText, + }); + if (response.ok) { + const body = await readResponseBytesAtMost( + response, + SCRAPE_IMAGE_METADATA_BYTES_LIMIT, + ); + try { + const metadata = await sharp(body).metadata(); + switch (metadata.orientation) { + case 6: + case 8: + image.imageWidth = metadata.height; + image.imageHeight = metadata.width; + break; + case 1: + case 3: + default: + image.imageWidth = metadata.width; + image.imageHeight = metadata.height; + break; + } + } catch { + image.imageWidth = undefined; + image.imageHeight = undefined; + } + } else { + await response.body?.cancel().catch(() => {}); + } + } catch (error) { + logger.debug( + "Failed to fetch image {url}: {error}", + { url: image.imageUrl, error }, + ); + if (error instanceof UnsafeRemoteUrlError) { + clearPreviewImage(image); + } else { + image.imageWidth = undefined; + image.imageHeight = undefined; + } + } + } + const creatorHandle = result.customMetaTags?.fediverseCreator == null + ? undefined + : Array.isArray(result.customMetaTags.fediverseCreator) + ? result.customMetaTags.fediverseCreator[0] + : result.customMetaTags.fediverseCreator; + const declaredCanonicalUrl = result.ogUrl ?? result.twitterUrl ?? + result.requestUrl; + if (declaredCanonicalUrl != null) { + lg.debug("Ignoring declared canonical URL for {url}: {canonicalUrl}", { + url: responseUrl, + canonicalUrl: declaredCanonicalUrl, + }); + } + return { + id: generateUuidV7(), + // response.url is the strongest identity we can verify: unlike Open Graph + // or rel=canonical metadata, it is the URL reached by an actual redirect + // chain. Fragments never reach HTTP and are preserved separately on Post. + url: responseUrl, + title: result.ogTitle ?? result.twitterTitle, + siteName: result.ogSiteName, + type: result.ogType, + description: result.ogDescription ?? result.twitterDescription, + author: result.ogArticleAuthor, + creatorId: creatorHandle == null || handleToActorId == null + ? undefined + : await handleToActorId(creatorHandle), + ...image, + }; +} + +const POST_LINK_CACHE_TTL = Temporal.Duration.from({ hours: 24 }); + +export async function persistPostLink( + ctx: ApplicationContext, + url: string | URL, + options: { signal?: AbortSignal } = {}, +): Promise { + if (typeof url === "string") url = new URL(url); + if (!isSSRFSafeURL(url.href)) { + logger.warn("Unsafe URL: {url}", { url: url.href }); + return undefined; + } + const scrapeUrl = new URL(url); + scrapeUrl.hash = ""; + const { db } = ctx; + let link = await db.query.postLinkTable.findFirst({ + where: { url: scrapeUrl.href }, + }); + if (link == null) { + const priorPost = await db.query.postTable.findFirst({ + columns: { linkId: true }, + where: { linkUrl: url.href }, + orderBy: { updated: "desc" }, + }); + if (priorPost?.linkId != null) { + link = await db.query.postLinkTable.findFirst({ + where: { id: priorPost.linkId }, + }); + } + } + if (link != null) { + const scraped = link.scraped.toTemporalInstant(); + if ( + Temporal.Instant.compare( + scraped.add(POST_LINK_CACHE_TTL), + Temporal.Now.instant(), + ) > 0 + ) { + logger.debug("Post link cache hit: {url}", { url: scrapeUrl.href }); + return link; + } + } + let scrapedLink = await scrapePostLink(ctx, scrapeUrl, async (handle) => { + if (!handle.startsWith("@")) handle = `@${handle}`; + const actors = await persistActorsByHandles(ctx, [handle]); + return actors[handle]?.id; + }, { + signal: options.signal, + }); + logger.debug("Scraped link {url}: {link}", { + url: url.href, + link: scrapedLink, + }); + if (scrapedLink == null) return undefined; + if (scrapedLink.imageWidth == null || scrapedLink.imageHeight == null) { + scrapedLink = { + ...scrapedLink, + imageWidth: undefined, + imageHeight: undefined, + }; + } + const result = await db + .insert(postLinkTable) + .values(scrapedLink) + .onConflictDoUpdate({ + target: postLinkTable.url, + set: { + title: scrapedLink.title, + siteName: scrapedLink.siteName, + type: scrapedLink.type, + description: scrapedLink.description, + imageUrl: scrapedLink.imageUrl, + imageAlt: scrapedLink.imageAlt, + imageType: scrapedLink.imageType, + imageWidth: scrapedLink.imageWidth, + imageHeight: scrapedLink.imageHeight, + creatorId: scrapedLink.creatorId, + scraped: sql`CURRENT_TIMESTAMP`, + }, + setWhere: eq(postLinkTable.url, scrapedLink.url), + }) + .returning(); + return result[0]; +} diff --git a/models/note.ts b/models/note.ts index db389d1c9..dbf1d2abe 100644 --- a/models/note.ts +++ b/models/note.ts @@ -11,11 +11,9 @@ import { createQuoteNotification, createReplyNotification, } from "./notification.ts"; -import { - getAllowedQuoteTargetForActor, - syncPostFromNoteSource, - updateRepliesCount, -} from "./post.ts"; +import { updateRepliesCount } from "./post/engagement.ts"; +import { syncPostFromNoteSource } from "./post/source.ts"; +import { getAllowedQuoteTargetForActor } from "./post/visibility.ts"; import { type Account, type AccountEmail, diff --git a/models/poll.ts b/models/poll.ts index cd8a8db36..d9b430ff6 100644 --- a/models/poll.ts +++ b/models/poll.ts @@ -13,7 +13,9 @@ import type { ApplicationContext } from "./context.ts"; import { toDate } from "./date.ts"; import type { Database, Transaction } from "./db.ts"; import { createPollEndedNotification } from "./notification.ts"; -import { getPersistedPost, isPostVisibleTo, persistPost } from "./post.ts"; +import { getPersistedPost } from "./post/core.ts"; +import { persistPost } from "./post/remote.ts"; +import { isPostVisibleTo } from "./post/visibility.ts"; import { type Account, type Actor, diff --git a/models/post.delete.test.ts b/models/post.delete.test.ts index fb059bba3..ec56e971c 100644 --- a/models/post.delete.test.ts +++ b/models/post.delete.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert"; import test from "node:test"; import { eq, inArray } from "drizzle-orm"; -import { deletePost, sharePost } from "./post.ts"; +import { deletePost } from "./post/lifecycle.ts"; +import { sharePost } from "./post/sharing.ts"; import { noteSourceTable, notificationTable, postTable } from "./schema.ts"; import { createFedCtx, diff --git a/models/post.facade.test.ts b/models/post.facade.test.ts new file mode 100644 index 000000000..2b64d53f1 --- /dev/null +++ b/models/post.facade.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import * as facade from "./post.ts"; +import * as core from "./post/core.ts"; +import * as engagement from "./post/engagement.ts"; +import * as lifecycle from "./post/lifecycle.ts"; +import * as remote from "./post/remote.ts"; +import * as sharing from "./post/sharing.ts"; +import * as source from "./post/source.ts"; +import * as visibility from "./post/visibility.ts"; +import * as linkPreview from "./link-preview.ts"; + +test("post compatibility facade re-exports each feature implementation", () => { + assert.equal(facade.isPostObject, core.isPostObject); + assert.equal(facade.getPersistedPost, core.getPersistedPost); + assert.equal(facade.updateRepliesCount, engagement.updateRepliesCount); + assert.equal(facade.revokeQuote, engagement.revokeQuote); + assert.equal(facade.deletePost, lifecycle.deletePost); + assert.equal(facade.persistPost, remote.persistPost); + assert.equal( + facade.withDocumentLoaderTimeout, + remote.withDocumentLoaderTimeout, + ); + assert.equal(facade.sharePost, sharing.sharePost); + assert.equal(facade.syncPostFromNoteSource, source.syncPostFromNoteSource); + assert.equal(facade.isPostVisibleTo, visibility.isPostVisibleTo); + assert.equal(facade.persistPostLink, linkPreview.persistPostLink); +}); diff --git a/models/post.lifecycle.test.ts b/models/post.lifecycle.test.ts index bb6a704bb..fb2ebb66e 100644 --- a/models/post.lifecycle.test.ts +++ b/models/post.lifecycle.test.ts @@ -3,7 +3,8 @@ import assert from "node:assert"; import test from "node:test"; import { and, eq } from "drizzle-orm"; import { follow } from "./following.ts"; -import { revokeQuote, sharePost, unsharePost } from "./post.ts"; +import { revokeQuote } from "./post/engagement.ts"; +import { sharePost, unsharePost } from "./post/sharing.ts"; import { followingTable, noteSourceTable, diff --git a/models/post.remote.test.ts b/models/post.remote.test.ts index c7650ab19..f573a74b1 100644 --- a/models/post.remote.test.ts +++ b/models/post.remote.test.ts @@ -17,14 +17,14 @@ import { QuoteAuthorization, } from "@fedify/vocab"; import { eq } from "drizzle-orm"; +import { getPostByUsernameAndId } from "./post/core.ts"; import { deletePersistedPost, deleteSharedPost, - getAllowedQuoteTargetForActor, - getPostByUsernameAndId, persistPost, persistSharedPost, -} from "./post.ts"; +} from "./post/remote.ts"; +import { getAllowedQuoteTargetForActor } from "./post/visibility.ts"; import { actorTable, instanceTable, diff --git a/models/post.share.test.ts b/models/post.share.test.ts index 774f85ffb..d5b5e14c9 100644 --- a/models/post.share.test.ts +++ b/models/post.share.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert"; import test from "node:test"; -import { arePostsSharedBy, sharePost } from "./post.ts"; +import { arePostsSharedBy, sharePost } from "./post/sharing.ts"; import { createFedCtx, insertAccountWithActor, diff --git a/models/post.sync.test.ts b/models/post.sync.test.ts index d964e1469..7738ebafa 100644 --- a/models/post.sync.test.ts +++ b/models/post.sync.test.ts @@ -10,7 +10,10 @@ import { postTable, } from "./schema.ts"; import { createQuestion } from "./question.ts"; -import { syncPostFromArticleSource, syncPostFromNoteSource } from "./post.ts"; +import { + syncPostFromArticleSource, + syncPostFromNoteSource, +} from "./post/source.ts"; import { generateUuidV7 } from "./uuid.ts"; import { createFedCtx, diff --git a/models/post.test.ts b/models/post.test.ts index 7f86674e1..c62850212 100644 --- a/models/post.test.ts +++ b/models/post.test.ts @@ -12,12 +12,10 @@ import { import assert from "node:assert"; import test, { describe, it } from "node:test"; import { validate } from "@std/uuid/v7"; -import { - isArticleLike, - isPostVisibleTo, - scrapePostLink, - withDocumentLoaderTimeout, -} from "./post.ts"; +import { scrapePostLink } from "./link-preview.ts"; +import { isArticleLike } from "./post/core.ts"; +import { withDocumentLoaderTimeout } from "./post/remote.ts"; +import { isPostVisibleTo } from "./post/visibility.ts"; import type { Actor, Instance, Post } from "./schema.ts"; const federation = createFederation({ @@ -44,7 +42,7 @@ test("scrapePostLink() scrapes Open Graph metadata", async () => { mockGlobalFetch(); mockFetch("https://example.internal/index.html", { headers: { - "Content-Type": "text/html; charset=utf-8", + "Content-Type": 'text/html; charset="utf-8"', }, body: ` @@ -139,6 +137,134 @@ test("scrapePostLink() keeps image URL when metadata probing fails", async () => resetGlobalFetch(); }); +test("scrapePostLink() cancels unsuccessful image metadata responses", async () => { + const originalFetch = globalThis.fetch; + let imageResponseCancelled = false; + globalThis.fetch = ((input) => { + const url = input.toString(); + if (url === "https://example.com/article") { + return Promise.resolve( + new Response( + ` + + + `, + { headers: { "Content-Type": "text/html; charset=utf-8" } }, + ), + ); + } + if (url === "https://images.example/missing.png") { + return Promise.resolve( + new Response( + new ReadableStream({ + cancel() { + imageResponseCancelled = true; + }, + }), + { status: 404 }, + ), + ); + } + throw new Error(`Unexpected fetch: ${url}`); + }) as typeof fetch; + try { + const link = await scrapePostLink( + ctx, + "https://example.com/article", + () => Promise.resolve(undefined), + ); + + assert.equal(link?.imageUrl, "https://images.example/missing.png"); + assert.equal(imageResponseCancelled, true); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("scrapePostLink() does not fetch unsafe preview images", async () => { + const originalFetch = globalThis.fetch; + const requestedUrls: string[] = []; + globalThis.fetch = ((input) => { + const url = input.toString(); + requestedUrls.push(url); + if (url === "https://example.com/article") { + return Promise.resolve( + new Response( + ` + + + `, + { headers: { "Content-Type": "text/html; charset=utf-8" } }, + ), + ); + } + throw new Error(`Unexpected fetch: ${url}`); + }) as typeof fetch; + try { + const link = await scrapePostLink( + ctx, + "https://example.com/article", + () => Promise.resolve(undefined), + ); + + assert.deepEqual(requestedUrls, ["https://example.com/article"]); + assert.equal(link?.imageUrl, undefined); + assert.equal(link?.imageWidth, undefined); + assert.equal(link?.imageHeight, undefined); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("scrapePostLink() rejects unsafe preview image redirects", async () => { + const originalFetch = globalThis.fetch; + const requestedUrls: string[] = []; + const redirectModes: (RequestRedirect | undefined)[] = []; + globalThis.fetch = ((input, init) => { + const url = input.toString(); + requestedUrls.push(url); + redirectModes.push(init?.redirect); + if (url === "https://example.com/article") { + return Promise.resolve( + new Response( + ` + + + `, + { headers: { "Content-Type": "text/html; charset=utf-8" } }, + ), + ); + } + if (url === "https://images.example/preview.png") { + return Promise.resolve( + new Response(null, { + status: 302, + headers: { Location: "http://127.0.0.1/private.png" }, + }), + ); + } + throw new Error(`Unexpected fetch: ${url}`); + }) as typeof fetch; + try { + const link = await scrapePostLink( + ctx, + "https://example.com/article", + () => Promise.resolve(undefined), + ); + + assert.deepEqual(requestedUrls, [ + "https://example.com/article", + "https://images.example/preview.png", + ]); + assert.deepEqual(redirectModes, ["manual", "manual"]); + assert.equal(link?.imageUrl, undefined); + assert.equal(link?.imageWidth, undefined); + assert.equal(link?.imageHeight, undefined); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("scrapePostLink() ignores malformed canonical metadata", async () => { mockGlobalFetch(); mockFetch("https://delta.chat/index.html", { @@ -428,4 +554,16 @@ describe("isPostVisibleTo()", () => { } as unknown as Parameters[0]; assert.equal(isPostVisibleTo(fineWrapper), true); }); + + it("hides boost wrappers whose shared post is missing", () => { + const wrapper = { + sharedPostId: crypto.randomUUID(), + sharedPost: null, + visibility: "public", + actor: fakeActor({}), + mentions: [], + } as unknown as Parameters[0]; + + assert.equal(isPostVisibleTo(wrapper), false); + }); }); diff --git a/models/post.ts b/models/post.ts index 320d3a4c2..610a0a685 100644 --- a/models/post.ts +++ b/models/post.ts @@ -1,3778 +1,48 @@ -import { assertAccountActorNotSuspended } from "./moderation.ts"; -import { type DocumentLoader, getUserAgent } from "@fedify/fedify"; -import { - isActor, - LanguageString, - lookupObject, - PUBLIC_COLLECTION, - type Recipient, - traverseCollection, -} from "@fedify/vocab"; -import * as vocab from "@fedify/vocab"; -import { getLogger } from "@logtape/logtape"; -import { assertNever } from "@std/assert/unstable-never"; -import { - and, - arrayOverlaps, - count, - eq, - inArray, - isNotNull, - isNull, - or, - sql, -} from "drizzle-orm"; -import iconv from "iconv-lite"; -import { Buffer } from "node:buffer"; -import ogs from "open-graph-scraper"; -import { PDFDocument } from "pdf-lib"; -import sharp from "sharp"; -import { isSSRFSafeURL } from "ssrfcheck"; -import { - getPersistedActor, - isFederationBlocked, - persistActor, - persistActorsByHandles, - syncActorFromAccount, - toRecipient, -} from "./actor.ts"; -import { - getArticleSourceMediumUrls, - getOriginalArticleContent, -} from "./article.ts"; -import type { ApplicationContext } from "./context.ts"; -import { toDate } from "./date.ts"; -import { - type Database, - type RelationsFilter, - runInTransaction, - type Transaction, -} from "./db.ts"; -import { extractExternalLinks, stripHtml } from "./html.ts"; -import { getMissingArticleMediumLabel, renderMarkup } from "./markup.ts"; -import { persistPostMedium } from "./medium.ts"; -import { refreshNewsScores, refreshNewsScoresForPostLinks } from "./news.ts"; -import { - createQuotedPostUpdatedNotification, - createSharedPostUpdatedNotification, - createShareNotification, - deleteShareNotification, -} from "./notification.ts"; -import { createPoll, type CreatePollInput, persistPoll } from "./poll.ts"; -import { - type Account, - type AccountEmail, - type AccountLink, - type Actor, - actorTable, - type ArticleContent, - type ArticleSource, - articleSourceTable, - type Blocking, - type Following, - type Instance, - type Medium, - type Mention, - mentionTable, - type NewPost, - type NewPostLink, - type NoteSource, - type NoteSourceMedium, - noteSourceTable, - type Poll, - type PollOption, - type Post, - type PostLink, - postLinkTable, - type PostMedium, - postMediumTable, - postTable, - type PostVisibility, - quoteAuthorizationTable, - type QuotePolicy, - quoteRequestTable, - type Reaction, -} from "./schema.ts"; -import { addPostToTimeline, removeFromTimeline } from "./timeline.ts"; -import { queueAfterCommit } from "./tx.ts"; -import { generateUuidV7, type Uuid } from "./uuid.ts"; - -const logger = getLogger(["hackerspub", "models", "post"]); -const DEFAULT_MAX_PERSIST_POST_DEPTH = 3; -const DEFAULT_MAX_INLINE_REPLIES = 50; -const DEFAULT_INLINE_REPLIES_THRESHOLD = 10; -const REPLIES_BACKFILL_LOCK_TTL_MS = 300_000; -const REPLIES_BACKFILL_RETRY_DELAY_MS = 30_000; -const EMOJI_REACTIONS_BACKFILL_LOCK_TTL_MS = 300_000; -const EMOJI_REACTIONS_BACKFILL_RETRY_DELAY_MS = 30_000; -const MAX_EMOJI_REACTIONS_BACKFILL = 500; -// Per-fetch ceiling for remote ActivityPub dereferencing during post -// persistence. Deno's `fetch` has no default timeout, so an unresponsive -// remote host could otherwise hang an inbox handler past the message queue's -// 60-second handler timeout (and pin a DB connection while it hangs). -// Exported so inbox handlers can apply the same ceiling to pre-persistPost -// fetches (e.g. the initial getObject() type check in onPostShared). -export const REMOTE_FETCH_TIMEOUT_MS = 10_000; -// Wall-clock budget for the synchronous (inline) replies traversal. Even when -// every individual fetch stays under REMOTE_FETCH_TIMEOUT_MS, a long reply -// collection could otherwise accumulate enough sequential fetches to drag a -// single handler toward the queue timeout, so we stop early and leave the rest -// to the deferred backfill / future federation. -const INLINE_REPLIES_TRAVERSAL_BUDGET_MS = 15_000; -// Overall wall-clock budget for a single synchronous persistPost subtree (one -// inbox message). The per-fetch (REMOTE_FETCH_TIMEOUT_MS) and inline-replies -// (INLINE_REPLIES_TRAVERSAL_BUDGET_MS) bounds do NOT cap the AGGREGATE of the -// many sequential fetches spread across the reply/quote/reply-target recursion, -// so a handler could still run past PostgresMessageQueue's handlerTimeout. -// That timeout is a Promise.race that does not abort the handler, so the -// handler then keeps running detached and pins a DB connection (Sentry -// GRAPHQL-1H). This shared deadline is threaded through the synchronous -// recursion so that once it elapses every remaining remote fetch aborts at -// once, the handler unwinds (degrading to partial persistence; large reply sets -// already fall to the deferred backfill), and the connection is released well -// before the 180s queue timeout. -// -// Set to 90s (not 120s) so that operations outside persistPost — the pre-check -// getObject() in onPostShared, DB writes, notification creation, news score -// refresh — have 90s of headroom before the 180s MQ limit. Exported so -// callers can mint a handler-level AbortSignal that covers both pre-persistPost -// work and the persistPost subtree under one shared deadline. -export const PERSIST_POST_OVERALL_BUDGET_MS = 90_000; -const SCRAPE_IMAGE_METADATA_BYTES_LIMIT = 128 * 1024; -const ARTICLE_LINK_DESCRIPTION_MAX_LENGTH = 500; - -function getRemoteFetchSignal(signal?: AbortSignal): AbortSignal { - const signals = [AbortSignal.timeout(REMOTE_FETCH_TIMEOUT_MS), signal] - .filter((s): s is AbortSignal => s != null); - return AbortSignal.any(signals); -} - -/** - * Wraps a Fedify {@link DocumentLoader} so every remote document fetch is - * bounded by an {@link AbortSignal.timeout}. Without this, a single - * unresponsive remote host can stall an inbox message handler long enough to - * trip `PostgresMessageQueue`'s handler timeout. Any caller-supplied signal is - * preserved by combining it with the timeout via {@link AbortSignal.any}. - * - * `overallSignal` is the shared per-subtree deadline - * ({@link PERSIST_POST_OVERALL_BUDGET_MS}); combining it here is what makes the - * aggregate of many sequential fetches abortable, not just each fetch on its - * own. Exported for unit testing of the signal-combination behavior. - */ -export function withDocumentLoaderTimeout( - loader: DocumentLoader, - timeoutMs: number = REMOTE_FETCH_TIMEOUT_MS, - overallSignal?: AbortSignal, -): DocumentLoader { - return (url, options) => { - const signals = [ - options?.signal, - AbortSignal.timeout(timeoutMs), - overallSignal, - ].filter((s): s is AbortSignal => s != null); - const signal = signals.length === 1 ? signals[0] : AbortSignal.any(signals); - return loader(url, { ...options, signal }); - }; -} - -async function backfillEmojiReactions( - ctx: ApplicationContext, - post: PostObject, - persistedPost: Pick, - options: { - contextLoader?: DocumentLoader; - documentLoader?: DocumentLoader; - }, -): Promise { - const opts = { - contextLoader: options.contextLoader, - documentLoader: withDocumentLoaderTimeout( - options.documentLoader ?? ctx.documentLoader, - ), - suppressError: true, - }; - const collection = await post.getEmojiReactions(opts); - if (collection == null) return; - const { persistReaction, updateReactionsCounts } = await import( - "./reaction.ts" - ); - let shouldUpdateCounts = false; - try { - let scanned = 0; - for await (const item of traverseCollection(collection, opts)) { - if (scanned >= MAX_EMOJI_REACTIONS_BACKFILL) break; - scanned++; - if (!(item instanceof vocab.Like || item instanceof vocab.EmojiReact)) { - continue; - } - if (item.objectId?.href !== persistedPost.iri) continue; - shouldUpdateCounts = true; - await persistReaction(ctx, item, opts); - } - } finally { - if (shouldUpdateCounts) { - await updateReactionsCounts(ctx.db, persistedPost.id); - } - } -} - -async function enqueueEmojiReactionsBackfill( - ctx: ApplicationContext, - post: PostObject, - persistedPost: Pick, - options: { - contextLoader?: DocumentLoader; - documentLoader?: DocumentLoader; - }, -): Promise { - const lockKey = `emoji-reactions-backfill/${persistedPost.iri}`; - const [locked] = await ctx.kv.getMany([lockKey]); - if (locked === "1") return; - await ctx.kv.set( - lockKey, - "1", - EMOJI_REACTIONS_BACKFILL_LOCK_TTL_MS, - ); - void (async () => { - const run = async (attempt: number): Promise => { - try { - await backfillEmojiReactions(ctx, post, persistedPost, options); - } catch (error) { - if (attempt < 1) { - await new Promise((resolve) => - setTimeout(resolve, EMOJI_REACTIONS_BACKFILL_RETRY_DELAY_MS) - ); - await run(attempt + 1); - return; - } - logger.warn( - "Failed to backfill emoji reactions for {postIri} after retry: " + - "{error}", - { postIri: persistedPost.iri, error }, - ); - } - }; - await run(0); - })().catch((error) => { - logger.warn( - "Emoji reactions backfill task failed for {postIri}: {error}", - { postIri: persistedPost.iri, error }, - ); - }); -} - -export type PostObject = vocab.Article | vocab.Note | vocab.Question; - -type NoteSourceMediumWithMedium = NoteSourceMedium & { medium: Medium }; -type QuotePolicyPost = Post & { - actor: Actor & { - followers: Following[]; - blockees: Blocking[]; - blockers: Blocking[]; - }; - mentions: Mention[]; -}; - -type QuoteUpdatePost = Post & { - actor: Actor; - quotedPost: (Post & { actor: Actor }) | null; - replyTarget: Post | null; - mentions: (Mention & { actor: Actor })[]; -}; - -type PersistedQuoteTarget = Post & { actor: Actor & { instance: Instance } }; - -const maxQuoteShareChainDepth = 16; - -type PostUpdateComparison = Pick< - Post, - "id" | "actorId" | "name" | "contentHtml" | "updated" ->; - -function hasNotifiablePostContentChange( - previousPost: Pick | undefined, - updatedPost: Pick, -): boolean { - return previousPost != null && - (previousPost.name !== updatedPost.name || - previousPost.contentHtml !== updatedPost.contentHtml); -} - -function getActorMentionHrefs(actor: Actor): string[] { - return [ - actor.iri, - ...(actor.url == null ? [] : [actor.url]), - ...actor.aliases, - ]; -} - -function deleteActorMentionHrefs( - mentionHrefs: Set, - actor: Actor, -): void { - for (const href of getActorMentionHrefs(actor)) mentionHrefs.delete(href); -} - -function isHttpUrl(value: string | undefined | null): value is string { - if (value == null) return false; - try { - const url = new URL(value); - return url.protocol === "http:" || url.protocol === "https:"; - } catch { - return false; - } -} - -function truncatePlainText(value: string): string { - const text = value.replace(/\s+/g, " ").trim(); - if (text.length <= ARTICLE_LINK_DESCRIPTION_MAX_LENGTH) return text; - return `${ - text.slice(0, ARTICLE_LINK_DESCRIPTION_MAX_LENGTH - 3).trimEnd() - }...`; -} - -async function persistArticleNewsLink( - fedCtx: ApplicationContext, - article: { - readonly url: string | null | undefined; - readonly iri: string; - readonly name: string | null | undefined; - readonly summary: string | null | undefined; - readonly contentHtml: string | null | undefined; - }, - actor: Pick, -): Promise { - const url = isHttpUrl(article.url) ? article.url : article.iri; - if (!isHttpUrl(url)) return undefined; - const parsed = new URL(url); - const description = article.summary == null || article.summary.trim() === "" - ? article.contentHtml == null - ? undefined - : truncatePlainText(stripHtml(article.contentHtml)) - : truncatePlainText(stripHtml(article.summary)); - const author = actor.name == null || actor.name.trim() === "" - ? actor.username - : actor.name; - const values: NewPostLink = { - id: generateUuidV7(), - url: parsed.href, - title: article.name ?? undefined, - siteName: parsed.host, - type: "article", - description, - author, - creatorId: actor.id, - }; - const rows = await fedCtx.db - .insert(postLinkTable) - .values(values) - .onConflictDoUpdate({ - target: postLinkTable.url, - set: { - title: values.title, - siteName: values.siteName, - type: values.type, - description: values.description, - author: values.author, - creatorId: values.creatorId, - scraped: sql`CURRENT_TIMESTAMP`, - }, - setWhere: eq(postLinkTable.url, values.url), - }) - .returning(); - return rows[0]; -} - -async function resolveMentionedActors( - ctx: ApplicationContext, - mentionHrefs: ReadonlySet, - options: { - contextLoader?: DocumentLoader; - documentLoader?: DocumentLoader; - }, -): Promise { - if (mentionHrefs.size < 1) return []; - const { db } = ctx; - const unresolvedHrefs = new Set(mentionHrefs); - const actorsById = new Map(); - const actorRows = await db.query.actorTable.findMany({ - where: { - OR: [ - { iri: { in: [...mentionHrefs] } }, - { url: { in: [...mentionHrefs] } }, - { RAW: (table) => arrayOverlaps(table.aliases, [...mentionHrefs]) }, - ], - }, - }); - for (const actor of actorRows) { - actorsById.set(actor.id, actor); - deleteActorMentionHrefs(unresolvedHrefs, actor); - } - for (const href of unresolvedHrefs) { - const apActor = await lookupObject(href, options); - if (!isActor(apActor)) continue; - let actor = await persistActor(ctx, apActor, options); - if (actor == null) continue; - if (actor.iri !== href && !actor.aliases.includes(href)) { - const aliases = [...actor.aliases, href]; - await db.update(actorTable) - .set({ aliases }) - .where(eq(actorTable.id, actor.id)); - actor = { ...actor, aliases }; - } - actorsById.set(actor.id, actor); - } - return [...actorsById.values()]; -} - -async function createTargetPostUpdatedNotifications( - db: Database, - previousPost: Pick | undefined, - updatedPost: PostUpdateComparison, - updatingActor: Actor, -): Promise { - if (!hasNotifiablePostContentChange(previousPost, updatedPost)) return; - const originalAuthorAccountId = updatingActor.accountId; - const shouldNotifyAccount = ( - accountId: Uuid | null, - ): accountId is Uuid => - accountId != null && accountId !== originalAuthorAccountId; - - const shareRows = await db - .select({ accountId: actorTable.accountId }) - .from(postTable) - .innerJoin(actorTable, eq(actorTable.id, postTable.actorId)) - .where(and( - eq(postTable.sharedPostId, updatedPost.id), - isNotNull(actorTable.accountId), - )); - const sharingAccountIds = new Set( - shareRows.map((row) => row.accountId).filter(shouldNotifyAccount), - ); - for (const accountId of sharingAccountIds) { - await createSharedPostUpdatedNotification( - db, - accountId, - updatedPost, - updatingActor, - ); - } - - const directQuoteRows = await db - .select({ accountId: actorTable.accountId }) - .from(postTable) - .innerJoin(actorTable, eq(actorTable.id, postTable.actorId)) - .where(and( - eq(postTable.quotedPostId, updatedPost.id), - isNotNull(actorTable.accountId), - )); - const quoteRequestRows = await db - .select({ accountId: actorTable.accountId }) - .from(quoteRequestTable) - .innerJoin(postTable, eq(postTable.id, quoteRequestTable.quotePostId)) - .innerJoin(actorTable, eq(actorTable.id, postTable.actorId)) - .where(and( - eq(quoteRequestTable.quotedPostId, updatedPost.id), - isNull(quoteRequestTable.accepted), - isNull(quoteRequestTable.rejected), - isNotNull(actorTable.accountId), - )); - const quotingAccountIds = new Set( - [...directQuoteRows, ...quoteRequestRows] - .map((row) => row.accountId) - .filter(shouldNotifyAccount), - ); - for (const accountId of quotingAccountIds) { - await createQuotedPostUpdatedNotification( - db, - accountId, - updatedPost, - updatingActor, - ); - } -} - -export function isPostObject(object: unknown): object is PostObject { - return object instanceof vocab.Article || object instanceof vocab.Note || - object instanceof vocab.Question; -} - -export function isArticleLike( - post: Post & { actor: Actor & { instance: Instance } }, -): boolean { - if (post.type === "Question") return false; - return post.type === "Article" || - post.name != null && post.actor.instance.software !== "nodebb"; -} - -export function normalizeQuotePolicyForVisibility( - visibility: PostVisibility, - quotePolicy: QuotePolicy | null | undefined, -): QuotePolicy { - if (visibility !== "public" && visibility !== "unlisted") return "self"; - return quotePolicy ?? "everyone"; -} - -function quotePolicyFromApprovalUrls( - post: PostObject, - approvalUrls: string[], - authorFollowersUrl: string | null, -): QuotePolicy | undefined { - if (approvalUrls.includes(PUBLIC_COLLECTION.href)) return "everyone"; - if (authorFollowersUrl != null && approvalUrls.includes(authorFollowersUrl)) { - return "followers"; - } - if ( - post.attributionId != null && - approvalUrls.includes(post.attributionId.href) - ) { - return "self"; - } - return undefined; -} - -function quotePoliciesFromInteractionPolicy( - post: PostObject, - visibility: PostVisibility, - authorFollowersUrl: string | null, -): { - quotePolicy: QuotePolicy; - quoteRequestPolicy: QuotePolicy | null; -} { - if (visibility !== "public" && visibility !== "unlisted") { - return { quotePolicy: "self", quoteRequestPolicy: null }; - } - const policy = post.interactionPolicy?.canQuote; - if (policy == null) { - return { - quotePolicy: normalizeQuotePolicyForVisibility(visibility, undefined), - quoteRequestPolicy: null, - }; - } - const quotePolicy = quotePolicyFromApprovalUrls( - post, - policy.automaticApprovals.map((url) => url.href), - authorFollowersUrl, - ) ?? "self"; - const quoteRequestPolicy = quotePolicyFromApprovalUrls( - post, - policy.manualApprovals.map((url) => url.href), - authorFollowersUrl, - ) ?? null; - return { quotePolicy, quoteRequestPolicy }; -} - -function canActorQuoteByPolicy( - post: Post & { actor: Actor & { followers: Following[] } }, - actor: Actor, - policy: QuotePolicy, -): boolean { - if (post.actorId === actor.id) return true; - if (policy === "everyone") return true; - if (policy === "followers") { - return post.actor.followers.some((follower) => - follower.followerId === actor.id && follower.accepted != null - ); - } - return false; -} - -export function canActorQuotePost( - post: Post & { - actor: Actor & { - followers: Following[]; - blockees: Blocking[]; - blockers: Blocking[]; - }; - mentions: Mention[]; - }, - actor: Actor, -): boolean { - if (post.sharedPostId != null) return false; - if (post.visibility === "direct" || post.visibility === "none") return false; - if (!isPostVisibleTo(post, actor)) return false; - return canActorQuoteByPolicy(post, actor, post.quotePolicy); -} - -export function canActorRequestQuotePost( - post: Post & { - actor: Actor & { - followers: Following[]; - blockees: Blocking[]; - blockers: Blocking[]; - }; - mentions: Mention[]; - }, - actor: Actor, -): boolean { - if (canActorQuotePost(post, actor)) return true; - if (post.sharedPostId != null) return false; - if (post.visibility === "direct" || post.visibility === "none") return false; - if (!isPostVisibleTo(post, actor)) return false; - if (post.quoteRequestPolicy == null) return false; - return canActorQuoteByPolicy(post, actor, post.quoteRequestPolicy); -} - -export async function getAllowedQuoteTargetForActor( - db: Database, - actor: Actor, - post: Post, -): Promise { - const targetPostId = await getOriginalPostId(db, post); - if (targetPostId == null) return undefined; - const quotedPost: QuotePolicyPost | undefined = await db.query.postTable - .findFirst({ - with: { - actor: { - with: { - followers: { where: { followerId: actor.id } }, - blockees: { where: { blockeeId: actor.id } }, - blockers: { where: { blockerId: actor.id } }, - }, - }, - mentions: { where: { actorId: actor.id } }, - }, - where: { id: targetPostId }, - }); - if (quotedPost == null) return undefined; - // A censored post cannot be quoted (by anyone, including its author): - // quoting re-amplifies moderation-hidden content. The submitted row - // is checked too, so a censored share wrapper cannot be used as a - // quote handle either. - if (post.censored != null || quotedPost.censored != null) { - return undefined; - } - const allowed = canActorRequestQuotePost(quotedPost, actor); - return allowed ? quotedPost : undefined; -} - -export async function getOriginalPostId( - db: Database, - post: Pick, -): Promise { - const visited = new Set([post.id]); - let target = post; - let depth = 0; - while (target.sharedPostId != null) { - if (depth >= maxQuoteShareChainDepth) return undefined; - depth++; - if (visited.has(target.sharedPostId)) return undefined; - visited.add(target.sharedPostId); - const next = await db.query.postTable.findFirst({ - columns: { id: true, sharedPostId: true }, - where: { id: target.sharedPostId }, - }); - if (next == null) return undefined; - target = next; - } - return target.id; -} - -async function readResponseBytesAtMost( - response: Response, - maxBytes: number, -): Promise { - if (response.body == null) { - return new Uint8Array((await response.arrayBuffer()).slice(0, maxBytes)); - } - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let total = 0; - try { - // Stop reading once we have enough bytes for lightweight metadata probing. - while (total < maxBytes) { - const { done, value } = await reader.read(); - if (done || value == null) break; - if (total + value.length <= maxBytes) { - chunks.push(value); - total += value.length; - continue; - } - const remaining = maxBytes - total; - if (remaining > 0) { - chunks.push(value.slice(0, remaining)); - total += remaining; - } - break; - } - } finally { - // Cancel the unread remainder so Deno closes the underlying HTTP body - // resource here, with the cancellation awaited (and any rejection - // swallowed). Without this, a partially-read body is abandoned with its - // reader still locked; when the peer tears the keep-alive connection down - // mid-flight, the dangling read rejects with "resource closed" as a - // *detached* unhandled rejection that escapes the caller's try/catch and - // is only caught by the instrument.ts backstop (GRAPHQL-1N). - await reader.cancel().catch(() => {}); - } - const result = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - result.set(chunk, offset); - offset += chunk.length; - } - return result; -} - -export async function syncPostFromArticleSource( - fedCtx: ApplicationContext, - articleSource: ArticleSource & { - account: Account & { - avatarMedium: Medium | null; - emails: AccountEmail[]; - links: AccountLink[]; - }; - contents: ArticleContent[]; - }, -): Promise< - Post & { - actor: Actor & { - account: Account & { - avatarMedium: Medium | null; - emails: AccountEmail[]; - links: AccountLink[]; - }; - instance: Instance; - }; - articleSource: ArticleSource & { - account: Account & { - avatarMedium: Medium | null; - emails: AccountEmail[]; - links: AccountLink[]; - }; - contents: ArticleContent[]; - }; - mentions: Mention[]; - } -> { - const { db, kv, storage: disk } = fedCtx; - const actor = await syncActorFromAccount(fedCtx, articleSource.account); - const content = getOriginalArticleContent(articleSource); - if (content == null) { - throw new Error("No content."); - } - const rendered = await renderMarkup(fedCtx, content.content, { - docId: articleSource.id, - kv, - mediumUrls: await getArticleSourceMediumUrls(db, disk, articleSource.id), - missingMediumLabel: getMissingArticleMediumLabel(content.language), - }); - const url = - `${fedCtx.origin}/@${articleSource.account.username}/${articleSource.publishedYear}/${ - encodeURIComponent(articleSource.slug) - }`; - const link = await persistArticleNewsLink(fedCtx, { - url, - iri: fedCtx.getObjectUri(vocab.Article, { id: articleSource.id }).href, - name: content.title, - summary: content.summary, - contentHtml: rendered.html, - }, actor); - const values: Omit = { - iri: fedCtx.getObjectUri(vocab.Article, { id: articleSource.id }).href, - type: "Article", - visibility: "public", - quotePolicy: articleSource.quotePolicy, - actorId: actor.id, - articleSourceId: articleSource.id, - name: content.title, - summary: content.summary, - contentHtml: rendered.html, - language: content.language, - tags: Object.fromEntries( - [...articleSource.tags, ...rendered.hashtags].map((tag) => [ - tag.toLowerCase().replace(/^#/, ""), - `${fedCtx.canonicalOrigin}/tags/${ - encodeURIComponent(tag.replace(/^#/, "")) - }`, - ]), - ), - linkId: link?.id ?? null, - linkUrl: link?.url ?? null, - url, - updated: articleSource.updated, - published: articleSource.published, - }; - const existingPost = await db.query.postTable.findFirst({ - columns: { id: true, name: true, contentHtml: true, linkId: true }, - where: { articleSourceId: articleSource.id }, - }); - const rows = await db.insert(postTable) - .values({ id: generateUuidV7(), ...values }) - .onConflictDoUpdate({ - target: postTable.articleSourceId, - set: values, - setWhere: eq(postTable.articleSourceId, articleSource.id), - }) - .returning(); - const [post] = rows; - await createTargetPostUpdatedNotifications(db, existingPost, post, actor); - await db.delete(mentionTable).where(eq(mentionTable.postId, post.id)); - const mentionList = globalThis.Object.values(rendered.mentions); - const mentions = mentionList.length > 0 - ? await db.insert(mentionTable).values( - mentionList.map((actor) => ({ - postId: post.id, - actorId: actor.id, - })), - ).onConflictDoNothing().returning() - : []; - await refreshNewsScores(db, [post.linkId, existingPost?.linkId]); - return { ...post, actor, mentions, articleSource }; -} - -export async function syncPostFromNoteSource( - fedCtx: ApplicationContext, - noteSource: NoteSource & { - account: Account & { - avatarMedium: Medium | null; - emails: AccountEmail[]; - links: AccountLink[]; - }; - media: NoteSourceMediumWithMedium[]; - }, - relations: { - replyTarget?: Post & { actor: Actor }; - quotedPost?: Post & { actor: Actor }; - question?: { - title: string; - poll: CreatePollInput; - }; - } = {}, -): Promise< - | Post & { - actor: Actor & { - account: Account & { - avatarMedium: Medium | null; - emails: AccountEmail[]; - links: AccountLink[]; - }; - instance: Instance; - }; - noteSource: NoteSource & { - account: Account & { - avatarMedium: Medium | null; - emails: AccountEmail[]; - links: AccountLink[]; - }; - media: NoteSourceMediumWithMedium[]; - }; - replyTarget: Post & { actor: Actor } | null; - quotedPost: Post & { actor: Actor } | null; - quoteRequestRequired: boolean; - quoteRequestTarget: Post & { actor: Actor } | null; - mentions: (Mention & { actor: Actor })[]; - media: PostMedium[]; - poll: (Poll & { options: PollOption[] }) | null; - } - | undefined -> { - const { db, kv, storage: disk } = fedCtx; - const existingPost = await db.query.postTable.findFirst({ - columns: { - id: true, - type: true, - name: true, - contentHtml: true, - quotedPostId: true, - quoteAuthorizationIri: true, - quoteTargetState: true, - linkId: true, - }, - where: { noteSourceId: noteSource.id }, - }); - const type = existingPost?.type ?? - (relations.question == null ? "Note" : "Question"); - if (existingPost != null && existingPost.type !== type) return undefined; - const iri = type === "Question" - ? fedCtx.getObjectUri(vocab.Question, { id: noteSource.id }).href - : fedCtx.getObjectUri(vocab.Note, { id: noteSource.id }).href; - const actor = await syncActorFromAccount(fedCtx, noteSource.account); - const hasQuotedPostRelation = Object.hasOwn(relations, "quotedPost"); - let quotedPost: QuotePolicyPost | undefined; - if (relations.quotedPost != null) { - quotedPost = await getAllowedQuoteTargetForActor( - db, - actor, - relations.quotedPost, - ); - if (quotedPost == null) { - logger.warn("Rejecting local quote creation due to quote policy.", { - noteSourceId: noteSource.id, - actorId: actor.id, - quotedPostId: relations.quotedPost.sharedPostId ?? - relations.quotedPost.id, - }); - return undefined; - } - } - // FIXME: Note should be rendered in a different way - const rendered = await renderMarkup(fedCtx, noteSource.content, { - docId: noteSource.id, - kv, - }); - const externalLinks = extractExternalLinks(rendered.html); - const link = externalLinks.length > 0 - ? await persistPostLink(fedCtx, externalLinks[0]) - : undefined; - const url = new URL( - `/@${noteSource.account.username}/${noteSource.id}`, - fedCtx.canonicalOrigin, - ).href; - const id = existingPost?.id ?? generateUuidV7(); - const quoteTargetId = quotedPost?.id ?? null; - const existingQuoteAuthorizationIri = - existingPost != null && existingPost.quotedPostId === quoteTargetId - ? existingPost.quoteAuthorizationIri - : null; - const quoteRequestRequired = quotedPost != null && - !canActorQuotePost(quotedPost, actor) && - existingQuoteAuthorizationIri == null; - const quotedPostId = !hasQuotedPostRelation && existingPost != null - ? undefined - : quoteRequestRequired - ? null - : quoteTargetId; - const quoteAuthorizationIri = !hasQuotedPostRelation && existingPost != null - ? undefined - : quotedPost == null - ? null - : quoteRequestRequired - ? null - : quotedPost.actor.accountId == null - ? existingQuoteAuthorizationIri - : quotedPost.actorId === actor.id - ? null - : fedCtx.getObjectUri(vocab.QuoteAuthorization, { id }).href; - const quoteTargetState = !hasQuotedPostRelation && existingPost != null - ? undefined - : quoteRequestRequired - ? "pending" - : null; - const values: Omit = { - iri, - type, - visibility: noteSource.visibility, - quotePolicy: normalizeQuotePolicyForVisibility( - noteSource.visibility, - noteSource.quotePolicy, - ), - actorId: actor.id, - noteSourceId: noteSource.id, - replyTargetId: relations.replyTarget?.id, - quotedPostId, - quoteAuthorizationIri, - quoteTargetState, - name: relations.question?.title, - contentHtml: rendered.html, - language: noteSource.language, - tags: Object.fromEntries( - rendered.hashtags.map((tag) => [ - tag.toLowerCase().replace(/^#/, ""), - `${fedCtx.canonicalOrigin}/tags/${ - encodeURIComponent(tag.replace(/^#/, "")) - }`, - ]), - ), - // Use explicit `null` (not `undefined`) so editing a note to remove its - // link actually clears `link_id` on update: drizzle drops `undefined` from - // the update set, which would otherwise leave the old link attached (and - // keep a dropped story in the news feed). Matches `persistPost`. - linkId: link?.id ?? null, - // Keep the exact URL the author shared for navigation. The PostLink URL - // is a fragment-less fetch/aggregation identity and may differ after an - // HTTP redirect; neither should replace the author's query or fragment. - linkUrl: link == null ? null : externalLinks[0].href, - url, - updated: noteSource.updated, - published: noteSource.published, - }; - const rows = await db.insert(postTable) - .values({ id, ...values }) - .onConflictDoUpdate({ - target: postTable.noteSourceId, - set: values, - setWhere: eq(postTable.noteSourceId, noteSource.id), - }) - .returning(); - const post = rows[0]; - await createTargetPostUpdatedNotifications(db, existingPost, post, actor); - if (post.quoteAuthorizationIri != null && quotedPost != null) { - await db.insert(quoteAuthorizationTable).values({ - id, - iri: post.quoteAuthorizationIri, - quotePostIri: post.iri, - quotePostId: post.id, - quotedPostId: quotedPost.sharedPostId ?? quotedPost.id, - attributedActorId: quotedPost.actorId, - }).onConflictDoUpdate({ - target: quoteAuthorizationTable.iri, - set: { - quotePostIri: post.iri, - quotePostId: post.id, - quotedPostId: quotedPost.sharedPostId ?? quotedPost.id, - attributedActorId: quotedPost.actorId, - revoked: false, - updated: sql`CURRENT_TIMESTAMP`, - }, - }); - } - await db.delete(mentionTable).where(eq(mentionTable.postId, post.id)); - const mentionList = globalThis.Object.values(rendered.mentions); - - if (hasQuotedPostRelation && quoteAuthorizationIri == null) { - await db.delete(quoteAuthorizationTable) - .where(eq(quoteAuthorizationTable.quotePostId, post.id)); - } - if ( - hasQuotedPostRelation && - existingPost?.quotedPostId != null && - existingPost.quotedPostId !== quotedPostId - ) { - const previousQuotedPost = await db.query.postTable.findFirst({ - where: { id: existingPost.quotedPostId }, - }); - if (previousQuotedPost != null) { - await updateQuotesCount(db, previousQuotedPost, -1); - } - } - if ( - hasQuotedPostRelation && quotedPost != null && - quotedPostId != null && - existingPost?.quotedPostId !== quotedPostId - ) { - await updateQuotesCount(db, quotedPost, 1); - } - const mentions = mentionList.length > 0 - ? (await db.insert(mentionTable).values( - mentionList.map((actor) => ({ - postId: post.id, - actorId: actor.id, - })), - ).onConflictDoNothing().returning()).map((m) => ({ - ...m, - actor: mentionList.find((a) => a.id === m.actorId)!, - })) - : []; - await db.delete(postMediumTable).where(eq(postMediumTable.postId, post.id)); - const media = noteSource.media.length > 0 - ? await db.insert(postMediumTable).values( - await Promise.all(noteSource.media.map(async (medium) => ({ - postId: post.id, - index: medium.index, - type: medium.medium.type, - url: await disk.getUrl(medium.medium.key), - alt: medium.alt, - width: medium.medium.width, - height: medium.medium.height, - }))), - ).returning() - : []; - const poll = relations.question == null - ? null - : await createPoll(db, post.id, relations.question.poll); - const returnedQuotedPost = hasQuotedPostRelation - ? quoteRequestRequired ? null : quotedPost ?? null - : post.quotedPostId == null - ? null - : await db.query.postTable.findFirst({ - where: { id: post.quotedPostId }, - with: { actor: true }, - }) ?? null; - const quoteRequestTarget = quoteRequestRequired ? quotedPost ?? null : null; - // Score the link this note now shares; also refresh the previous link when - // an edit changed or removed it, so the old story can drop out. - await refreshNewsScores(db, [post.linkId, existingPost?.linkId]); - return { - ...post, - actor, - noteSource, - mentions, - media, - replyTarget: relations.replyTarget ?? null, - quotedPost: returnedQuotedPost, - quoteRequestRequired, - quoteRequestTarget, - poll, - }; -} - -export async function persistPost( - ctx: ApplicationContext, - post: PostObject, - options: { - actor?: Actor & { instance: Instance }; - replyTarget?: Post & { actor: Actor & { instance: Instance } }; - replies?: boolean; - depth?: number; - maxDepth?: number; - maxReplies?: number; - inlineRepliesThreshold?: number; - deferLargeReplies?: boolean; - contextLoader?: DocumentLoader; - documentLoader?: DocumentLoader; - /** - * Shared overall deadline for the whole synchronous persist subtree. Left - * unset by top-level callers (an inbox handler): the first call mints one - * from {@link PERSIST_POST_OVERALL_BUDGET_MS} and threads it through the - * synchronous recursion so every level shares one budget. The deferred - * reply backfill deliberately does NOT inherit it (each backfilled reply - * gets its own fresh budget instead of being cut off by the handler's). - */ - signal?: AbortSignal; - } = {}, -): Promise< - | Post & { - actor: Actor & { instance: Instance }; - mentions: (Mention & { actor: Actor })[]; - replyTarget: Post & { actor: Actor } | null; - quotedPost: Post & { actor: Actor } | null; - poll: Poll | null; - } - | undefined -> { - if (post.id == null || post.attributionId == null || post.content == null) { - logger.debug( - "Missing required fields (id, attributedTo, content): {post}", - { post }, - ); - return; - } - const { db } = ctx; - const depth = options.depth ?? 0; - const maxDepth = options.maxDepth ?? DEFAULT_MAX_PERSIST_POST_DEPTH; - const maxReplies = options.maxReplies ?? DEFAULT_MAX_INLINE_REPLIES; - const inlineRepliesThreshold = options.inlineRepliesThreshold ?? - DEFAULT_INLINE_REPLIES_THRESHOLD; - const deferLargeReplies = options.deferLargeReplies ?? true; - const shouldRecurse = depth < maxDepth; - if (post.id.origin === ctx.canonicalOrigin) { - return await getPersistedPost(db, post.id); - } - let actor = - options.actor == null || options.actor.iri !== post.attributionId.href - ? await getPersistedActor(db, post.attributionId) - : options.actor; - // One deadline for the entire synchronous subtree: reuse the parent's when - // recursing, otherwise mint a fresh one at the top-level (handler) call. - const overallSignal = options.signal ?? - AbortSignal.timeout(PERSIST_POST_OVERALL_BUDGET_MS); - const opts = { - contextLoader: options.contextLoader, - documentLoader: withDocumentLoaderTimeout( - options.documentLoader ?? ctx.documentLoader, - REMOTE_FETCH_TIMEOUT_MS, - overallSignal, - ), - suppressError: true, - }; - if (actor != null && isFederationBlocked(actor)) return undefined; - if (actor == null) { - const apActor = await post.getAttribution(opts); - if (apActor == null) return; - // Use `opts`, not `options`: `opts.documentLoader` is bounded by the - // per-fetch timeout and the shared `overallSignal`, so persistActor's own - // dereferencing (icon/image/attachments/featured/tags) cannot stall this - // handler past the queue timeout. - actor = await persistActor(ctx, apActor, opts); - if (actor == null) { - logger.debug("Failed to persist actor: {actor}", { actor: apActor }); - return; - } - } - const tags: Record = {}; - const mentions = new Set(); - const emojis: Record = {}; - const quotedPostIris: string[] = []; - for await (const tag of post.getTags(opts)) { - if (tag instanceof vocab.Hashtag) { - if (tag.name == null || tag.href == null) continue; - tags[tag.name.toString().replace(/^#/, "").toLowerCase()] = tag.href.href; - } else if (tag instanceof vocab.Mention) { - if (tag.href == null) continue; - mentions.add(tag.href.href); - } else if (tag instanceof vocab.Emoji) { - if (tag.name == null) continue; - const icon = await tag.getIcon(opts); - if ( - icon?.url == null || - icon.url instanceof vocab.Link && icon.url.href == null - ) { - continue; - } - emojis[tag.name.toString()] = icon.url instanceof URL - ? icon.url.href - : icon.url.href!.href; - } else if (tag instanceof vocab.Link) { - if (tag.mediaType == null || tag.href == null) continue; - const [mediaType, ...paramList] = tag.mediaType.split(/\s*;\s*/g); - const params = Object.fromEntries( - paramList.map((param) => { - let [key, value] = param.split(/\s*=\s*/g); - // value can be quoted: - value = value.match(/^"([^"]*)"\s*$/)?.[1] ?? value.trim(); - return [key.trim(), value]; - }), - ); - if ( - mediaType !== "application/activity+json" && - !(mediaType === "application/ld+json" && - params.profile === "https://www.w3.org/ns/activitystreams") - ) { - continue; - } - if (quotedPostIris.includes(tag.href.href)) continue; - quotedPostIris.push(tag.href.href); - } - } - if (post.quoteUrl != null) { - if (!quotedPostIris.includes(post.quoteUrl.href)) { - quotedPostIris.push(post.quoteUrl.href); - } - } - if (post.quoteId != null) { - if (!quotedPostIris.includes(post.quoteId.href)) { - quotedPostIris.unshift(post.quoteId.href); - } - } - let quotedPost: PersistedQuoteTarget | undefined; - let quotedPostIri: string | undefined; - if (quotedPostIris.length > 0) { - const quotedPosts = await db.query.postTable.findMany({ - with: { - actor: { - with: { instance: true }, - }, - }, - where: { iri: { in: quotedPostIris } }, - }); - quotedPosts.sort((a, b) => - quotedPostIris.indexOf(a.iri) - quotedPostIris.indexOf(b.iri) - ); - if (quotedPosts.length > 0) { - quotedPost = quotedPosts[0]; - quotedPostIri = quotedPost.iri; - } else if (shouldRecurse) { - for (const iri of quotedPostIris) { - let obj: vocab.Object | null; - try { - obj = await ctx.lookupObject(iri, opts); - } catch { - continue; - } - if (!isPostObject(obj)) continue; - quotedPost = await persistPost(ctx, obj, { - replies: false, - depth: depth + 1, - maxDepth, - maxReplies, - inlineRepliesThreshold, - deferLargeReplies: false, - contextLoader: options.contextLoader, - documentLoader: options.documentLoader, - signal: overallSignal, - }); - if (quotedPost != null) { - quotedPostIri = iri; - break; - } - } - } - } - if (quotedPost != null) { - quotedPost = await getOriginalQuoteTarget(db, quotedPost); - } - const attachments: vocab.Document[] = []; - for await (const attachment of post.getAttachments(opts)) { - if (attachment instanceof vocab.Document) attachments.push(attachment); - } - let replyTarget: Post & { actor: Actor & { instance: Instance } } | undefined; - if (post.replyTargetId != null) { - replyTarget = options.replyTarget ?? - await getPersistedPost(db, post.replyTargetId); - if (replyTarget == null && shouldRecurse) { - const apReplyTarget = await post.getReplyTarget(opts); - if (!isPostObject(apReplyTarget)) return; - replyTarget = await persistPost(ctx, apReplyTarget, { - ...options, - replies: false, - depth: depth + 1, - signal: overallSignal, - }); - if (replyTarget == null) return; - } - } - const replies = options.replies ? await post.getReplies(opts) : null; - const shares = await post.getShares(opts); - const to = new Set(post.toIds.map((u) => u.href)); - const cc = new Set(post.ccIds.map((u) => u.href)); - const recipients = to.union(cc); - const visibility: PostVisibility = to.has(PUBLIC_COLLECTION.href) - ? "public" - : cc.has(PUBLIC_COLLECTION.href) - ? "unlisted" - : actor.followersUrl != null && recipients.has(actor.followersUrl) && - mentions.isSubsetOf(recipients) - ? "followers" - : mentions.isSubsetOf(recipients) - ? "direct" - : "none"; - logger.debug( - "Post visibility: {visibility} (drived from recipients {recipients} and " + - "mentions {mentions}).", - { visibility, recipients, to, cc, mentions }, - ); - const { quotePolicy, quoteRequestPolicy } = - quotePoliciesFromInteractionPolicy( - post, - visibility, - actor.followersUrl, - ); - let quoteAuthorizationIri = post.quoteAuthorizationId?.href; - const existingPost = await db.query.postTable.findFirst({ - columns: { - id: true, - name: true, - contentHtml: true, - quotedPostId: true, - linkId: true, - }, - where: { iri: post.id.href }, - }); - if (quoteAuthorizationIri != null && quotedPost != null) { - const authorization = await post.getQuoteAuthorization(opts); - let validAuthorization = - authorization instanceof vocab.QuoteAuthorization && - authorization.interactingObjectId?.href === post.id.href && - authorization.interactionTargetId?.href === quotedPost.iri && - authorization.attributionId?.href === quotedPost.actor.iri; - if (validAuthorization && quotedPost.actor.accountId != null) { - const issuedAuthorization = await db.select({ - id: quoteAuthorizationTable.id, - }) - .from(quoteAuthorizationTable) - .where(and( - eq(quoteAuthorizationTable.iri, quoteAuthorizationIri), - eq(quoteAuthorizationTable.quotePostIri, post.id.href), - eq(quoteAuthorizationTable.quotedPostId, quotedPost.id), - eq(quoteAuthorizationTable.attributedActorId, quotedPost.actorId), - eq(quoteAuthorizationTable.revoked, false), - )) - .limit(1); - validAuthorization = issuedAuthorization.length > 0; - } else if (validAuthorization) { - // Fedify dereferences cross-origin embedded authorizations before this. - validAuthorization = new URL(quoteAuthorizationIri).origin === - new URL(quotedPost.actor.iri).origin; - } - if (!validAuthorization) { - logger.debug("Ignoring invalid quote authorization: {iri}", { - iri: quoteAuthorizationIri, - }); - quoteAuthorizationIri = undefined; - } - } - if ( - quotedPost?.visibility === "direct" || quotedPost?.visibility === "none" - ) { - logger.debug("Ignoring quoted post with private visibility: {iri}", { - iri: quotedPost.iri, - }); - quotedPost = undefined; - } - let unauthorizedQuoteTarget: PersistedQuoteTarget | undefined; - // A censored post cannot be quoted, even through a quote authorization - // issued before moderators censored it. This runs before (and independent - // of) the policy check below, which the authorization path skips, so - // censorship is never overridden by an outstanding authorization. - if (quotedPost != null && quotedPost.censored != null) { - logger.debug("Ignoring censored quoted post: {iri}", { - iri: quotedPost.iri, - }); - unauthorizedQuoteTarget = quotedPost; - quotedPost = undefined; - quoteAuthorizationIri = undefined; - } - if ( - quotedPost != null && - quoteAuthorizationIri == null && - !(await canPersistIncomingQuote(db, quotedPost.id, actor)) - ) { - logger.debug("Ignoring quoted post denied by quote policy: {iri}", { - iri: quotedPost.iri, - }); - unauthorizedQuoteTarget = quotedPost; - quotedPost = undefined; - } - if (quotedPost == null && quoteAuthorizationIri != null) { - logger.debug("Ignoring quote authorization without quote target: {iri}", { - iri: quoteAuthorizationIri, - }); - quoteAuthorizationIri = undefined; - } - const quoteTargetState: Post["quoteTargetState"] = quotedPost != null || - quotedPostIris.length < 1 || existingPost == null || - unauthorizedQuoteTarget == null - ? null - : await getPersistedQuoteTargetState( - db, - existingPost.id, - unauthorizedQuoteTarget.id, - ); - const mentionedActors = await resolveMentionedActors(ctx, mentions, opts); - const mentionLinkHrefs = new Set(mentions); - for (const actor of mentionedActors) { - for (const href of getActorMentionHrefs(actor)) mentionLinkHrefs.add(href); - } - const contentHtml = post.content?.toString(); - const postUrl = post.url instanceof vocab.Link - ? post.url.href?.href - : post.url?.href; - const type = post instanceof vocab.Article - ? "Article" - : post instanceof vocab.Note - ? "Note" - : post instanceof vocab.Question - ? "Question" - : assertNever(post, `Unexpected type of post: ${post}`); - const qualifyingArticleNewsPost = type === "Article" && - (visibility === "public" || visibility === "unlisted") && - replyTarget == null && - quotedPost == null; - const articleNewsLink = qualifyingArticleNewsPost - ? await persistArticleNewsLink(ctx, { - url: postUrl, - iri: post.id.href, - name: post.name?.toString(), - summary: post.summary?.toString(), - contentHtml, - }, actor) - : undefined; - let externalLinks = contentHtml == null - ? [] - : extractExternalLinks(contentHtml, { excludeHrefs: mentionLinkHrefs }); - if (quotedPost != null) { - externalLinks = externalLinks.filter((l) => - quotedPost.iri !== l.href && - quotedPost.url !== l.href && - quotedPostIri !== l.href - ); - } - const embeddedLink = articleNewsLink == null && externalLinks.length > 0 - ? await persistPostLink(ctx, externalLinks[0], { signal: overallSignal }) - : undefined; - const link = articleNewsLink ?? embeddedLink; - const values: Omit = { - iri: post.id.href, - type, - visibility, - quotePolicy, - quoteRequestPolicy, - actorId: actor.id, - sensitive: post.sensitive ?? false, - name: post.name?.toString(), - summary: post.summary?.toString(), - contentHtml, - language: post.content instanceof LanguageString - ? post.content.locale.toString() - : post.contents.length > 1 && post.contents[1] instanceof LanguageString - ? post.contents[1].locale.toString() - : undefined, - tags, - emojis, - linkId: link?.id ?? null, - // Keep the exact URL from the post body for navigation. Canonical - // metadata and redirects belong to the shared PostLink identity only. - linkUrl: link == null - ? null - : articleNewsLink != null - ? articleNewsLink.url - : externalLinks[0].href, - url: postUrl, - replyTargetId: replyTarget?.id, - quotedPostId: quotedPost?.id ?? null, - quoteAuthorizationIri: quoteAuthorizationIri ?? null, - quoteTargetState, - repliesCount: replies?.totalItems ?? 0, - sharesCount: shares?.totalItems ?? 0, - updated: toDate(post.updated ?? post.published) ?? undefined, - published: toDate(post.published) ?? undefined, - }; - const { - repliesCount: _repliesCount, - sharesCount: _sharesCount, - ...updateSet - } = values; - const rows = await db.insert(postTable) - .values({ id: generateUuidV7(), ...values }) - .onConflictDoUpdate({ - target: postTable.iri, - set: updateSet, - setWhere: eq(postTable.iri, post.id.href), - }) - .returning(); - const persistedPost = { ...rows[0], actor }; - await createTargetPostUpdatedNotifications( - db, - existingPost, - persistedPost, - actor, - ); - if (quoteAuthorizationIri != null && quotedPost != null) { - await db.insert(quoteAuthorizationTable).values({ - id: generateUuidV7(), - iri: quoteAuthorizationIri, - quotePostIri: persistedPost.iri, - quotePostId: persistedPost.id, - quotedPostId: quotedPost.id, - attributedActorId: quotedPost.actorId, - }).onConflictDoUpdate({ - target: quoteAuthorizationTable.iri, - set: { - quotePostIri: persistedPost.iri, - quotePostId: persistedPost.id, - quotedPostId: quotedPost.id, - attributedActorId: quotedPost.actorId, - revoked: false, - updated: sql`CURRENT_TIMESTAMP`, - }, - }); - } - await db.delete(mentionTable).where( - eq(mentionTable.postId, persistedPost.id), - ); - - if ( - existingPost?.quotedPostId != null && - existingPost.quotedPostId !== quotedPost?.id - ) { - const previousQuotedPost = await db.query.postTable.findFirst({ - where: { id: existingPost.quotedPostId }, - }); - if (previousQuotedPost != null) { - await updateQuotesCount(db, previousQuotedPost, -1); - } - } - if (quotedPost != null && existingPost?.quotedPostId !== quotedPost.id) { - await updateQuotesCount(db, quotedPost, 1); - } - let mentionList: (Mention & { actor: Actor })[] = []; - const mentionsResult = mentionedActors.length > 0 - ? await db.insert(mentionTable) - .values( - mentionedActors.map((actor) => ({ - postId: persistedPost.id, - actorId: actor.id, - })), - ) - .onConflictDoNothing() - .returning() - .execute() - : []; - mentionList = mentionsResult.map((m) => ({ - ...m, - actor: mentionedActors.find((a) => a.id === m.actorId)!, - })); - await db.delete(postMediumTable).where( - eq(postMediumTable.postId, persistedPost.id), - ); - let i = 0; - for (const attachment of attachments) { - await persistPostMedium(ctx, attachment, persistedPost.id, i); - i++; - } - if (options.replies && depth === 0 && replies != null) { - const totalItems = replies.totalItems ?? 0; - const canInlineReplies = totalItems < 1 || - totalItems <= inlineRepliesThreshold; - if (canInlineReplies) { - let repliesCount = 0; - const traversalDeadline = Date.now() + INLINE_REPLIES_TRAVERSAL_BUDGET_MS; - const repliesIterator = traverseCollection(replies, opts)[ - Symbol.asyncIterator - ](); - while (repliesCount < maxReplies) { - let result: Awaited>; - try { - result = await repliesIterator.next(); - } catch (error) { - logger.debug( - "Inline replies traversal for {postIri} failed after " + - "{repliesCount} replies; keeping the parent post.", - { postIri: persistedPost.iri, repliesCount, error }, - ); - break; - } - if (result.done) break; - if (Date.now() >= traversalDeadline) { - logger.debug( - "Inline replies traversal for {postIri} hit the {budgetMs}ms " + - "budget after {repliesCount} replies; stopping early to stay " + - "under the message handler timeout.", - { - postIri: persistedPost.iri, - budgetMs: INLINE_REPLIES_TRAVERSAL_BUDGET_MS, - repliesCount, - }, - ); - break; - } - const reply = result.value; - if (!isPostObject(reply)) continue; - await persistPost(ctx, reply, { - ...options, - actor, - replyTarget: persistedPost, - replies: false, - depth: depth + 1, - signal: overallSignal, - }); - repliesCount++; - } - if (persistedPost.repliesCount < repliesCount) { - await db.update(postTable) - .set({ - repliesCount: - sql`GREATEST(${postTable.repliesCount}, ${repliesCount})`, - }) - .where(eq(postTable.id, persistedPost.id)); - persistedPost.repliesCount = Math.max( - persistedPost.repliesCount, - repliesCount, - ); - } - } else if (deferLargeReplies) { - const lockKey = `reply-backfill/${persistedPost.iri}`; - const [locked] = await ctx.kv.getMany([lockKey]); - if (locked !== "1") { - // Best-effort dedupe lock: avoid spawning multiple backfills for - // the same post during bursty inbox traffic. - await ctx.kv.set(lockKey, "1", REPLIES_BACKFILL_LOCK_TTL_MS); - await queueAfterCommit(ctx, () => { - const backgroundDb = ctx.rootDb ?? ctx.db; - const backgroundCtx: ApplicationContext = { - ...ctx.withDatabase(backgroundDb), - db: backgroundDb, - rootDb: backgroundDb, - afterCommit: undefined, - }; - void (async () => { - // This runs in the background after the handler returns, so it must - // NOT inherit the handler's overall deadline (`opts` is bound to - // `overallSignal`). Give it a loader with only the per-fetch timeout - // so a long backfill is not truncated at the synchronous handler's - // budget; each backfilled reply still gets its own fresh overall - // budget via the `persistPost` call below (which omits `signal`). - const backfillOpts = { - contextLoader: options.contextLoader, - documentLoader: withDocumentLoaderTimeout( - options.documentLoader ?? backgroundCtx.documentLoader, - ), - suppressError: true, - }; - const persistReply = async ( - attempt: number, - ): Promise => { - try { - let count = 0; - for await ( - const reply of traverseCollection(replies, backfillOpts) - ) { - if (count >= maxReplies) break; - if (!isPostObject(reply)) continue; - await persistPost(backgroundCtx, reply, { - ...options, - actor, - replyTarget: persistedPost, - replies: false, - depth: depth + 1, - // Don't inherit a caller's top-level deadline (`...options` - // may carry one); each backfilled reply mints its own fresh - // budget so the background batch isn't cut off by the - // originating handler's deadline. - signal: undefined, - }); - count++; - } - if (persistedPost.repliesCount < count) { - await backgroundDb.update(postTable) - .set({ - repliesCount: - sql`GREATEST(${postTable.repliesCount}, ${count})`, - }) - .where(eq(postTable.id, persistedPost.id)); - persistedPost.repliesCount = Math.max( - persistedPost.repliesCount, - count, - ); - } - } catch (error) { - if (attempt < 1) { - // Single delayed retry to absorb transient federation failures - // without introducing a durable queue. - await new Promise((resolve) => - setTimeout(resolve, REPLIES_BACKFILL_RETRY_DELAY_MS) - ); - await persistReply(attempt + 1); - return; - } - logger.warn( - "Failed to backfill replies for {postIri} after retry: {error}", - { postIri: persistedPost.iri, error }, - ); - } - }; - await persistReply(0); - })().catch((error) => { - logger.warn( - "Replies backfill task failed for {postIri}: {error}", - { - postIri: persistedPost.iri, - error, - }, - ); - }); - }); - } - } - } - let poll: Poll | undefined; - if (post instanceof vocab.Question) { - poll = await persistPoll(db, post, persistedPost.id); - } - if (depth === 0) { - await queueAfterCommit(ctx, () => { - const db = ctx.rootDb ?? ctx.db; - return enqueueEmojiReactionsBackfill( - { - ...ctx.withDatabase(db), - db, - rootDb: db, - afterCommit: undefined, - }, - post, - persistedPost, - { - contextLoader: options.contextLoader, - documentLoader: options.documentLoader, - }, - ); - }); - } - // Only refresh at the top level: recursive reply backfill (depth > 0) would - // re-score the same story once per reply; the periodic sweep covers those. - if (depth === 0) { - await refreshNewsScores(db, [persistedPost.linkId, existingPost?.linkId]); - } - return { - ...persistedPost, - replyTarget: replyTarget ?? null, - quotedPost: quotedPost ?? null, - mentions: mentionList, - poll: poll ?? null, - }; -} - -export async function persistSharedPost( - ctx: ApplicationContext, - announce: vocab.Announce, - options: { - actor?: Actor & { instance: Instance }; - contextLoader?: DocumentLoader; - documentLoader?: DocumentLoader; - /** - * Shared overall deadline for the whole operation. When provided by the - * caller (e.g. an inbox handler that already minted one), getActor(), - * getObject(), and the internal persistPost() all share this signal so - * their aggregate cannot exceed the message-queue handler timeout. When - * omitted, a fresh budget of PERSIST_POST_OVERALL_BUDGET_MS is minted. - */ - signal?: AbortSignal; - } = {}, -): Promise< - Post & { - actor: Actor & { instance: Instance }; - sharedPost: Post & { actor: Actor & { instance: Instance } }; - } | undefined -> { - if (announce.id == null || announce.actorId == null) { - logger.debug( - "Missing required fields (id, actor): {announce}", - { announce }, - ); - return; - } - const announceId = announce.id.href; - const { db } = ctx; - // One deadline for this entire operation. Reuse the caller's signal when - // available so pre-persistPost fetches (getActor, getObject) and the - // persistPost subtree are all capped by the same wall-clock budget. - const overallSignal = options.signal ?? - AbortSignal.timeout(PERSIST_POST_OVERALL_BUDGET_MS); - const boundedOpts = { - contextLoader: options.contextLoader, - documentLoader: withDocumentLoaderTimeout( - options.documentLoader ?? ctx.documentLoader, - REMOTE_FETCH_TIMEOUT_MS, - overallSignal, - ), - suppressError: true, - }; - let actor: Actor & { instance: Instance } | undefined = - options.actor == null || options.actor.iri !== announce.actorId.href - ? await getPersistedActor(db, announce.actorId) - : options.actor; - if (actor != null && isFederationBlocked(actor)) return; - if (actor == null) { - const apActor = await announce.getActor(boundedOpts); - if (apActor == null) return; - actor = await persistActor(ctx, apActor, boundedOpts); - if (actor == null) return; - } - const object = await announce.getObject(boundedOpts); - if (!isPostObject(object)) return; - const post = await persistPost(ctx, object, { - ...options, - replies: true, - signal: overallSignal, - }); - if (post == null) return; - // A censored post cannot be re-amplified via a federated boost, mirroring - // the local sharePost() guard: drop the Announce instead of inserting a - // wrapper that timelines and the share notification would surface. - if (post.censored != null) return; - const to = new Set(announce.toIds.map((u) => u.href)); - const cc = new Set(announce.ccIds.map((u) => u.href)); - const values: Omit = { - iri: announceId, - type: post.type, - visibility: to.has(PUBLIC_COLLECTION.href) - ? "public" - : cc.has(PUBLIC_COLLECTION.href) - ? "unlisted" - : actor.followersUrl != null && - (to.has(actor.followersUrl) || cc.has(actor.followersUrl)) - ? "followers" - : "none", - actorId: actor.id, - sharedPostId: post.id, - name: post.name, - contentHtml: post.contentHtml, - language: post.language, - tags: {}, - emojis: post.emojis, - sensitive: post.sensitive, - url: post.url, - updated: toDate(announce.updated ?? announce.published) ?? undefined, - published: toDate(announce.published) ?? undefined, - }; - return await runInTransaction(db, async (tx) => { - const lockKeys = [ - `announce:${announceId}`, - `share:${actor.id}:${post.id}`, - ].sort(); - for (const lockKey of lockKeys) { - await tx.execute(sql` - SELECT pg_advisory_xact_lock(hashtextextended(${lockKey}, 0)) - `); - } - - const existingByIri = await tx.query.postTable.findFirst({ - where: { iri: announceId }, - }); - if (existingByIri != null && existingByIri.sharedPostId == null) { - logger.warn( - "Dropping Announce {announceId}: its IRI belongs to a non-share post.", - { announceId }, - ); - return undefined; - } - const existingByPair = await tx.query.postTable.findFirst({ - where: { actorId: actor.id, sharedPostId: post.id }, - }); - const affectedNewsLinkIds = new Set(); - if (post.type === "Article" && post.linkId != null) { - affectedNewsLinkIds.add(post.linkId); - } - - if ( - existingByIri != null && existingByPair != null && - existingByIri.id !== existingByPair.id - ) { - logger.warn( - "Keeping existing share {shareId}: Announce {announceId} also belongs to share {iriShareId}.", - { - shareId: existingByPair.id, - announceId, - iriShareId: existingByIri.id, - }, - ); - await refreshNewsScores(tx, [...affectedNewsLinkIds]); - return { ...existingByPair, actor, sharedPost: post }; - } - const existing = existingByIri ?? existingByPair; - - let persistedShare: Post; - if (existing == null) { - const rows = await tx.insert(postTable) - .values({ id: generateUuidV7(), ...values }) - .returning(); - if (rows.length < 1) return undefined; - persistedShare = rows[0]; - await updateSharesCount(tx, post, 1); - } else { - const rows = await tx.update(postTable) - .set(values) - .where(eq(postTable.id, existing.id)) - .returning(); - if (rows.length < 1) return undefined; - persistedShare = rows[0]; - if (existing.sharedPostId !== post.id) { - if (existing.sharedPostId != null) { - const previousPost = await tx.query.postTable.findFirst({ - where: { id: existing.sharedPostId }, - }); - if (previousPost != null) { - await updateSharesCount(tx, previousPost, -1); - if ( - previousPost.type === "Article" && previousPost.linkId != null - ) { - affectedNewsLinkIds.add(previousPost.linkId); - } - } - } - await updateSharesCount(tx, post, 1); - } - } - - await refreshNewsScores(tx, [...affectedNewsLinkIds]); - return { ...persistedShare, actor, sharedPost: post }; - }); -} - -async function getOriginalSharedPost( - db: Database, - post: Post & { actor: Actor }, -): Promise { - if (post.sharedPostId == null) return post; - - const visited = new Set([post.id]); - let currentId: Uuid | null = post.sharedPostId; - while (currentId != null) { - if (visited.has(currentId)) return post; - visited.add(currentId); - - const current: Pick | undefined = await db - .query.postTable.findFirst({ - columns: { id: true, sharedPostId: true }, - where: { id: currentId }, - }); - if (current == null) return post; - if (current.sharedPostId == null) { - const original = await db.query.postTable.findFirst({ - with: { actor: true }, - where: { id: current.id }, - }); - return original ?? post; - } - currentId = current.sharedPostId; - } - - return post; -} - -async function canPersistIncomingQuote( - db: Database, - quotedPostId: Uuid, - actor: Actor, -): Promise { - const quotedPost = await db.query.postTable.findFirst({ - with: { - actor: { - with: { - followers: { where: { followerId: actor.id } }, - blockees: { where: { blockeeId: actor.id } }, - blockers: { where: { blockerId: actor.id } }, - }, - }, - mentions: { where: { actorId: actor.id } }, - }, - where: { id: quotedPostId }, - }); - // Quote *policy* only; the censored (moderation) gate lives at the caller so - // it also applies to the quote-authorization path, which legitimately - // overrides policy but must never override censorship. - return quotedPost != null && canActorQuotePost(quotedPost, actor); -} - -async function getPersistedQuoteTargetState( - db: Database, - quotePostId: Uuid, - quotedPostId: Uuid, -): Promise { - const pendingRequests = await db.select({ id: quoteRequestTable.id }) - .from(quoteRequestTable) - .where(and( - eq(quoteRequestTable.quotePostId, quotePostId), - eq(quoteRequestTable.quotedPostId, quotedPostId), - isNull(quoteRequestTable.accepted), - isNull(quoteRequestTable.rejected), - )) - .limit(1); - if (pendingRequests.length > 0) return "pending"; - - const deniedRequests = await db.select({ id: quoteRequestTable.id }) - .from(quoteRequestTable) - .where(and( - eq(quoteRequestTable.quotePostId, quotePostId), - eq(quoteRequestTable.quotedPostId, quotedPostId), - isNotNull(quoteRequestTable.rejected), - )) - .limit(1); - if (deniedRequests.length > 0) return "denied"; - - const revokedAuthorizations = await db.select({ - id: quoteAuthorizationTable.id, - }) - .from(quoteAuthorizationTable) - .where(and( - eq(quoteAuthorizationTable.quotePostId, quotePostId), - eq(quoteAuthorizationTable.quotedPostId, quotedPostId), - eq(quoteAuthorizationTable.revoked, true), - )) - .limit(1); - return revokedAuthorizations.length > 0 ? "denied" : null; -} - -async function getOriginalQuoteTarget( - db: Database, - post: PersistedQuoteTarget, -): Promise { - const originalPostId = await getOriginalPostId(db, post); - if (originalPostId == null) return undefined; - if (originalPostId === post.id) return post; - return await db.query.postTable.findFirst({ - with: { - actor: { - with: { instance: true }, - }, - }, - where: { id: originalPostId }, - }); -} - -export async function sharePost( - fedCtx: ApplicationContext, - account: Account & { - avatarMedium: Medium | null; - emails: AccountEmail[]; - links: AccountLink[]; - }, - post: Post & { actor: Actor }, - visibility?: PostVisibility, -): Promise { - const { db } = fedCtx; - await assertAccountActorNotSuspended(db, account.id); - const sharedPost = await getOriginalSharedPost(db, post); - // Callers reject censored targets with a proper response; this is a - // backstop so no future caller can boost (and thus re-amplify and - // copy into the wrapper) moderation-hidden content. - if (post.censored != null || sharedPost.censored != null) { - throw new TypeError("A censored post cannot be shared."); - } - const actor = await syncActorFromAccount(fedCtx, account); - const id = generateUuidV7(); - const posts = await db.insert(postTable).values({ - id, - iri: fedCtx.getObjectUri(vocab.Announce, { id }).href, - type: sharedPost.type, - visibility: visibility || account.shareVisibility, - actorId: actor.id, - sharedPostId: sharedPost.id, - name: sharedPost.name, - contentHtml: sharedPost.contentHtml, - language: sharedPost.language, - tags: {}, - emojis: sharedPost.emojis, - sensitive: sharedPost.sensitive, - url: sharedPost.url, - }).onConflictDoNothing().returning(); - if (posts.length < 1) { - const share = await db.query.postTable.findFirst({ - where: { - actorId: actor.id, - sharedPostId: sharedPost.id, - }, - }); - return share!; - } - const share = posts[0]; - sharedPost.sharesCount = await updateSharesCount(db, sharedPost, 1); - share.sharesCount = sharedPost.sharesCount; - await refreshNewsScores(db, [ - sharedPost.type === "Article" ? sharedPost.linkId : null, - ]); - await addPostToTimeline(db, share); - - // Create a share notification for the original post's author - if (sharedPost.actor.accountId != null) { - const notification = await createShareNotification( - db, - sharedPost.actor.accountId, - sharedPost, - actor, - share.published, - ); - logger.debug("Created share notification for {accountId}: {notification}", { - accountId: sharedPost.actor.accountId, - notification, - }); - } - const announce = fedCtx.services.federation.getAnnounce(fedCtx, { - ...share, - sharedPost, - actor: { ...actor, account }, - mentions: [], - }); - await fedCtx.sendActivity( - { identifier: account.id }, - "followers", - announce, - { - orderingKey: share.iri, - preferSharedInbox: true, - excludeBaseUris: [new URL(fedCtx.canonicalOrigin)], - }, - ); - await fedCtx.sendActivity( - { identifier: account.id }, - toRecipient(sharedPost.actor), - announce, - { - orderingKey: share.iri, - excludeBaseUris: [new URL(fedCtx.canonicalOrigin)], - }, - ); - return share; -} - -export async function unsharePost( - fedCtx: ApplicationContext, - account: Account & { - avatarMedium: Medium | null; - emails: AccountEmail[]; - links: AccountLink[]; - }, - sharedPost: Post & { actor: Actor }, -): Promise { - const { db } = fedCtx; - const originalPost = await getOriginalSharedPost(db, sharedPost); - if (originalPost.sharedPostId != null) return; - const actor = await syncActorFromAccount(fedCtx, account); - const unshared = await db.delete(postTable).where( - and( - eq(postTable.actorId, actor.id), - eq(postTable.sharedPostId, originalPost.id), - ), - ).returning(); - if (unshared.length < 1) return undefined; - originalPost.sharesCount = await updateSharesCount(db, originalPost, -1); - await refreshNewsScores(db, [ - originalPost.type === "Article" ? originalPost.linkId : null, - ]); - await removeFromTimeline(db, unshared[0]); - if (originalPost.actor.accountId != null) { - await deleteShareNotification( - db, - originalPost.actor.accountId, - originalPost, - actor, - ); - } - const announce = fedCtx.services.federation.getAnnounce(fedCtx, { - ...unshared[0], - actor: { ...actor, account }, - sharedPost: originalPost, - mentions: [], - }); - const undo = new vocab.Undo({ - actor: fedCtx.getActorUri(account.id), - object: announce, - tos: announce.toIds, - ccs: announce.ccIds, - }); - await fedCtx.sendActivity( - { identifier: account.id }, - "followers", - undo, - { - orderingKey: unshared[0].iri, - preferSharedInbox: true, - excludeBaseUris: [new URL(fedCtx.canonicalOrigin)], - }, - ); - await fedCtx.sendActivity( - { identifier: account.id }, - toRecipient(originalPost.actor), - undo, - { - orderingKey: unshared[0].iri, - excludeBaseUris: [new URL(fedCtx.canonicalOrigin)], - }, - ); - return unshared[0]; -} - -export async function arePostsSharedBy( - db: Database, - postIds: readonly Uuid[], - account: Account & { actor: Actor }, -): Promise> { - if (postIds.length < 1) return new Set(); - const rows = await db.select({ sharedPostId: postTable.sharedPostId }) - .from(postTable) - .where( - and( - eq(postTable.actorId, account.actor.id), - inArray(postTable.sharedPostId, postIds as Uuid[]), - ), - ); - const result = new Set(); - for (const row of rows) { - if (row.sharedPostId != null) result.add(row.sharedPostId); - } - return result; -} - -export function getPersistedPost( - db: Database, - iri: URL, -): Promise< - | Post & { - actor: Actor & { instance: Instance }; - mentions: (Mention & { actor: Actor })[]; - replyTarget: Post & { actor: Actor } | null; - quotedPost: Post & { actor: Actor } | null; - poll: Poll | null; - } - | undefined -> { - return db.query.postTable.findFirst({ - with: { - actor: { - with: { instance: true }, - }, - mentions: { - with: { actor: true }, - }, - replyTarget: { - with: { actor: true }, - }, - quotedPost: { - with: { actor: true }, - }, - poll: true, - }, - where: { - iri: iri.href, - }, - }); -} - -export function getPostByUsernameAndId( - db: Database, - username: string, - id: Uuid, - signedAccount: Account & { actor: Actor } | undefined, -): Promise< - | Post & { - actor: Actor & { - instance: Instance; - followers: Following[]; - blockees: Blocking[]; - blockers: Blocking[]; - }; - link: PostLink & { creator?: Actor | null } | null; - sharedPost: - | Post & { - actor: Actor & { - instance: Instance; - followers: Following[]; - blockees: Blocking[]; - blockers: Blocking[]; - }; - link: PostLink & { creator?: Actor | null } | null; - replyTarget: - | Post & { - actor: Actor & { - instance: Instance; - followers: (Following & { follower: Actor })[]; - blockees: Blocking[]; - blockers: Blocking[]; - }; - link: PostLink & { creator?: Actor | null } | null; - mentions: (Mention & { actor: Actor })[]; - media: PostMedium[]; - } - | null; - mentions: (Mention & { actor: Actor })[]; - media: PostMedium[]; - shares: Post[]; - reactions: Reaction[]; - } - | null; - replyTarget: - | Post & { - actor: Actor & { - instance: Instance; - followers: (Following & { follower: Actor })[]; - blockees: Blocking[]; - blockers: Blocking[]; - }; - link: PostLink & { creator?: Actor | null } | null; - mentions: (Mention & { actor: Actor })[]; - media: PostMedium[]; - } - | null; - mentions: (Mention & { actor: Actor })[]; - media: PostMedium[]; - shares: Post[]; - reactions: Reaction[]; - } - | undefined -> { - if (!username.includes("@")) return Promise.resolve(undefined); - let host: string; - [username, host] = username.split("@"); - return db.query.postTable.findFirst({ - with: { - actor: { - with: { - instance: true, - followers: true, - blockees: true, - blockers: true, - }, - }, - link: { with: { creator: true } }, - sharedPost: { - with: { - actor: { - with: { - instance: true, - followers: { - where: signedAccount == null - ? { RAW: sql`false` } - : { followerId: signedAccount.actor.id }, - }, - blockees: { - where: signedAccount == null - ? { RAW: sql`false` } - : { blockeeId: signedAccount.actor.id }, - }, - blockers: { - where: signedAccount == null - ? { RAW: sql`false` } - : { blockerId: signedAccount.actor.id }, - }, - }, - }, - link: { with: { creator: true } }, - replyTarget: { - with: { - actor: { - with: { - instance: true, - followers: { - where: signedAccount == null - ? { RAW: sql`false` } - : { followerId: signedAccount.actor.id }, - with: { follower: true }, - }, - blockees: { - where: signedAccount == null - ? { RAW: sql`false` } - : { blockeeId: signedAccount.actor.id }, - }, - blockers: { - where: signedAccount == null - ? { RAW: sql`false` } - : { blockerId: signedAccount.actor.id }, - }, - }, - }, - link: { with: { creator: true } }, - mentions: { - with: { actor: true }, - }, - media: true, - }, - }, - mentions: { - with: { actor: true }, - }, - media: true, - shares: { - where: signedAccount == null - ? { RAW: sql`false` } - : { actorId: signedAccount.actor.id }, - }, - reactions: { - where: signedAccount == null - ? { RAW: sql`false` } - : { actorId: signedAccount.actor.id }, - }, - }, - }, - replyTarget: { - with: { - actor: { - with: { - instance: true, - followers: { - where: signedAccount == null - ? { RAW: sql`false` } - : { followerId: signedAccount.actor.id }, - with: { follower: true }, - }, - blockees: { - where: signedAccount == null - ? { RAW: sql`false` } - : { blockeeId: signedAccount.actor.id }, - }, - blockers: { - where: signedAccount == null - ? { RAW: sql`false` } - : { blockerId: signedAccount.actor.id }, - }, - }, - }, - link: { with: { creator: true } }, - mentions: { - with: { actor: true }, - }, - media: true, - }, - }, - mentions: { - with: { actor: true }, - }, - media: true, - shares: { - where: signedAccount == null - ? { RAW: sql`false` } - : { actorId: signedAccount.actor.id }, - }, - reactions: { - where: signedAccount == null - ? { RAW: sql`false` } - : { actorId: signedAccount.actor.id }, - }, - }, - where: { - id, - actor: { - username, - OR: [ - { instanceHost: host }, - { handleHost: host }, - ], - }, - }, - }); -} - -export async function deletePersistedPost( - db: Database, - iri: URL, - actorIri: URL, -): Promise { - const deletedPosts = await db.delete(postTable).where( - and( - eq(postTable.iri, iri.toString()), - inArray( - postTable.actorId, - db.select({ id: actorTable.id }) - .from(actorTable) - .where(eq(actorTable.iri, actorIri.toString())), - ), - isNull(postTable.sharedPostId), - ), - ).returning(); - if (deletedPosts.length < 1) return false; - const [deletedPost] = deletedPosts; - if (deletedPost.replyTargetId != null) { - const replyTarget = await db.query.postTable.findFirst({ - where: { id: deletedPost.replyTargetId }, - }); - if (replyTarget != null) await updateRepliesCount(db, replyTarget, -1); - } - if (deletedPost.quotedPostId != null) { - const quotedPost = await db.query.postTable.findFirst({ - where: { id: deletedPost.quotedPostId }, - }); - if (quotedPost != null) await updateQuotesCount(db, quotedPost, -1); - } - // Re-score the link this post shared and the links of the posts it replied - // to / quoted (their public reply/quote count dropped). - await refreshNewsScoresForPostLinks(db, deletedPost); - return true; -} - -export async function deleteSharedPost( - db: Database, - iri: URL, - actorIri: URL, -): Promise { - const actor = await db.query.actorTable.findFirst({ - where: { iri: actorIri.toString() }, - }); - if (actor == null) return undefined; - const shares = await db.delete(postTable).where( - and( - eq(postTable.iri, iri.toString()), - eq(postTable.actorId, actor.id), - isNotNull(postTable.sharedPostId), - ), - ).returning(); - if (shares.length < 1) return undefined; - const [share] = shares; - if (share.sharedPostId == null) return undefined; - const sharedPost = await db.query.postTable.findFirst({ - where: { id: share.sharedPostId }, - }); - if (sharedPost == null) return { ...share, actor }; - await updateSharesCount(db, sharedPost, -1); - await refreshNewsScores(db, [ - sharedPost.type === "Article" ? sharedPost.linkId : null, - ]); - return { ...share, actor }; -} - -export function isPostVisibleTo( - post: Post & { - actor: Actor & { - followers: Following[]; - blockees: Blocking[]; - blockers: Blocking[]; - }; - mentions: Mention[]; - }, - actor?: Actor, -): boolean; -export function isPostVisibleTo( - post: Post & { - actor: Actor & { - followers: (Following & { follower: Actor })[]; - blockees: (Blocking & { blockee: Actor })[]; - blockers: (Blocking & { blocker: Actor })[]; - }; - mentions: (Mention & { actor: Actor })[]; - }, - actor?: { iri: string }, -): boolean; -export function isPostVisibleTo( - post: Post & { - actor: Actor & { - followers: (Following & { follower?: Actor })[]; - blockees: (Blocking & { blockee?: Actor })[]; - blockers: (Blocking & { blocker?: Actor })[]; - }; - mentions: (Mention & { actor?: Actor })[]; - sharedPost?: (Post & { actor: Actor }) | null; - }, - actor?: Actor | { iri: string }, -): boolean { - // A share wrapper's visibility depends on the boosted post (its author's - // block and sanction state, which the booster's actor does not carry). - // When the boosted post was not loaded, that cannot be evaluated, so fail - // closed rather than let a wrapper of a hidden post pass on the booster - // alone (e.g. an interaction resolver that has only a wrapper id). - if (post.sharedPostId != null && post.sharedPost === undefined) return false; - // A share wrapper denormalizes the boosted post's content, so a boost - // of a sanction-hidden actor's post is hidden too (when the relation - // is loaded). Checked before the wrapper-author fast path: only the - // boosted post's author keeps access, not the booster. - if (post.sharedPost?.actor != null) { - const sharedAuthor = post.sharedPost.actor; - const viewerIsSharedAuthor = actor != null && ( - "id" in actor - ? sharedAuthor.id === actor.id - : sharedAuthor.iri === actor.iri - ); - if (!viewerIsSharedAuthor && isActorSanctionHidden(sharedAuthor)) { - return false; - } - } - if (actor != null) { - if ( - "id" in actor && post.actor.id === actor.id || - "iri" in actor && post.actor.iri === actor.iri - ) { - return true; - } - } - if (isActorSanctionHidden(post.actor)) return false; - if (actor != null) { - const blocked = "id" in actor - ? post.actor.blockees.some((b) => b.blockeeId === actor.id) || - post.actor.blockers.some((b) => b.blockerId === actor.id) - : post.actor.blockees.some((b) => b.blockee?.iri === actor.iri) || - post.actor.blockers.some((b) => b.blocker?.iri === actor.iri); - if (blocked) return false; - } - if (post.visibility === "public" || post.visibility === "unlisted") { - return true; - } - if (actor == null) return false; - if (post.visibility === "followers") { - if ("id" in actor) { - return post.actor.followers.some((follower) => - follower.followerId === actor.id && follower.accepted != null - ) || post.mentions.some((mention) => mention.actorId === actor.id); - } else { - return post.actor.followers.some((follower) => - follower.follower?.iri === actor.iri && follower.accepted != null - ) || post.mentions.some((mention) => mention.actor?.iri === actor.iri); - } - } - if (post.visibility === "direct") { - if ("id" in actor) { - return post.mentions.some((mention) => mention.actorId === actor.id); - } else { - return post.mentions.some((mention) => mention.actor?.iri === actor.iri); - } - } - return false; -} - -export interface PostInteractionPolicy { - readonly canReply: boolean; - readonly canQuote: boolean; - readonly canShare: boolean; -} - -const DENY_ALL: PostInteractionPolicy = { - canReply: false, - canQuote: false, - canShare: false, -}; - -export async function getPostInteractionPolicies( - db: Database, - postIds: readonly Uuid[], - viewer: Actor | null, -): Promise> { - const result = new Map(); - for (const id of postIds) result.set(id, DENY_ALL); - if (postIds.length < 1 || viewer == null) return result; - - // Filter each viewer-relevant relation down to the viewer's row only. - // `isPostVisibleTo` just checks `.some(... === viewer.id ...)`, so loading - // the full follower/blockee/blocker/mention sets for popular actors is - // wasteful — at most one row per relation actually matters. - const posts = await db.query.postTable.findMany({ - with: { - actor: { - with: { - followers: { where: { followerId: viewer.id } }, - blockees: { where: { blockeeId: viewer.id } }, - blockers: { where: { blockerId: viewer.id } }, - }, - }, - mentions: { where: { actorId: viewer.id } }, - sharedPost: { - with: { - actor: { - with: { - followers: { where: { followerId: viewer.id } }, - blockees: { where: { blockeeId: viewer.id } }, - blockers: { where: { blockerId: viewer.id } }, - }, - }, - mentions: { where: { actorId: viewer.id } }, - }, - }, - }, - where: { - id: { in: postIds as Uuid[] }, - }, - }); - - for (const post of posts) { - if (!isPostVisibleTo(post, viewer)) continue; - const effective = post.sharedPost ?? post; - // A censored post (or a wrapper of one) cannot be boosted or quoted by - // anyone, including its author or a moderator: both actions re-amplify - // moderation-hidden content, and the share/quote mutations reject them - // outright. Deny the policy too so the UI never offers an affordance - // that is guaranteed to fail. - const censored = post.censored != null || effective.censored != null; - const canAmplify = !censored && effective.sharedPostId == null && - isPostVisibleTo(effective, viewer) && ( - effective.visibility === "public" || - effective.visibility === "unlisted" || - (effective.visibility === "followers" && - effective.actorId === viewer.id) - ); - const canQuote = !censored && effective.sharedPostId == null && - isPostVisibleTo(effective, viewer) && - canActorRequestQuotePost(effective, viewer); - result.set(post.id, { - canReply: true, - canQuote, - canShare: canAmplify, - }); - } - return result; -} - -/** - * Builds a post filter that excludes posts whose author (or whose shared - * original's author) is muted by the given muter. The `sharedPost` clause - * matters for the public timeline, where shares are wrapper posts: it hides a - * muted author's content even when an unmuted account boosts it. (In the - * personal timeline `post_id` always points at the underlying post, so the - * `sharedPost` clause is a harmless no-op there; muted *sharers* are handled - * separately via `timeline_item.last_sharer_id`.) - * - * Unlike {@link getActorContentExclusionFilter} (used for blocking), this is - * intentionally NOT folded into {@link getPostVisibilityFilter}: muting must - * only hide content from feeds, not from the muted actor's own profile or from - * thread views. Apply it explicitly in feed queries. - */ -export function getMutedActorExclusionFilter( - muterActorId: Uuid, -): RelationsFilter<"postTable"> { - return { - actor: { NOT: { muters: { muterId: muterActorId } } }, - NOT: { sharedPost: { actor: { muters: { muterId: muterActorId } } } }, - } satisfies RelationsFilter<"postTable">; -} - -/** - * Builds a post filter that excludes censored posts (and boosts of censored - * posts) from feed-like surfaces: timelines, search, news, and profile post - * lists. Like {@link getMutedActorExclusionFilter}, this is intentionally - * NOT folded into {@link getPostVisibilityFilter}: a censored post's - * permalink must remain reachable so it can show a censorship notice - * instead of disappearing with a 404. Apply it explicitly in list queries. - * - * When `viewerActorId` is given, the viewer's own censored posts stay - * visible to them ("author can still view their own content"). - */ -export function getCensoredPostExclusionFilter( - viewerActorId?: Uuid | null, -): RelationsFilter<"postTable"> { - return { - ...(viewerActorId == null ? { censored: { isNull: true } } : { - OR: [ - { censored: { isNull: true } }, - { actorId: viewerActorId }, - ], - }), - NOT: { sharedPost: { censored: { isNotNull: true } } }, - } satisfies RelationsFilter<"postTable">; -} - -/** - * Matches actors whose content is currently hidden by a moderation - * sanction: banned local actors (permanent suspension) and remote actors - * under an active federation block (temporary or permanent). A - * *temporarily* suspended local actor's content stays visible; only their - * ability to write is restricted. - * - * Sanction activeness is always evaluated by time comparison against the - * given instant, so expired suspensions need no cleanup writes. - * - * This is a *positive* matcher; it is only safe to negate at the relation - * level (`NOT: { sharedPost: { actor: ... } }` compiles to `NOT EXISTS`). - * Negating it directly on an actor row would trip SQL's three-valued - * logic: for unsanctioned actors `suspended` is `NULL`, the comparison - * evaluates to `NULL`, and `NOT NULL` is still `NULL`, filtering the row - * out. Use {@link getSanctionVisibleActorFilter} for the inclusion form. - */ -export function getSanctionHiddenActorFilter( - now: Date = new Date(), -): RelationsFilter<"actorTable"> { - return { - suspended: { lte: now }, - OR: [ - // Remote actor under an active federation block: - { accountId: { isNull: true }, suspendedUntil: { isNull: true } }, - { accountId: { isNull: true }, suspendedUntil: { gt: now } }, - // Banned local actor: - { accountId: { isNotNull: true }, suspendedUntil: { isNull: true } }, - ], - } satisfies RelationsFilter<"actorTable">; -} - -/** - * The TypeScript-side counterpart of {@link getSanctionHiddenActorFilter}: - * whether the actor's content is currently hidden by a moderation sanction - * (banned local actor, or remote actor under an active federation block). - */ -export function isActorSanctionHidden( - actor: Pick, - now: Date = new Date(), -): boolean { - if (actor.suspended == null || actor.suspended > now) return false; - if (actor.suspendedUntil != null && actor.suspendedUntil <= now) { - return false; // Expired. - } - // An active *temporary* suspension of a local actor only restricts - // writing; a permanent one (ban), or any active sanction on a remote - // actor (federation block), hides content. - return actor.accountId == null || actor.suspendedUntil == null; -} - -/** - * The NULL-safe inclusion complement of - * {@link getSanctionHiddenActorFilter}: matches actors whose content is - * NOT hidden by a moderation sanction, including the common case where - * `suspended` is `NULL`. - */ -export function getSanctionVisibleActorFilter( - now: Date = new Date(), -): RelationsFilter<"actorTable"> { - return { - OR: [ - // Not sanctioned at all: - { suspended: { isNull: true } }, - // Sanction not started yet: - { suspended: { gt: now } }, - // Sanction already expired: - { suspendedUntil: { lte: now } }, - // Active temporary suspension of a *local* actor only restricts - // writing; their content stays visible: - { accountId: { isNotNull: true }, suspendedUntil: { gt: now } }, - ], - } satisfies RelationsFilter<"actorTable">; -} - -function getActorContentExclusionFilter( - actorId: Uuid, -): RelationsFilter<"actorTable"> { - return { - AND: [ - { - NOT: { - OR: [ - { blockees: { blockeeId: actorId } }, - { blockers: { blockerId: actorId } }, - ], - }, - }, - getSanctionVisibleActorFilter(), - ], - } satisfies RelationsFilter<"actorTable">; -} - -export function getPostVisibilityFilter( - actor: Actor | null, -): RelationsFilter<"postTable">; -export function getPostVisibilityFilter( - actor: Post, -): RelationsFilter<"actorTable">; - -export function getPostVisibilityFilter( - actorOrPost: Actor | Post | null, -): RelationsFilter<"postTable"> | RelationsFilter<"actorTable"> { - if (actorOrPost == null) { - return { - visibility: { in: ["public", "unlisted"] }, - actor: getSanctionVisibleActorFilter(), - NOT: { sharedPost: { actor: getSanctionHiddenActorFilter() } }, - } satisfies RelationsFilter<"postTable">; - } - if ("accountId" in actorOrPost) { - return { - actor: getActorContentExclusionFilter(actorOrPost.id), - NOT: { sharedPost: { actor: getSanctionHiddenActorFilter() } }, - OR: [ - { actorId: actorOrPost.id }, - { visibility: { in: ["public", "unlisted"] } }, - { mentions: { actorId: actorOrPost.id } }, - { - visibility: "followers", - actor: { - followers: { - followerId: actorOrPost.id, - accepted: { isNotNull: true }, - }, - }, - }, - ], - } satisfies RelationsFilter<"postTable">; - } else { - if ( - actorOrPost.visibility === "public" || - actorOrPost.visibility === "unlisted" - ) { - return getActorContentExclusionFilter(actorOrPost.actorId); - } - return { - AND: [ - getActorContentExclusionFilter(actorOrPost.actorId), - { - OR: [ - { id: actorOrPost.actorId }, - { mentions: { postId: actorOrPost.id } }, - ...(actorOrPost.visibility === "followers" - ? [{ - followees: { - followeeId: actorOrPost.actorId, - accepted: { isNotNull: true }, - } satisfies RelationsFilter<"followingTable">, - }] - : []), - ], - }, - ], - } satisfies RelationsFilter<"actorTable">; - } -} - -export function getPublicTimelineVisibilityFilter( - actor: Actor | null, -): RelationsFilter<"postTable"> { - if (actor == null) { - return { - visibility: "public", - actor: getSanctionVisibleActorFilter(), - NOT: { sharedPost: { actor: getSanctionHiddenActorFilter() } }, - } satisfies RelationsFilter<"postTable">; - } - return { - actor: getActorContentExclusionFilter(actor.id), - NOT: { sharedPost: { actor: getSanctionHiddenActorFilter() } }, - visibility: "public", - } satisfies RelationsFilter<"postTable">; -} - -export async function updateRepliesCount( - db: Database, - replyTarget: Post, - delta: number, -): Promise { - const repliesCount = replyTarget.repliesCount + delta; - const cnt = await db.select({ count: count() }) - .from(postTable) - .where(eq(postTable.replyTargetId, replyTarget.id)); - if (repliesCount <= cnt[0].count) { - await db.update(postTable) - .set({ repliesCount: cnt[0].count }) - .where(eq(postTable.id, replyTarget.id)); - replyTarget.repliesCount = cnt[0].count; - return cnt[0].count; - } - return repliesCount; -} - -export async function updateSharesCount( - db: Database, - post: Post, - delta: number, -): Promise { - const sharesCount = post.sharesCount + delta; - const cnt = await db.select({ count: count() }) - .from(postTable) - .where(eq(postTable.sharedPostId, post.id)); - if (sharesCount <= cnt[0].count) { - await db.update(postTable) - .set({ sharesCount: cnt[0].count }) - .where(eq(postTable.id, post.id)); - post.sharesCount = cnt[0].count; - return cnt[0].count; - } - return sharesCount; -} - -export async function updateQuotesCount( - db: Database | Transaction, - post: Post, - delta: number, -): Promise { - const quotesCount = post.quotesCount + delta; - const cnt = await db.select({ count: count() }) - .from(postTable) - .where(eq(postTable.quotedPostId, post.id)); - if (quotesCount <= cnt[0].count) { - await db.update(postTable) - .set({ quotesCount: cnt[0].count }) - .where(eq(postTable.id, post.id)); - post.quotesCount = cnt[0].count; - return cnt[0].count; - } - return quotesCount; -} - -export async function revokeQuote( - fedCtx: ApplicationContext, - account: Account, - quotePost: Post & { actor: Actor }, - quotedPost: Post, -): Promise { - const { db } = fedCtx; - const revoked = new Date(); - let updatedQuote: QuoteUpdatePost | undefined; - const rows = await db.update(postTable) - .set({ - quotedPostId: null, - quoteAuthorizationIri: null, - quoteTargetState: "denied", - updated: revoked, - }) - .where(and( - eq(postTable.id, quotePost.id), - eq(postTable.quotedPostId, quotedPost.id), - )) - .returning(); - const updatedPost = rows[0]; - if (updatedPost == null) { - return await db.query.postTable.findFirst({ - where: { id: quotePost.id }, - }) ?? - quotePost; - } - if (quotePost.actor.accountId != null && quotePost.noteSourceId != null) { - await db.update(noteSourceTable) - .set({ updated: revoked }) - .where(eq(noteSourceTable.id, quotePost.noteSourceId)); - updatedQuote = await db.query.postTable.findFirst({ - with: { - actor: true, - quotedPost: { with: { actor: true } }, - replyTarget: true, - mentions: { with: { actor: true } }, - }, - where: { id: quotePost.id }, - }); - if (updatedQuote != null) { - await sendLocalQuoteUpdate(fedCtx, updatedQuote, null, revoked); - } - } - if (quotePost.quoteAuthorizationIri != null) { - await db.update(quoteAuthorizationTable) - .set({ revoked: true, updated: revoked }) - .where(eq(quoteAuthorizationTable.iri, quotePost.quoteAuthorizationIri)); - if (quotePost.actor.accountId == null) { - const activity = new vocab.Delete({ - id: new URL("#delete", quotePost.quoteAuthorizationIri), - actor: fedCtx.getActorUri(account.id), - object: new URL(quotePost.quoteAuthorizationIri), - }); - await fedCtx.sendActivity( - { identifier: account.id }, - toRecipient(quotePost.actor), - activity, - { - orderingKey: quotePost.quoteAuthorizationIri, - excludeBaseUris: [new URL(fedCtx.canonicalOrigin)], - }, - ); - } else if (updatedQuote != null) { - await sendLocalQuoteAuthorizationDelete( - fedCtx, - account, - updatedQuote, - quotePost.quoteAuthorizationIri, - ); - } - } - await updateQuotesCount(db, quotedPost, -1); - // The quoted post lost a public quote, so re-score its link. - await refreshNewsScores(db, [quotedPost.linkId]); - return updatedPost; -} - -async function sendLocalQuoteAuthorizationDelete( - fedCtx: ApplicationContext, - account: Account, - quote: QuoteUpdatePost, - quoteAuthorizationIri: string, -): Promise { - const activity = new vocab.Delete({ - id: new URL("#delete", quoteAuthorizationIri), - actor: fedCtx.getActorUri(account.id), - object: new URL(quoteAuthorizationIri), - }); - const excludeBaseUris = [ - new URL(fedCtx.origin), - new URL(fedCtx.canonicalOrigin), - ]; - if (quote.mentions.length > 0) { - await fedCtx.sendActivity( - { identifier: account.id }, - quote.mentions.map((mention) => toRecipient(mention.actor)), - activity, - { - orderingKey: quoteAuthorizationIri, - preferSharedInbox: false, - excludeBaseUris, - }, - ); - } - if ( - quote.visibility !== "public" && - quote.visibility !== "unlisted" && - quote.visibility !== "followers" - ) { - return; - } - const followers = await fedCtx.db.query.followingTable.findMany({ - with: { follower: true }, - where: { - followeeId: quote.actorId, - accepted: { isNotNull: true }, - }, - }); - if (followers.length < 1) return; - await fedCtx.sendActivity( - { identifier: account.id }, - followers.map((following) => toRecipient(following.follower)), - activity, - { - orderingKey: quoteAuthorizationIri, - preferSharedInbox: true, - excludeBaseUris, - }, - ); -} - -async function sendLocalQuoteUpdate( - fedCtx: ApplicationContext, - quote: QuoteUpdatePost, - quoteAuthorizationIri: string | null, - updated: Date, -): Promise { - if (quote.actor.accountId == null || quote.noteSourceId == null) return; - const noteSource = await fedCtx.db.query.noteSourceTable.findFirst({ - where: { id: quote.noteSourceId }, - with: { - account: true, - media: { with: { medium: true }, orderBy: { index: "asc" } }, - }, - }); - if (noteSource == null) return; - const noteObject = await fedCtx.services.federation.getNote( - fedCtx, - noteSource, - { - replyTargetId: quote.replyTarget == null - ? undefined - : new URL(quote.replyTarget.iri), - quotedPost: quote.quotedPost ?? undefined, - quoteAuthorizationIri, - }, - ); - const update = new vocab.Update({ - id: new URL( - `#update/${updated.toISOString()}`, - noteObject.id ?? fedCtx.canonicalOrigin, - ), - actor: fedCtx.getActorUri(quote.actor.accountId), - tos: noteObject.toIds, - ccs: noteObject.ccIds, - object: noteObject, - }); - const excludeBaseUris = [ - new URL(fedCtx.origin), - new URL(fedCtx.canonicalOrigin), - ]; - if (quote.mentions.length > 0) { - await fedCtx.sendActivity( - { identifier: quote.actor.accountId }, - quote.mentions.map((mention) => toRecipient(mention.actor)), - update, - { - orderingKey: quote.iri, - preferSharedInbox: false, - excludeBaseUris, - }, - ); - } - if ( - quote.visibility === "public" || - quote.visibility === "unlisted" || - quote.visibility === "followers" - ) { - await fedCtx.sendActivity( - { identifier: quote.actor.accountId }, - "followers", - update, - { - orderingKey: quote.iri, - preferSharedInbox: true, - excludeBaseUris, - }, - ); - } - const relayedTags = await fedCtx.services.federation - .sendTagsPubRelayActivity( - fedCtx, - quote.actor.accountId, - update, - { - orderingKey: quote.iri, - visibility: quote.visibility, - accountBio: noteSource.account.bio, - relayedTags: quote.relayedTags, - }, - ); - if (relayedTags != null) { - await fedCtx.db.update(postTable) - .set({ relayedTags: [...relayedTags] }) - .where(eq(postTable.id, quote.id)); - quote.relayedTags = [...relayedTags]; - } -} - -export async function deletePost( - fedCtx: ApplicationContext, - post: Post & { actor: Actor; replyTarget: Post | null }, -): Promise { - const { db } = fedCtx; - const replies = await db.query.postTable.findMany({ - with: { actor: true }, - where: { - replyTargetId: post.id, - OR: [ - { articleSourceId: { isNotNull: true } }, - { noteSourceId: { isNotNull: true } }, - ], - }, - }); - for (const reply of replies) { - await deletePost(fedCtx, { ...reply, replyTarget: post }); - } - // Get posts quoting this post before deleting - const quotingPosts = await db.query.postTable.findMany({ - where: { - quotedPostId: post.id, - }, - }); - - const interactions = await db.delete(postTable).where( - or( - eq(postTable.replyTargetId, post.id), - eq(postTable.sharedPostId, post.id), - eq(postTable.quotedPostId, post.id), - eq(postTable.id, post.id), - ), - ).returning(); - - const originalPostIds = [ - post.replyTargetId, - post.sharedPostId, - post.quotedPostId, - ].filter((id): id is Uuid => id != null); - const originalPosts = originalPostIds.length < 1 - ? [] - : await db.query.postTable.findMany({ - where: { - OR: originalPostIds.map((id) => ({ id })), - }, - }); - - if (post.replyTargetId != null) { - const replyTarget = originalPosts.find((p) => p.id === post.replyTargetId); - if (replyTarget != null) { - await updateRepliesCount(db, replyTarget, -1); - } - } - if (post.sharedPostId != null) { - const sharedPost = originalPosts.find((p) => p.id === post.sharedPostId); - if (sharedPost != null) { - await updateSharesCount(db, sharedPost, -1); - } - } - if (post.quotedPostId != null) { - const quotedPost = originalPosts.find((p) => p.id === post.quotedPostId); - if (quotedPost != null) { - await updateQuotesCount(db, quotedPost, -1); - } - } - - // When a quoted post is deleted, update the quotes count of the original posts - for (const quotingPost of quotingPosts) { - if (quotingPost.quotedPostId) { - const quotedPost = await db.query.postTable.findFirst({ - where: { - id: quotingPost.quotedPostId, - }, - }); - if (quotedPost) { - await updateQuotesCount(db, quotedPost, -1); - } - } - } - // Re-score every link affected by this cascade: the link each deleted post - // shared (this post plus its bulk-deleted replies/quotes/boosts, any of which - // may itself be a sharing post), and the links of the posts this post replied - // to / quoted (whose public reply/quote count dropped). - const affectedLinkIds = new Set(); - const parentIds = new Set(); - for (const deleted of interactions) { - if (deleted.linkId != null) affectedLinkIds.add(deleted.linkId); - // A bulk-deleted interaction may reply to or quote a story other than this - // post (e.g. a post that quoted this one while also replying to a different - // story); that story's public reply/quote count just dropped too. - if (deleted.replyTargetId != null) parentIds.add(deleted.replyTargetId); - if (deleted.quotedPostId != null) parentIds.add(deleted.quotedPostId); - } - for (const original of originalPosts) { - if (original.linkId != null) affectedLinkIds.add(original.linkId); - } - if (parentIds.size > 0) { - const parents = await db.query.postTable.findMany({ - where: { id: { in: [...parentIds] } }, - columns: { linkId: true }, - }); - for (const parent of parents) { - if (parent.linkId != null) affectedLinkIds.add(parent.linkId); - } - } - await refreshNewsScores(db, [...affectedLinkIds]); - const noteSourceIds = interactions - .filter((i) => i.noteSourceId != null) - .map((i) => i.noteSourceId!); - if (noteSourceIds.length > 0) { - await db.delete(noteSourceTable).where( - inArray(noteSourceTable.id, noteSourceIds), - ); - } - const articleSourceIds = interactions - .filter((i) => i.articleSourceId != null) - .map((i) => i.articleSourceId!); - if (articleSourceIds.length > 0) { - await db.delete(articleSourceTable).where( - inArray(articleSourceTable.id, articleSourceIds), - ); - } - if (post.actor.accountId == null) return; - const interactors = await db.query.actorTable.findMany({ - where: { - id: { in: [...interactions, ...originalPosts].map((i) => i.actorId) }, - }, - }); - const recipients: Recipient[] = interactors.map((actor) => ({ - id: new URL(actor.iri), - inboxId: new URL(actor.inboxUrl), - endpoints: actor.sharedInboxUrl == null ? null : { - sharedInbox: new URL(actor.sharedInboxUrl), - }, - })); - const activity = new vocab.Delete({ - id: new URL("#delete", post.iri), - actor: fedCtx.getActorUri(post.actor.accountId), - to: PUBLIC_COLLECTION, - cc: fedCtx.getFollowersUri(post.actor.accountId), - object: new vocab.Tombstone({ - id: new URL(post.iri), - }), - }); - await fedCtx.sendActivity( - { identifier: post.actor.accountId }, - "followers", - activity, - { - orderingKey: post.iri, - preferSharedInbox: true, - excludeBaseUris: [new URL(fedCtx.canonicalOrigin)], - }, - ); - await fedCtx.services.federation.sendTagsPubRelayActivity( - fedCtx, - post.actor.accountId, - activity, - { - orderingKey: post.iri, - visibility: post.visibility, - accountBio: post.actor.bioHtml, - relayedTags: post.relayedTags, - }, - ); - await fedCtx.sendActivity( - { identifier: post.actor.accountId }, - recipients, - activity, - { - orderingKey: post.iri, - preferSharedInbox: true, - excludeBaseUris: [new URL(fedCtx.canonicalOrigin)], - }, - ); -} - -export async function scrapePostLink( - fedCtx: Pick, - url: string | URL, - handleToActorId: (handle: string) => Promise, - options: { signal?: AbortSignal } = {}, -): Promise { - const lg = logger.getChild("scrapePostLink"); - url = typeof url === "string" ? new URL(url) : url; - if (!isSSRFSafeURL(url.href)) { - lg.warn("Unsafe URL: {url}", { url: url.href }); - return undefined; - } - let response: Response; - try { - response = await fetch(url, { - headers: { - "User-Agent": getUserAgent({ - software: "HackersPub", - url: new URL(fedCtx.canonicalOrigin), - }), - }, - redirect: "follow", - signal: getRemoteFetchSignal(options.signal), - }); - } catch (error) { - // Best-effort link-preview scrape: a remote being unreachable (DNS, TLS, - // connection errors) is expected and not actionable, so log at `warn` to - // keep it out of error tracking. The post still persists without a preview. - lg.warn("Failed to fetch {url}: {error}", { url: url.href, error }); - return undefined; - } - const responseUrl = response.url == null || response.url === "" - ? url.href - : response.url; - if (!response.ok) { - // Best-effort: many sites refuse scrapers (403) or are briefly down (5xx). - // Not actionable, so `warn` rather than `error`. - lg.warn("Failed to scrape {url}: {status} {statusText}", { - url: responseUrl, - status: response.status, - statusText: response.statusText, - }); - await response.body?.cancel().catch(() => {}); - return undefined; - } - const fullContentType = response.headers.get("Content-Type"); - const contentType = fullContentType?.replace(/\s*;.*$/, ""); - if ( - contentType === "application/pdf" || contentType === "application/x-pdf" - ) { - try { - const pdf = await PDFDocument.load(await response.bytes(), { - updateMetadata: false, - }); - return { - id: generateUuidV7(), - url: responseUrl, - title: pdf.getTitle(), - description: pdf.getSubject(), - author: pdf.getAuthor(), - }; - } catch (error) { - lg.warn("Failed to read or parse PDF from {url}: {error}", { - url: responseUrl, - error, - }); - return undefined; - } - } - if (contentType !== "text/html" && contentType !== "application/xhtml+xml") { - lg.warn("Not an HTML page: {url} ({contentType})", { - url: responseUrl, - contentType, - }); - await response.body?.cancel().catch(() => {}); - return undefined; - } - const contentTypeParams = Object.fromEntries( - (fullContentType - ?.replace(/^[^;]*;\s*/, "") - ?.split(/\s*;\s*/g) ?? []).map((pair: string) => pair.split(/\s*=\s*/)) - .filter((pair) => pair.length === 2).map((pair) => - pair as [string, string] - ), - ); - let charset = contentTypeParams.charset?.toLowerCase(); - let bytes: Uint8Array; - try { - bytes = await response.bytes(); - } catch (error) { - lg.warn("Failed to read body from {url}: {error}", { - url: responseUrl, - error, - }); - return undefined; - } - if (!charset) { - // Try to find charset in meta tags if not specified in Content-Type - const decoder = new TextDecoder(); - const rawHtml = decoder.decode(bytes); - const charsetMatch = rawHtml.match(/>["result"]; - try { - const scraped = await ogs({ - html, - customMetaTags: [ - { - multiple: false, - property: "fediverse:creator", - fieldName: "fediverseCreator", - }, - ], - }); - if (scraped.error) { - // Best-effort: the page loaded but Open Graph parsing failed. Not - // actionable, so `warn` rather than `error`. - lg.warn("Failed to scrape {url}: {error}", { - url: responseUrl, - result: scraped.result, - }); - return undefined; - } - result = scraped.result; - } catch (error) { - // `open-graph-scraper` throws plain objects for parser setup failures. - // Link previews are best-effort, so do not fail ActivityPub ingestion. - lg.warn("Failed to scrape {url}: {error}", { url: responseUrl, error }); - return undefined; - } - lg.debug("Scraped {url}: {result}", { url: responseUrl, result }); - const ogImage = result.ogImage ?? []; - const twitterImage = result.twitterImage ?? []; - const image: { - imageUrl?: string; - imageAlt?: string; - imageType?: string; - imageWidth?: number; - imageHeight?: number; - } = ogImage.length > 0 - ? { - imageUrl: ogImage[0].url, - imageAlt: ogImage[0].alt, - imageType: ogImage[0].type === "png" - ? "image/png" - : ogImage[0].type === "jpg" || ogImage[0].type === "jpeg" - ? "image/jpeg" - : ogImage[0].type == null || - !ogImage[0].type.startsWith("image/") - ? undefined - : ogImage[0].type, - imageWidth: typeof ogImage[0].width === "string" - ? parseInt(ogImage[0].width) - : ogImage[0].width, - imageHeight: typeof ogImage[0].height === "string" - ? parseInt(ogImage[0].height) - : ogImage[0].height, - } - : twitterImage.length > 0 - ? { - imageUrl: twitterImage[0].url, - imageAlt: twitterImage[0].alt, - imageWidth: typeof twitterImage[0].width === "string" - ? parseInt(twitterImage[0].width) - : twitterImage[0].width, - imageHeight: typeof twitterImage[0].height === "string" - ? parseInt(twitterImage[0].height) - : twitterImage[0].height, - } - : {}; - if (image.imageUrl != null) { - try { - const imageUrl = new URL(image.imageUrl, responseUrl); - if (imageUrl.protocol !== "http:" && imageUrl.protocol !== "https:") { - throw new TypeError( - `Unsupported image URL protocol: ${imageUrl.protocol}`, - ); - } - image.imageUrl = imageUrl.href; - } catch (error) { - lg.warn("Ignoring invalid preview image URL for {url}: {error}", { - url: responseUrl, - imageUrl: image.imageUrl, - error, - }); - image.imageUrl = undefined; - image.imageAlt = undefined; - image.imageType = undefined; - image.imageWidth = undefined; - image.imageHeight = undefined; - } - } - if ( - image.imageUrl != null && - (image.imageWidth == null || image.imageHeight == null) - ) { - try { - const response = await fetch(image.imageUrl, { - headers: { - "User-Agent": getUserAgent({ - software: "HackersPub", - url: new URL(fedCtx.canonicalOrigin), - }), - "Accept": "image/*", - "Range": `bytes=0-${SCRAPE_IMAGE_METADATA_BYTES_LIMIT - 1}`, - "Referer": responseUrl, - }, - redirect: "follow", - signal: getRemoteFetchSignal(options.signal), - }); - logger.debug("Fetched image {url}: {status} {statusText}", { - url: response.url, - status: response.status, - statusText: response.statusText, - }); - if (response.ok) { - const body = await readResponseBytesAtMost( - response, - SCRAPE_IMAGE_METADATA_BYTES_LIMIT, - ); - try { - const metadata = await sharp(body).metadata(); - switch (metadata.orientation) { - case 6: - case 8: - image.imageWidth = metadata.height; - image.imageHeight = metadata.width; - break; - case 1: - case 3: - default: - image.imageWidth = metadata.width; - image.imageHeight = metadata.height; - break; - } - } catch { - image.imageWidth = undefined; - image.imageHeight = undefined; - } - } - } catch (error) { - logger.debug( - "Failed to fetch image {url}: {error}", - { url: image.imageUrl, error }, - ); - image.imageWidth = undefined; - image.imageHeight = undefined; - } - } - const creatorHandle = result.customMetaTags?.fediverseCreator == null - ? undefined - : Array.isArray(result.customMetaTags.fediverseCreator) - ? result.customMetaTags.fediverseCreator[0] - : result.customMetaTags.fediverseCreator; - const declaredCanonicalUrl = result.ogUrl ?? result.twitterUrl ?? - result.requestUrl; - if (declaredCanonicalUrl != null) { - lg.debug("Ignoring declared canonical URL for {url}: {canonicalUrl}", { - url: responseUrl, - canonicalUrl: declaredCanonicalUrl, - }); - } - return { - id: generateUuidV7(), - // response.url is the strongest identity we can verify: unlike Open Graph - // or rel=canonical metadata, it is the URL reached by an actual redirect - // chain. Fragments never reach HTTP and are preserved separately on Post. - url: responseUrl, - title: result.ogTitle ?? result.twitterTitle, - siteName: result.ogSiteName, - type: result.ogType, - description: result.ogDescription ?? result.twitterDescription, - author: result.ogArticleAuthor, - creatorId: creatorHandle == null || handleToActorId == null - ? undefined - : await handleToActorId(creatorHandle), - ...image, - }; -} - -const POST_LINK_CACHE_TTL = Temporal.Duration.from({ hours: 24 }); - -export async function persistPostLink( - ctx: ApplicationContext, - url: string | URL, - options: { signal?: AbortSignal } = {}, -): Promise { - if (typeof url === "string") url = new URL(url); - if (!isSSRFSafeURL(url.href)) { - logger.warn("Unsafe URL: {url}", { url: url.href }); - return undefined; - } - const scrapeUrl = new URL(url); - scrapeUrl.hash = ""; - const { db } = ctx; - const link = await db.query.postLinkTable.findFirst({ - where: { url: scrapeUrl.href }, - }); - if (link != null) { - const scraped = link.scraped.toTemporalInstant(); - if ( - Temporal.Instant.compare( - scraped.add(POST_LINK_CACHE_TTL), - Temporal.Now.instant(), - ) > 0 - ) { - logger.debug("Post link cache hit: {url}", { url: scrapeUrl.href }); - return link; - } - } - let scrapedLink = await scrapePostLink(ctx, scrapeUrl, async (handle) => { - if (!handle.startsWith("@")) handle = `@${handle}`; - const actors = await persistActorsByHandles(ctx, [handle]); - return actors[handle]?.id; - }, { - signal: options.signal, - }); - logger.debug("Scraped link {url}: {link}", { - url: url.href, - link: scrapedLink, - }); - if (scrapedLink == null) return undefined; - if (scrapedLink.imageWidth == null || scrapedLink.imageHeight == null) { - scrapedLink = { - ...scrapedLink, - imageWidth: undefined, - imageHeight: undefined, - }; - } - const result = await db - .insert(postLinkTable) - .values(scrapedLink) - .onConflictDoUpdate({ - target: postLinkTable.url, - set: { - title: scrapedLink.title, - siteName: scrapedLink.siteName, - type: scrapedLink.type, - description: scrapedLink.description, - imageUrl: scrapedLink.imageUrl, - imageAlt: scrapedLink.imageAlt, - imageType: scrapedLink.imageType, - imageWidth: scrapedLink.imageWidth, - imageHeight: scrapedLink.imageHeight, - creatorId: scrapedLink.creatorId, - scraped: sql`CURRENT_TIMESTAMP`, - }, - setWhere: eq(postLinkTable.url, scrapedLink.url), - }) - .returning(); - return result[0]; -} +// Compatibility facade. New code should import the narrowest `post/*` +// feature module so dependencies remain explicit. +export { + getPersistedPost, + getPostByUsernameAndId, + isArticleLike, + isPostObject, + type PostObject, +} from "./post/core.ts"; +export { + revokeQuote, + updateQuotesCount, + updateRepliesCount, + updateSharesCount, +} from "./post/engagement.ts"; +export { deletePost } from "./post/lifecycle.ts"; +export { + deletePersistedPost, + deleteSharedPost, + PERSIST_POST_OVERALL_BUDGET_MS, + persistPost, + persistSharedPost, + REMOTE_FETCH_TIMEOUT_MS, + withDocumentLoaderTimeout, +} from "./post/remote.ts"; +export { arePostsSharedBy, sharePost, unsharePost } from "./post/sharing.ts"; +export { + syncPostFromArticleSource, + syncPostFromNoteSource, +} from "./post/source.ts"; +export { + canActorQuotePost, + canActorRequestQuotePost, + getAllowedQuoteTargetForActor, + getCensoredPostExclusionFilter, + getMutedActorExclusionFilter, + getOriginalPostId, + getPostInteractionPolicies, + getPostVisibilityFilter, + getPublicTimelineVisibilityFilter, + getSanctionHiddenActorFilter, + getSanctionVisibleActorFilter, + isActorSanctionHidden, + isPostVisibleTo, + normalizeQuotePolicyForVisibility, + type PostInteractionPolicy, +} from "./post/visibility.ts"; +export { persistPostLink, scrapePostLink } from "./link-preview.ts"; diff --git a/models/post/core.ts b/models/post/core.ts new file mode 100644 index 000000000..34161ce5f --- /dev/null +++ b/models/post/core.ts @@ -0,0 +1,269 @@ +import * as vocab from "@fedify/vocab"; +import { sql } from "drizzle-orm"; +import type { Database } from "../db.ts"; +import type { + Account, + Actor, + Blocking, + Following, + Instance, + Mention, + Poll, + Post, + PostLink, + PostMedium, + Reaction, +} from "../schema.ts"; +import type { Uuid } from "../uuid.ts"; + +export type PostObject = vocab.Article | vocab.Note | vocab.Question; +export function isPostObject(object: unknown): object is PostObject { + return object instanceof vocab.Article || object instanceof vocab.Note || + object instanceof vocab.Question; +} + +export function isArticleLike( + post: Post & { actor: Actor & { instance: Instance } }, +): boolean { + if (post.type === "Question") return false; + return post.type === "Article" || + post.name != null && post.actor.instance.software !== "nodebb"; +} + +export function getPersistedPost( + db: Database, + iri: URL, +): Promise< + | Post & { + actor: Actor & { instance: Instance }; + mentions: (Mention & { actor: Actor })[]; + replyTarget: Post & { actor: Actor } | null; + quotedPost: Post & { actor: Actor } | null; + poll: Poll | null; + } + | undefined +> { + return db.query.postTable.findFirst({ + with: { + actor: { + with: { instance: true }, + }, + mentions: { + with: { actor: true }, + }, + replyTarget: { + with: { actor: true }, + }, + quotedPost: { + with: { actor: true }, + }, + poll: true, + }, + where: { + iri: iri.href, + }, + }); +} + +export function getPostByUsernameAndId( + db: Database, + username: string, + id: Uuid, + signedAccount: Account & { actor: Actor } | undefined, +): Promise< + | Post & { + actor: Actor & { + instance: Instance; + followers: Following[]; + blockees: Blocking[]; + blockers: Blocking[]; + }; + link: PostLink & { creator?: Actor | null } | null; + sharedPost: + | Post & { + actor: Actor & { + instance: Instance; + followers: Following[]; + blockees: Blocking[]; + blockers: Blocking[]; + }; + link: PostLink & { creator?: Actor | null } | null; + replyTarget: + | Post & { + actor: Actor & { + instance: Instance; + followers: (Following & { follower: Actor })[]; + blockees: Blocking[]; + blockers: Blocking[]; + }; + link: PostLink & { creator?: Actor | null } | null; + mentions: (Mention & { actor: Actor })[]; + media: PostMedium[]; + } + | null; + mentions: (Mention & { actor: Actor })[]; + media: PostMedium[]; + shares: Post[]; + reactions: Reaction[]; + } + | null; + replyTarget: + | Post & { + actor: Actor & { + instance: Instance; + followers: (Following & { follower: Actor })[]; + blockees: Blocking[]; + blockers: Blocking[]; + }; + link: PostLink & { creator?: Actor | null } | null; + mentions: (Mention & { actor: Actor })[]; + media: PostMedium[]; + } + | null; + mentions: (Mention & { actor: Actor })[]; + media: PostMedium[]; + shares: Post[]; + reactions: Reaction[]; + } + | undefined +> { + if (!username.includes("@")) return Promise.resolve(undefined); + let host: string; + [username, host] = username.split("@"); + return db.query.postTable.findFirst({ + with: { + actor: { + with: { + instance: true, + followers: true, + blockees: true, + blockers: true, + }, + }, + link: { with: { creator: true } }, + sharedPost: { + with: { + actor: { + with: { + instance: true, + followers: { + where: signedAccount == null + ? { RAW: sql`false` } + : { followerId: signedAccount.actor.id }, + }, + blockees: { + where: signedAccount == null + ? { RAW: sql`false` } + : { blockeeId: signedAccount.actor.id }, + }, + blockers: { + where: signedAccount == null + ? { RAW: sql`false` } + : { blockerId: signedAccount.actor.id }, + }, + }, + }, + link: { with: { creator: true } }, + replyTarget: { + with: { + actor: { + with: { + instance: true, + followers: { + where: signedAccount == null + ? { RAW: sql`false` } + : { followerId: signedAccount.actor.id }, + with: { follower: true }, + }, + blockees: { + where: signedAccount == null + ? { RAW: sql`false` } + : { blockeeId: signedAccount.actor.id }, + }, + blockers: { + where: signedAccount == null + ? { RAW: sql`false` } + : { blockerId: signedAccount.actor.id }, + }, + }, + }, + link: { with: { creator: true } }, + mentions: { + with: { actor: true }, + }, + media: true, + }, + }, + mentions: { + with: { actor: true }, + }, + media: true, + shares: { + where: signedAccount == null + ? { RAW: sql`false` } + : { actorId: signedAccount.actor.id }, + }, + reactions: { + where: signedAccount == null + ? { RAW: sql`false` } + : { actorId: signedAccount.actor.id }, + }, + }, + }, + replyTarget: { + with: { + actor: { + with: { + instance: true, + followers: { + where: signedAccount == null + ? { RAW: sql`false` } + : { followerId: signedAccount.actor.id }, + with: { follower: true }, + }, + blockees: { + where: signedAccount == null + ? { RAW: sql`false` } + : { blockeeId: signedAccount.actor.id }, + }, + blockers: { + where: signedAccount == null + ? { RAW: sql`false` } + : { blockerId: signedAccount.actor.id }, + }, + }, + }, + link: { with: { creator: true } }, + mentions: { + with: { actor: true }, + }, + media: true, + }, + }, + mentions: { + with: { actor: true }, + }, + media: true, + shares: { + where: signedAccount == null + ? { RAW: sql`false` } + : { actorId: signedAccount.actor.id }, + }, + reactions: { + where: signedAccount == null + ? { RAW: sql`false` } + : { actorId: signedAccount.actor.id }, + }, + }, + where: { + id, + actor: { + username, + OR: [ + { instanceHost: host }, + { handleHost: host }, + ], + }, + }, + }); +} diff --git a/models/post/engagement.ts b/models/post/engagement.ts new file mode 100644 index 000000000..8d61c56fd --- /dev/null +++ b/models/post/engagement.ts @@ -0,0 +1,300 @@ +import * as vocab from "@fedify/vocab"; +import { and, count, eq } from "drizzle-orm"; +import { toRecipient } from "../actor.ts"; +import type { ApplicationContext } from "../context.ts"; +import type { Database, Transaction } from "../db.ts"; +import { refreshNewsScores } from "../news.ts"; +import { + type Account, + type Actor, + type Mention, + noteSourceTable, + type Post, + postTable, + quoteAuthorizationTable, +} from "../schema.ts"; + +type QuoteUpdatePost = Post & { + actor: Actor; + quotedPost: (Post & { actor: Actor }) | null; + replyTarget: Post | null; + mentions: (Mention & { actor: Actor })[]; +}; + +export async function updateRepliesCount( + db: Database, + replyTarget: Post, + delta: number, +): Promise { + const repliesCount = replyTarget.repliesCount + delta; + const cnt = await db.select({ count: count() }) + .from(postTable) + .where(eq(postTable.replyTargetId, replyTarget.id)); + if (repliesCount <= cnt[0].count) { + await db.update(postTable) + .set({ repliesCount: cnt[0].count }) + .where(eq(postTable.id, replyTarget.id)); + replyTarget.repliesCount = cnt[0].count; + return cnt[0].count; + } + return repliesCount; +} + +export async function updateSharesCount( + db: Database, + post: Post, + delta: number, +): Promise { + const sharesCount = post.sharesCount + delta; + const cnt = await db.select({ count: count() }) + .from(postTable) + .where(eq(postTable.sharedPostId, post.id)); + if (sharesCount <= cnt[0].count) { + await db.update(postTable) + .set({ sharesCount: cnt[0].count }) + .where(eq(postTable.id, post.id)); + post.sharesCount = cnt[0].count; + return cnt[0].count; + } + return sharesCount; +} + +export async function updateQuotesCount( + db: Database | Transaction, + post: Post, + delta: number, +): Promise { + const quotesCount = post.quotesCount + delta; + const cnt = await db.select({ count: count() }) + .from(postTable) + .where(eq(postTable.quotedPostId, post.id)); + if (quotesCount <= cnt[0].count) { + await db.update(postTable) + .set({ quotesCount: cnt[0].count }) + .where(eq(postTable.id, post.id)); + post.quotesCount = cnt[0].count; + return cnt[0].count; + } + return quotesCount; +} + +export async function revokeQuote( + fedCtx: ApplicationContext, + account: Account, + quotePost: Post & { actor: Actor }, + quotedPost: Post, +): Promise { + const { db } = fedCtx; + const revoked = new Date(); + let updatedQuote: QuoteUpdatePost | undefined; + const rows = await db.update(postTable) + .set({ + quotedPostId: null, + quoteAuthorizationIri: null, + quoteTargetState: "denied", + updated: revoked, + }) + .where(and( + eq(postTable.id, quotePost.id), + eq(postTable.quotedPostId, quotedPost.id), + )) + .returning(); + const updatedPost = rows[0]; + if (updatedPost == null) { + return await db.query.postTable.findFirst({ + where: { id: quotePost.id }, + }) ?? + quotePost; + } + if (quotePost.actor.accountId != null && quotePost.noteSourceId != null) { + await db.update(noteSourceTable) + .set({ updated: revoked }) + .where(eq(noteSourceTable.id, quotePost.noteSourceId)); + updatedQuote = await db.query.postTable.findFirst({ + with: { + actor: true, + quotedPost: { with: { actor: true } }, + replyTarget: true, + mentions: { with: { actor: true } }, + }, + where: { id: quotePost.id }, + }); + if (updatedQuote != null) { + await sendLocalQuoteUpdate(fedCtx, updatedQuote, null, revoked); + } + } + if (quotePost.quoteAuthorizationIri != null) { + await db.update(quoteAuthorizationTable) + .set({ revoked: true, updated: revoked }) + .where(eq(quoteAuthorizationTable.iri, quotePost.quoteAuthorizationIri)); + if (quotePost.actor.accountId == null) { + const activity = new vocab.Delete({ + id: new URL("#delete", quotePost.quoteAuthorizationIri), + actor: fedCtx.getActorUri(account.id), + object: new URL(quotePost.quoteAuthorizationIri), + }); + await fedCtx.sendActivity( + { identifier: account.id }, + toRecipient(quotePost.actor), + activity, + { + orderingKey: quotePost.quoteAuthorizationIri, + excludeBaseUris: [new URL(fedCtx.canonicalOrigin)], + }, + ); + } else if (updatedQuote != null) { + await sendLocalQuoteAuthorizationDelete( + fedCtx, + account, + updatedQuote, + quotePost.quoteAuthorizationIri, + ); + } + } + await updateQuotesCount(db, quotedPost, -1); + // The quoted post lost a public quote, so re-score its link. + await refreshNewsScores(db, [quotedPost.linkId]); + return updatedPost; +} + +async function sendLocalQuoteAuthorizationDelete( + fedCtx: ApplicationContext, + account: Account, + quote: QuoteUpdatePost, + quoteAuthorizationIri: string, +): Promise { + const activity = new vocab.Delete({ + id: new URL("#delete", quoteAuthorizationIri), + actor: fedCtx.getActorUri(account.id), + object: new URL(quoteAuthorizationIri), + }); + const excludeBaseUris = [ + new URL(fedCtx.origin), + new URL(fedCtx.canonicalOrigin), + ]; + if (quote.mentions.length > 0) { + await fedCtx.sendActivity( + { identifier: account.id }, + quote.mentions.map((mention) => toRecipient(mention.actor)), + activity, + { + orderingKey: quoteAuthorizationIri, + preferSharedInbox: false, + excludeBaseUris, + }, + ); + } + if ( + quote.visibility !== "public" && + quote.visibility !== "unlisted" && + quote.visibility !== "followers" + ) { + return; + } + const followers = await fedCtx.db.query.followingTable.findMany({ + with: { follower: true }, + where: { + followeeId: quote.actorId, + accepted: { isNotNull: true }, + }, + }); + if (followers.length < 1) return; + await fedCtx.sendActivity( + { identifier: account.id }, + followers.map((following) => toRecipient(following.follower)), + activity, + { + orderingKey: quoteAuthorizationIri, + preferSharedInbox: true, + excludeBaseUris, + }, + ); +} + +async function sendLocalQuoteUpdate( + fedCtx: ApplicationContext, + quote: QuoteUpdatePost, + quoteAuthorizationIri: string | null, + updated: Date, +): Promise { + if (quote.actor.accountId == null || quote.noteSourceId == null) return; + const noteSource = await fedCtx.db.query.noteSourceTable.findFirst({ + where: { id: quote.noteSourceId }, + with: { + account: true, + media: { with: { medium: true }, orderBy: { index: "asc" } }, + }, + }); + if (noteSource == null) return; + const noteObject = await fedCtx.services.federation.getNote( + fedCtx, + noteSource, + { + replyTargetId: quote.replyTarget == null + ? undefined + : new URL(quote.replyTarget.iri), + quotedPost: quote.quotedPost ?? undefined, + quoteAuthorizationIri, + }, + ); + const update = new vocab.Update({ + id: new URL( + `#update/${updated.toISOString()}`, + noteObject.id ?? fedCtx.canonicalOrigin, + ), + actor: fedCtx.getActorUri(quote.actor.accountId), + tos: noteObject.toIds, + ccs: noteObject.ccIds, + object: noteObject, + }); + const excludeBaseUris = [ + new URL(fedCtx.origin), + new URL(fedCtx.canonicalOrigin), + ]; + if (quote.mentions.length > 0) { + await fedCtx.sendActivity( + { identifier: quote.actor.accountId }, + quote.mentions.map((mention) => toRecipient(mention.actor)), + update, + { + orderingKey: quote.iri, + preferSharedInbox: false, + excludeBaseUris, + }, + ); + } + if ( + quote.visibility === "public" || + quote.visibility === "unlisted" || + quote.visibility === "followers" + ) { + await fedCtx.sendActivity( + { identifier: quote.actor.accountId }, + "followers", + update, + { + orderingKey: quote.iri, + preferSharedInbox: true, + excludeBaseUris, + }, + ); + } + const relayedTags = await fedCtx.services.federation + .sendTagsPubRelayActivity( + fedCtx, + quote.actor.accountId, + update, + { + orderingKey: quote.iri, + visibility: quote.visibility, + accountBio: noteSource.account.bio, + relayedTags: quote.relayedTags, + }, + ); + if (relayedTags != null) { + await fedCtx.db.update(postTable) + .set({ relayedTags: [...relayedTags] }) + .where(eq(postTable.id, quote.id)); + quote.relayedTags = [...relayedTags]; + } +} diff --git a/models/post/lifecycle.ts b/models/post/lifecycle.ts new file mode 100644 index 000000000..260dde0ff --- /dev/null +++ b/models/post/lifecycle.ts @@ -0,0 +1,175 @@ +import { PUBLIC_COLLECTION, type Recipient } from "@fedify/vocab"; +import * as vocab from "@fedify/vocab"; +import { eq, inArray, or } from "drizzle-orm"; +import type { ApplicationContext } from "../context.ts"; +import { refreshNewsScores } from "../news.ts"; +import { + type Actor, + articleSourceTable, + noteSourceTable, + type Post, + postTable, +} from "../schema.ts"; +import type { Uuid } from "../uuid.ts"; +import { + updateQuotesCount, + updateRepliesCount, + updateSharesCount, +} from "./engagement.ts"; + +export async function deletePost( + fedCtx: ApplicationContext, + post: Post & { actor: Actor; replyTarget: Post | null }, +): Promise { + const { db } = fedCtx; + const replies = await db.query.postTable.findMany({ + with: { actor: true }, + where: { + replyTargetId: post.id, + OR: [ + { articleSourceId: { isNotNull: true } }, + { noteSourceId: { isNotNull: true } }, + ], + }, + }); + for (const reply of replies) { + await deletePost(fedCtx, { ...reply, replyTarget: post }); + } + const interactions = await db.delete(postTable).where( + or( + eq(postTable.replyTargetId, post.id), + eq(postTable.sharedPostId, post.id), + eq(postTable.quotedPostId, post.id), + eq(postTable.id, post.id), + ), + ).returning(); + + const originalPostIds = [ + post.replyTargetId, + post.sharedPostId, + post.quotedPostId, + ].filter((id): id is Uuid => id != null); + const originalPosts = originalPostIds.length < 1 + ? [] + : await db.query.postTable.findMany({ + where: { + OR: originalPostIds.map((id) => ({ id })), + }, + }); + + if (post.replyTargetId != null) { + const replyTarget = originalPosts.find((p) => p.id === post.replyTargetId); + if (replyTarget != null) { + await updateRepliesCount(db, replyTarget, -1); + } + } + if (post.sharedPostId != null) { + const sharedPost = originalPosts.find((p) => p.id === post.sharedPostId); + if (sharedPost != null) { + await updateSharesCount(db, sharedPost, -1); + } + } + if (post.quotedPostId != null) { + const quotedPost = originalPosts.find((p) => p.id === post.quotedPostId); + if (quotedPost != null) { + await updateQuotesCount(db, quotedPost, -1); + } + } + + // Re-score every link affected by this cascade: the link each deleted post + // shared (this post plus its bulk-deleted replies/quotes/boosts, any of which + // may itself be a sharing post), and the links of the posts this post replied + // to / quoted (whose public reply/quote count dropped). + const affectedLinkIds = new Set(); + const parentIds = new Set(); + for (const deleted of interactions) { + if (deleted.linkId != null) affectedLinkIds.add(deleted.linkId); + // A bulk-deleted interaction may reply to or quote a story other than this + // post (e.g. a post that quoted this one while also replying to a different + // story); that story's public reply/quote count just dropped too. + if (deleted.replyTargetId != null) parentIds.add(deleted.replyTargetId); + if (deleted.quotedPostId != null) parentIds.add(deleted.quotedPostId); + } + for (const original of originalPosts) { + if (original.linkId != null) affectedLinkIds.add(original.linkId); + } + if (parentIds.size > 0) { + const parents = await db.query.postTable.findMany({ + where: { id: { in: [...parentIds] } }, + columns: { linkId: true }, + }); + for (const parent of parents) { + if (parent.linkId != null) affectedLinkIds.add(parent.linkId); + } + } + await refreshNewsScores(db, [...affectedLinkIds]); + const noteSourceIds = interactions + .filter((i) => i.noteSourceId != null) + .map((i) => i.noteSourceId!); + if (noteSourceIds.length > 0) { + await db.delete(noteSourceTable).where( + inArray(noteSourceTable.id, noteSourceIds), + ); + } + const articleSourceIds = interactions + .filter((i) => i.articleSourceId != null) + .map((i) => i.articleSourceId!); + if (articleSourceIds.length > 0) { + await db.delete(articleSourceTable).where( + inArray(articleSourceTable.id, articleSourceIds), + ); + } + if (post.actor.accountId == null) return; + const interactors = await db.query.actorTable.findMany({ + where: { + id: { in: [...interactions, ...originalPosts].map((i) => i.actorId) }, + }, + }); + const recipients: Recipient[] = interactors.map((actor) => ({ + id: new URL(actor.iri), + inboxId: new URL(actor.inboxUrl), + endpoints: actor.sharedInboxUrl == null ? null : { + sharedInbox: new URL(actor.sharedInboxUrl), + }, + })); + const activity = new vocab.Delete({ + id: new URL("#delete", post.iri), + actor: fedCtx.getActorUri(post.actor.accountId), + to: PUBLIC_COLLECTION, + cc: fedCtx.getFollowersUri(post.actor.accountId), + object: new vocab.Tombstone({ + id: new URL(post.iri), + }), + }); + await fedCtx.sendActivity( + { identifier: post.actor.accountId }, + "followers", + activity, + { + orderingKey: post.iri, + preferSharedInbox: true, + excludeBaseUris: [new URL(fedCtx.canonicalOrigin)], + }, + ); + await fedCtx.services.federation.sendTagsPubRelayActivity( + fedCtx, + post.actor.accountId, + activity, + { + orderingKey: post.iri, + visibility: post.visibility, + accountBio: post.actor.bioHtml, + relayedTags: post.relayedTags, + }, + ); + await fedCtx.sendActivity( + { identifier: post.actor.accountId }, + recipients, + activity, + { + orderingKey: post.iri, + preferSharedInbox: true, + excludeBaseUris: [new URL(fedCtx.canonicalOrigin)], + }, + ); +} diff --git a/models/post/persistence.ts b/models/post/persistence.ts new file mode 100644 index 000000000..1e643c43c --- /dev/null +++ b/models/post/persistence.ts @@ -0,0 +1,172 @@ +import { and, eq, isNotNull, isNull, sql } from "drizzle-orm"; +import type { ApplicationContext } from "../context.ts"; +import type { Database } from "../db.ts"; +import { stripHtml } from "../html.ts"; +import { + createQuotedPostUpdatedNotification, + createSharedPostUpdatedNotification, +} from "../notification.ts"; +import { + type Actor, + actorTable, + type NewPostLink, + type Post, + type PostLink, + postLinkTable, + postTable, + quoteRequestTable, +} from "../schema.ts"; +import { generateUuidV7, type Uuid } from "../uuid.ts"; + +const ARTICLE_LINK_DESCRIPTION_MAX_LENGTH = 500; + +type PostUpdateComparison = Pick< + Post, + "id" | "actorId" | "name" | "contentHtml" | "updated" +>; + +function hasNotifiablePostContentChange( + previousPost: Pick | undefined, + updatedPost: Pick, +): boolean { + return previousPost != null && + (previousPost.name !== updatedPost.name || + previousPost.contentHtml !== updatedPost.contentHtml); +} + +function isHttpUrl(value: string | undefined | null): value is string { + if (value == null) return false; + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +function truncatePlainText(value: string): string { + const text = value.replace(/\s+/g, " ").trim(); + if (text.length <= ARTICLE_LINK_DESCRIPTION_MAX_LENGTH) return text; + return `${ + text.slice(0, ARTICLE_LINK_DESCRIPTION_MAX_LENGTH - 3).trimEnd() + }...`; +} + +export async function persistArticleNewsLink( + fedCtx: ApplicationContext, + article: { + readonly url: string | null | undefined; + readonly iri: string; + readonly name: string | null | undefined; + readonly summary: string | null | undefined; + readonly contentHtml: string | null | undefined; + }, + actor: Pick, +): Promise { + const url = isHttpUrl(article.url) ? article.url : article.iri; + if (!isHttpUrl(url)) return undefined; + const parsed = new URL(url); + const description = article.summary == null || article.summary.trim() === "" + ? article.contentHtml == null + ? undefined + : truncatePlainText(stripHtml(article.contentHtml)) + : truncatePlainText(stripHtml(article.summary)); + const author = actor.name == null || actor.name.trim() === "" + ? actor.username + : actor.name; + const values: NewPostLink = { + id: generateUuidV7(), + url: parsed.href, + title: article.name ?? undefined, + siteName: parsed.host, + type: "article", + description, + author, + creatorId: actor.id, + }; + const rows = await fedCtx.db + .insert(postLinkTable) + .values(values) + .onConflictDoUpdate({ + target: postLinkTable.url, + set: { + title: values.title, + siteName: values.siteName, + type: values.type, + description: values.description, + author: values.author, + creatorId: values.creatorId, + scraped: sql`CURRENT_TIMESTAMP`, + }, + setWhere: eq(postLinkTable.url, values.url), + }) + .returning(); + return rows[0]; +} + +export async function createTargetPostUpdatedNotifications( + db: Database, + previousPost: Pick | undefined, + updatedPost: PostUpdateComparison, + updatingActor: Actor, +): Promise { + if (!hasNotifiablePostContentChange(previousPost, updatedPost)) return; + const originalAuthorAccountId = updatingActor.accountId; + const shouldNotifyAccount = ( + accountId: Uuid | null, + ): accountId is Uuid => + accountId != null && accountId !== originalAuthorAccountId; + + const shareRows = await db + .select({ accountId: actorTable.accountId }) + .from(postTable) + .innerJoin(actorTable, eq(actorTable.id, postTable.actorId)) + .where(and( + eq(postTable.sharedPostId, updatedPost.id), + isNotNull(actorTable.accountId), + )); + const sharingAccountIds = new Set( + shareRows.map((row) => row.accountId).filter(shouldNotifyAccount), + ); + for (const accountId of sharingAccountIds) { + await createSharedPostUpdatedNotification( + db, + accountId, + updatedPost, + updatingActor, + ); + } + + const directQuoteRows = await db + .select({ accountId: actorTable.accountId }) + .from(postTable) + .innerJoin(actorTable, eq(actorTable.id, postTable.actorId)) + .where(and( + eq(postTable.quotedPostId, updatedPost.id), + isNotNull(actorTable.accountId), + )); + const quoteRequestRows = await db + .select({ accountId: actorTable.accountId }) + .from(quoteRequestTable) + .innerJoin(postTable, eq(postTable.id, quoteRequestTable.quotePostId)) + .innerJoin(actorTable, eq(actorTable.id, postTable.actorId)) + .where(and( + eq(quoteRequestTable.quotedPostId, updatedPost.id), + isNull(quoteRequestTable.accepted), + isNull(quoteRequestTable.rejected), + isNotNull(actorTable.accountId), + )); + const quotingAccountIds = new Set( + [...directQuoteRows, ...quoteRequestRows] + .map((row) => row.accountId) + .filter(shouldNotifyAccount), + ); + for (const accountId of quotingAccountIds) { + await createQuotedPostUpdatedNotification( + db, + accountId, + updatedPost, + updatingActor, + ); + } +} diff --git a/models/post/remote-fetch.ts b/models/post/remote-fetch.ts new file mode 100644 index 000000000..23dc59027 --- /dev/null +++ b/models/post/remote-fetch.ts @@ -0,0 +1,72 @@ +import type { DocumentLoader } from "@fedify/fedify"; + +export const REMOTE_FETCH_TIMEOUT_MS = 10_000; +export const PERSIST_POST_OVERALL_BUDGET_MS = 90_000; + +export function getRemoteFetchSignal(signal?: AbortSignal): AbortSignal { + const signals = [AbortSignal.timeout(REMOTE_FETCH_TIMEOUT_MS), signal] + .filter((s): s is AbortSignal => s != null); + return AbortSignal.any(signals); +} + +export function withDocumentLoaderTimeout( + loader: DocumentLoader, + timeoutMs: number = REMOTE_FETCH_TIMEOUT_MS, + overallSignal?: AbortSignal, +): DocumentLoader { + return (url, options) => { + const signals = [ + options?.signal, + AbortSignal.timeout(timeoutMs), + overallSignal, + ].filter((signal): signal is AbortSignal => signal != null); + const signal = signals.length === 1 ? signals[0] : AbortSignal.any(signals); + return loader(url, { ...options, signal }); + }; +} + +export async function readResponseBytesAtMost( + response: Response, + maxBytes: number, +): Promise { + if (response.body == null) { + return new Uint8Array((await response.arrayBuffer()).slice(0, maxBytes)); + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + // Stop reading once we have enough bytes for lightweight metadata probing. + while (total < maxBytes) { + const { done, value } = await reader.read(); + if (done || value == null) break; + if (total + value.length <= maxBytes) { + chunks.push(value); + total += value.length; + continue; + } + const remaining = maxBytes - total; + if (remaining > 0) { + chunks.push(value.slice(0, remaining)); + total += remaining; + } + break; + } + } finally { + // Cancel the unread remainder so Deno closes the underlying HTTP body + // resource here, with the cancellation awaited (and any rejection + // swallowed). Without this, a partially-read body is abandoned with its + // reader still locked; when the peer tears the keep-alive connection down + // mid-flight, the dangling read rejects with "resource closed" as a + // *detached* unhandled rejection that escapes the caller's try/catch and + // is only caught by the instrument.ts backstop (GRAPHQL-1N). + await reader.cancel().catch(() => {}); + } + const result = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + return result; +} diff --git a/models/post/remote.ts b/models/post/remote.ts new file mode 100644 index 000000000..8f8cf1c4a --- /dev/null +++ b/models/post/remote.ts @@ -0,0 +1,1234 @@ +export { + PERSIST_POST_OVERALL_BUDGET_MS, + REMOTE_FETCH_TIMEOUT_MS, + withDocumentLoaderTimeout, +} from "./remote-fetch.ts"; +import type { DocumentLoader } from "@fedify/fedify"; +import { + isActor, + LanguageString, + lookupObject, + PUBLIC_COLLECTION, + traverseCollection, +} from "@fedify/vocab"; +import * as vocab from "@fedify/vocab"; +import { getLogger } from "@logtape/logtape"; +import { assertNever } from "@std/assert/unstable-never"; +import { + and, + arrayOverlaps, + eq, + inArray, + isNotNull, + isNull, + sql, +} from "drizzle-orm"; +import { + getPersistedActor, + isFederationBlocked, + persistActor, +} from "../actor.ts"; +import type { ApplicationContext } from "../context.ts"; +import { toDate } from "../date.ts"; +import { type Database, runInTransaction } from "../db.ts"; +import { extractExternalLinks } from "../html.ts"; +import { persistPostMedium } from "../medium.ts"; +import { refreshNewsScores, refreshNewsScoresForPostLinks } from "../news.ts"; +import { persistPoll } from "../poll.ts"; +import { + type Actor, + actorTable, + type Instance, + type Mention, + mentionTable, + type NewPost, + type Poll, + type Post, + postMediumTable, + postTable, + type PostVisibility, + quoteAuthorizationTable, + quoteRequestTable, +} from "../schema.ts"; +import { queueAfterCommit } from "../tx.ts"; +import { generateUuidV7, type Uuid } from "../uuid.ts"; + +import { persistPostLink } from "../link-preview.ts"; +import { getPersistedPost, isPostObject, type PostObject } from "./core.ts"; +import { + createTargetPostUpdatedNotifications, + persistArticleNewsLink, +} from "./persistence.ts"; +import { + PERSIST_POST_OVERALL_BUDGET_MS, + REMOTE_FETCH_TIMEOUT_MS, + withDocumentLoaderTimeout, +} from "./remote-fetch.ts"; +import { + updateQuotesCount, + updateRepliesCount, + updateSharesCount, +} from "./engagement.ts"; +import { + canActorQuotePost, + getOriginalPostId, + quotePoliciesFromInteractionPolicy, +} from "./visibility.ts"; + +const logger = getLogger(["hackerspub", "models", "post", "remote"]); +const DEFAULT_MAX_PERSIST_POST_DEPTH = 3; +const DEFAULT_MAX_INLINE_REPLIES = 50; +const DEFAULT_INLINE_REPLIES_THRESHOLD = 10; +const REPLIES_BACKFILL_LOCK_TTL_MS = 300_000; +const REPLIES_BACKFILL_RETRY_DELAY_MS = 30_000; +const EMOJI_REACTIONS_BACKFILL_LOCK_TTL_MS = 300_000; +const EMOJI_REACTIONS_BACKFILL_RETRY_DELAY_MS = 30_000; +const MAX_EMOJI_REACTIONS_BACKFILL = 500; +const INLINE_REPLIES_TRAVERSAL_BUDGET_MS = 15_000; + +async function backfillEmojiReactions( + ctx: ApplicationContext, + post: PostObject, + persistedPost: Pick, + options: { + contextLoader?: DocumentLoader; + documentLoader?: DocumentLoader; + }, +): Promise { + const opts = { + contextLoader: options.contextLoader, + documentLoader: withDocumentLoaderTimeout( + options.documentLoader ?? ctx.documentLoader, + ), + suppressError: true, + }; + const collection = await post.getEmojiReactions(opts); + if (collection == null) return; + const { persistReaction, updateReactionsCounts } = await import( + "../reaction.ts" + ); + let shouldUpdateCounts = false; + try { + let scanned = 0; + for await (const item of traverseCollection(collection, opts)) { + if (scanned >= MAX_EMOJI_REACTIONS_BACKFILL) break; + scanned++; + if (!(item instanceof vocab.Like || item instanceof vocab.EmojiReact)) { + continue; + } + if (item.objectId?.href !== persistedPost.iri) continue; + shouldUpdateCounts = true; + await persistReaction(ctx, item, opts); + } + } finally { + if (shouldUpdateCounts) { + await updateReactionsCounts(ctx.db, persistedPost.id); + } + } +} + +async function enqueueEmojiReactionsBackfill( + ctx: ApplicationContext, + post: PostObject, + persistedPost: Pick, + options: { + contextLoader?: DocumentLoader; + documentLoader?: DocumentLoader; + }, +): Promise { + const lockKey = `emoji-reactions-backfill/${persistedPost.iri}`; + const [locked] = await ctx.kv.getMany([lockKey]); + if (locked === "1") return; + await ctx.kv.set( + lockKey, + "1", + EMOJI_REACTIONS_BACKFILL_LOCK_TTL_MS, + ); + void (async () => { + const run = async (attempt: number): Promise => { + try { + await backfillEmojiReactions(ctx, post, persistedPost, options); + } catch (error) { + if (attempt < 1) { + await new Promise((resolve) => + setTimeout(resolve, EMOJI_REACTIONS_BACKFILL_RETRY_DELAY_MS) + ); + await run(attempt + 1); + return; + } + logger.warn( + "Failed to backfill emoji reactions for {postIri} after retry: " + + "{error}", + { postIri: persistedPost.iri, error }, + ); + } + }; + await run(0); + })().catch((error) => { + logger.warn( + "Emoji reactions backfill task failed for {postIri}: {error}", + { postIri: persistedPost.iri, error }, + ); + }); +} + +type PersistedQuoteTarget = Post & { actor: Actor & { instance: Instance } }; + +function getActorMentionHrefs(actor: Actor): string[] { + return [ + actor.iri, + ...(actor.url == null ? [] : [actor.url]), + ...actor.aliases, + ]; +} + +function deleteActorMentionHrefs( + mentionHrefs: Set, + actor: Actor, +): void { + for (const href of getActorMentionHrefs(actor)) mentionHrefs.delete(href); +} + +async function resolveMentionedActors( + ctx: ApplicationContext, + mentionHrefs: ReadonlySet, + options: { + contextLoader?: DocumentLoader; + documentLoader?: DocumentLoader; + }, +): Promise { + if (mentionHrefs.size < 1) return []; + const { db } = ctx; + const unresolvedHrefs = new Set(mentionHrefs); + const actorsById = new Map(); + const actorRows = await db.query.actorTable.findMany({ + where: { + OR: [ + { iri: { in: [...mentionHrefs] } }, + { url: { in: [...mentionHrefs] } }, + { RAW: (table) => arrayOverlaps(table.aliases, [...mentionHrefs]) }, + ], + }, + }); + for (const actor of actorRows) { + actorsById.set(actor.id, actor); + deleteActorMentionHrefs(unresolvedHrefs, actor); + } + for (const href of unresolvedHrefs) { + const apActor = await lookupObject(href, options); + if (!isActor(apActor)) continue; + let actor = await persistActor(ctx, apActor, options); + if (actor == null) continue; + if (actor.iri !== href && !actor.aliases.includes(href)) { + const aliases = [...actor.aliases, href]; + await db.update(actorTable) + .set({ aliases }) + .where(eq(actorTable.id, actor.id)); + actor = { ...actor, aliases }; + } + actorsById.set(actor.id, actor); + } + return [...actorsById.values()]; +} + +export async function persistPost( + ctx: ApplicationContext, + post: PostObject, + options: { + actor?: Actor & { instance: Instance }; + replyTarget?: Post & { actor: Actor & { instance: Instance } }; + replies?: boolean; + depth?: number; + maxDepth?: number; + maxReplies?: number; + inlineRepliesThreshold?: number; + deferLargeReplies?: boolean; + contextLoader?: DocumentLoader; + documentLoader?: DocumentLoader; + /** + * Shared overall deadline for the whole synchronous persist subtree. Left + * unset by top-level callers (an inbox handler): the first call mints one + * from {@link PERSIST_POST_OVERALL_BUDGET_MS} and threads it through the + * synchronous recursion so every level shares one budget. The deferred + * reply backfill deliberately does NOT inherit it (each backfilled reply + * gets its own fresh budget instead of being cut off by the handler's). + */ + signal?: AbortSignal; + } = {}, +): Promise< + | Post & { + actor: Actor & { instance: Instance }; + mentions: (Mention & { actor: Actor })[]; + replyTarget: Post & { actor: Actor } | null; + quotedPost: Post & { actor: Actor } | null; + poll: Poll | null; + } + | undefined +> { + if (post.id == null || post.attributionId == null || post.content == null) { + logger.debug( + "Missing required fields (id, attributedTo, content): {post}", + { post }, + ); + return; + } + const { db } = ctx; + const depth = options.depth ?? 0; + const maxDepth = options.maxDepth ?? DEFAULT_MAX_PERSIST_POST_DEPTH; + const maxReplies = options.maxReplies ?? DEFAULT_MAX_INLINE_REPLIES; + const inlineRepliesThreshold = options.inlineRepliesThreshold ?? + DEFAULT_INLINE_REPLIES_THRESHOLD; + const deferLargeReplies = options.deferLargeReplies ?? true; + const shouldRecurse = depth < maxDepth; + if (post.id.origin === ctx.canonicalOrigin) { + return await getPersistedPost(db, post.id); + } + let actor = + options.actor == null || options.actor.iri !== post.attributionId.href + ? await getPersistedActor(db, post.attributionId) + : options.actor; + // One deadline for the entire synchronous subtree: reuse the parent's when + // recursing, otherwise mint a fresh one at the top-level (handler) call. + const overallSignal = options.signal ?? + AbortSignal.timeout(PERSIST_POST_OVERALL_BUDGET_MS); + const opts = { + contextLoader: options.contextLoader, + documentLoader: withDocumentLoaderTimeout( + options.documentLoader ?? ctx.documentLoader, + REMOTE_FETCH_TIMEOUT_MS, + overallSignal, + ), + suppressError: true, + }; + if (actor != null && isFederationBlocked(actor)) return undefined; + if (actor == null) { + const apActor = await post.getAttribution(opts); + if (apActor == null) return; + // Use `opts`, not `options`: `opts.documentLoader` is bounded by the + // per-fetch timeout and the shared `overallSignal`, so persistActor's own + // dereferencing (icon/image/attachments/featured/tags) cannot stall this + // handler past the queue timeout. + actor = await persistActor(ctx, apActor, opts); + if (actor == null) { + logger.debug("Failed to persist actor: {actor}", { actor: apActor }); + return; + } + } + const tags: Record = {}; + const mentions = new Set(); + const emojis: Record = {}; + const quotedPostIris: string[] = []; + for await (const tag of post.getTags(opts)) { + if (tag instanceof vocab.Hashtag) { + if (tag.name == null || tag.href == null) continue; + tags[tag.name.toString().replace(/^#/, "").toLowerCase()] = tag.href.href; + } else if (tag instanceof vocab.Mention) { + if (tag.href == null) continue; + mentions.add(tag.href.href); + } else if (tag instanceof vocab.Emoji) { + if (tag.name == null) continue; + const icon = await tag.getIcon(opts); + if ( + icon?.url == null || + icon.url instanceof vocab.Link && icon.url.href == null + ) { + continue; + } + emojis[tag.name.toString()] = icon.url instanceof URL + ? icon.url.href + : icon.url.href!.href; + } else if (tag instanceof vocab.Link) { + if (tag.mediaType == null || tag.href == null) continue; + const [mediaType, ...paramList] = tag.mediaType.split(/\s*;\s*/g); + const params = Object.fromEntries( + paramList.map((param) => { + let [key, value] = param.split(/\s*=\s*/g); + // value can be quoted: + value = value.match(/^"([^"]*)"\s*$/)?.[1] ?? value.trim(); + return [key.trim(), value]; + }), + ); + if ( + mediaType !== "application/activity+json" && + !(mediaType === "application/ld+json" && + params.profile === "https://www.w3.org/ns/activitystreams") + ) { + continue; + } + if (quotedPostIris.includes(tag.href.href)) continue; + quotedPostIris.push(tag.href.href); + } + } + if (post.quoteUrl != null) { + if (!quotedPostIris.includes(post.quoteUrl.href)) { + quotedPostIris.push(post.quoteUrl.href); + } + } + if (post.quoteId != null) { + if (!quotedPostIris.includes(post.quoteId.href)) { + quotedPostIris.unshift(post.quoteId.href); + } + } + let quotedPost: PersistedQuoteTarget | undefined; + let quotedPostIri: string | undefined; + if (quotedPostIris.length > 0) { + const quotedPosts = await db.query.postTable.findMany({ + with: { + actor: { + with: { instance: true }, + }, + }, + where: { iri: { in: quotedPostIris } }, + }); + quotedPosts.sort((a, b) => + quotedPostIris.indexOf(a.iri) - quotedPostIris.indexOf(b.iri) + ); + if (quotedPosts.length > 0) { + quotedPost = quotedPosts[0]; + quotedPostIri = quotedPost.iri; + } else if (shouldRecurse) { + for (const iri of quotedPostIris) { + let obj: vocab.Object | null; + try { + obj = await ctx.lookupObject(iri, opts); + } catch { + continue; + } + if (!isPostObject(obj)) continue; + quotedPost = await persistPost(ctx, obj, { + replies: false, + depth: depth + 1, + maxDepth, + maxReplies, + inlineRepliesThreshold, + deferLargeReplies: false, + contextLoader: options.contextLoader, + documentLoader: options.documentLoader, + signal: overallSignal, + }); + if (quotedPost != null) { + quotedPostIri = iri; + break; + } + } + } + } + if (quotedPost != null) { + quotedPost = await getOriginalQuoteTarget(db, quotedPost); + } + const attachments: vocab.Document[] = []; + for await (const attachment of post.getAttachments(opts)) { + if (attachment instanceof vocab.Document) attachments.push(attachment); + } + let replyTarget: Post & { actor: Actor & { instance: Instance } } | undefined; + if (post.replyTargetId != null) { + replyTarget = options.replyTarget ?? + await getPersistedPost(db, post.replyTargetId); + if (replyTarget == null && shouldRecurse) { + const apReplyTarget = await post.getReplyTarget(opts); + if (!isPostObject(apReplyTarget)) return; + replyTarget = await persistPost(ctx, apReplyTarget, { + ...options, + replies: false, + depth: depth + 1, + signal: overallSignal, + }); + if (replyTarget == null) return; + } + } + const replies = options.replies ? await post.getReplies(opts) : null; + const shares = await post.getShares(opts); + const to = new Set(post.toIds.map((u) => u.href)); + const cc = new Set(post.ccIds.map((u) => u.href)); + const recipients = to.union(cc); + const visibility: PostVisibility = to.has(PUBLIC_COLLECTION.href) + ? "public" + : cc.has(PUBLIC_COLLECTION.href) + ? "unlisted" + : actor.followersUrl != null && recipients.has(actor.followersUrl) && + mentions.isSubsetOf(recipients) + ? "followers" + : mentions.isSubsetOf(recipients) + ? "direct" + : "none"; + logger.debug( + "Post visibility: {visibility} (drived from recipients {recipients} and " + + "mentions {mentions}).", + { visibility, recipients, to, cc, mentions }, + ); + const { quotePolicy, quoteRequestPolicy } = + quotePoliciesFromInteractionPolicy( + post, + visibility, + actor.followersUrl, + ); + let quoteAuthorizationIri = post.quoteAuthorizationId?.href; + const existingPost = await db.query.postTable.findFirst({ + columns: { + id: true, + name: true, + contentHtml: true, + quotedPostId: true, + linkId: true, + }, + where: { iri: post.id.href }, + }); + if (quoteAuthorizationIri != null && quotedPost != null) { + const authorization = await post.getQuoteAuthorization(opts); + let validAuthorization = + authorization instanceof vocab.QuoteAuthorization && + authorization.interactingObjectId?.href === post.id.href && + authorization.interactionTargetId?.href === quotedPost.iri && + authorization.attributionId?.href === quotedPost.actor.iri; + if (validAuthorization && quotedPost.actor.accountId != null) { + const issuedAuthorization = await db.select({ + id: quoteAuthorizationTable.id, + }) + .from(quoteAuthorizationTable) + .where(and( + eq(quoteAuthorizationTable.iri, quoteAuthorizationIri), + eq(quoteAuthorizationTable.quotePostIri, post.id.href), + eq(quoteAuthorizationTable.quotedPostId, quotedPost.id), + eq(quoteAuthorizationTable.attributedActorId, quotedPost.actorId), + eq(quoteAuthorizationTable.revoked, false), + )) + .limit(1); + validAuthorization = issuedAuthorization.length > 0; + } else if (validAuthorization) { + // Fedify dereferences cross-origin embedded authorizations before this. + validAuthorization = new URL(quoteAuthorizationIri).origin === + new URL(quotedPost.actor.iri).origin; + } + if (!validAuthorization) { + logger.debug("Ignoring invalid quote authorization: {iri}", { + iri: quoteAuthorizationIri, + }); + quoteAuthorizationIri = undefined; + } + } + if ( + quotedPost?.visibility === "direct" || quotedPost?.visibility === "none" + ) { + logger.debug("Ignoring quoted post with private visibility: {iri}", { + iri: quotedPost.iri, + }); + quotedPost = undefined; + } + let unauthorizedQuoteTarget: PersistedQuoteTarget | undefined; + // A censored post cannot be quoted, even through a quote authorization + // issued before moderators censored it. This runs before (and independent + // of) the policy check below, which the authorization path skips, so + // censorship is never overridden by an outstanding authorization. + if (quotedPost != null && quotedPost.censored != null) { + logger.debug("Ignoring censored quoted post: {iri}", { + iri: quotedPost.iri, + }); + unauthorizedQuoteTarget = quotedPost; + quotedPost = undefined; + quoteAuthorizationIri = undefined; + } + if ( + quotedPost != null && + quoteAuthorizationIri == null && + !(await canPersistIncomingQuote(db, quotedPost.id, actor)) + ) { + logger.debug("Ignoring quoted post denied by quote policy: {iri}", { + iri: quotedPost.iri, + }); + unauthorizedQuoteTarget = quotedPost; + quotedPost = undefined; + } + if (quotedPost == null && quoteAuthorizationIri != null) { + logger.debug("Ignoring quote authorization without quote target: {iri}", { + iri: quoteAuthorizationIri, + }); + quoteAuthorizationIri = undefined; + } + const quoteTargetState: Post["quoteTargetState"] = quotedPost != null || + quotedPostIris.length < 1 || existingPost == null || + unauthorizedQuoteTarget == null + ? null + : await getPersistedQuoteTargetState( + db, + existingPost.id, + unauthorizedQuoteTarget.id, + ); + const mentionedActors = await resolveMentionedActors(ctx, mentions, opts); + const mentionLinkHrefs = new Set(mentions); + for (const actor of mentionedActors) { + for (const href of getActorMentionHrefs(actor)) mentionLinkHrefs.add(href); + } + const contentHtml = post.content?.toString(); + const postUrl = post.url instanceof vocab.Link + ? post.url.href?.href + : post.url?.href; + const type = post instanceof vocab.Article + ? "Article" + : post instanceof vocab.Note + ? "Note" + : post instanceof vocab.Question + ? "Question" + : assertNever(post, `Unexpected type of post: ${post}`); + const qualifyingArticleNewsPost = type === "Article" && + (visibility === "public" || visibility === "unlisted") && + replyTarget == null && + quotedPost == null; + const articleNewsLink = qualifyingArticleNewsPost + ? await persistArticleNewsLink(ctx, { + url: postUrl, + iri: post.id.href, + name: post.name?.toString(), + summary: post.summary?.toString(), + contentHtml, + }, actor) + : undefined; + let externalLinks = contentHtml == null + ? [] + : extractExternalLinks(contentHtml, { excludeHrefs: mentionLinkHrefs }); + if (quotedPost != null) { + externalLinks = externalLinks.filter((l) => + quotedPost.iri !== l.href && + quotedPost.url !== l.href && + quotedPostIri !== l.href + ); + } + const embeddedLink = articleNewsLink == null && externalLinks.length > 0 + ? await persistPostLink(ctx, externalLinks[0], { signal: overallSignal }) + : undefined; + const link = articleNewsLink ?? embeddedLink; + const values: Omit = { + iri: post.id.href, + type, + visibility, + quotePolicy, + quoteRequestPolicy, + actorId: actor.id, + sensitive: post.sensitive ?? false, + name: post.name?.toString(), + summary: post.summary?.toString(), + contentHtml, + language: post.content instanceof LanguageString + ? post.content.locale.toString() + : post.contents.length > 1 && post.contents[1] instanceof LanguageString + ? post.contents[1].locale.toString() + : undefined, + tags, + emojis, + linkId: link?.id ?? null, + // Keep the exact URL from the post body for navigation. Canonical + // metadata and redirects belong to the shared PostLink identity only. + linkUrl: link == null + ? null + : articleNewsLink != null + ? articleNewsLink.url + : externalLinks[0].href, + url: postUrl, + replyTargetId: replyTarget?.id, + quotedPostId: quotedPost?.id ?? null, + quoteAuthorizationIri: quoteAuthorizationIri ?? null, + quoteTargetState, + repliesCount: replies?.totalItems ?? 0, + sharesCount: shares?.totalItems ?? 0, + updated: toDate(post.updated ?? post.published) ?? undefined, + published: toDate(post.published) ?? undefined, + }; + const { + repliesCount: _repliesCount, + sharesCount: _sharesCount, + ...updateSet + } = values; + const rows = await db.insert(postTable) + .values({ id: generateUuidV7(), ...values }) + .onConflictDoUpdate({ + target: postTable.iri, + set: updateSet, + setWhere: eq(postTable.iri, post.id.href), + }) + .returning(); + const persistedPost = { ...rows[0], actor }; + await createTargetPostUpdatedNotifications( + db, + existingPost, + persistedPost, + actor, + ); + if (quoteAuthorizationIri != null && quotedPost != null) { + await db.insert(quoteAuthorizationTable).values({ + id: generateUuidV7(), + iri: quoteAuthorizationIri, + quotePostIri: persistedPost.iri, + quotePostId: persistedPost.id, + quotedPostId: quotedPost.id, + attributedActorId: quotedPost.actorId, + }).onConflictDoUpdate({ + target: quoteAuthorizationTable.iri, + set: { + quotePostIri: persistedPost.iri, + quotePostId: persistedPost.id, + quotedPostId: quotedPost.id, + attributedActorId: quotedPost.actorId, + revoked: false, + updated: sql`CURRENT_TIMESTAMP`, + }, + }); + } + await db.delete(mentionTable).where( + eq(mentionTable.postId, persistedPost.id), + ); + + if ( + existingPost?.quotedPostId != null && + existingPost.quotedPostId !== quotedPost?.id + ) { + const previousQuotedPost = await db.query.postTable.findFirst({ + where: { id: existingPost.quotedPostId }, + }); + if (previousQuotedPost != null) { + await updateQuotesCount(db, previousQuotedPost, -1); + } + } + if (quotedPost != null && existingPost?.quotedPostId !== quotedPost.id) { + await updateQuotesCount(db, quotedPost, 1); + } + let mentionList: (Mention & { actor: Actor })[] = []; + const mentionsResult = mentionedActors.length > 0 + ? await db.insert(mentionTable) + .values( + mentionedActors.map((actor) => ({ + postId: persistedPost.id, + actorId: actor.id, + })), + ) + .onConflictDoNothing() + .returning() + .execute() + : []; + mentionList = mentionsResult.map((m) => ({ + ...m, + actor: mentionedActors.find((a) => a.id === m.actorId)!, + })); + await db.delete(postMediumTable).where( + eq(postMediumTable.postId, persistedPost.id), + ); + let i = 0; + for (const attachment of attachments) { + await persistPostMedium(ctx, attachment, persistedPost.id, i); + i++; + } + if (options.replies && depth === 0 && replies != null) { + const totalItems = replies.totalItems ?? 0; + const canInlineReplies = totalItems < 1 || + totalItems <= inlineRepliesThreshold; + if (canInlineReplies) { + let repliesCount = 0; + const traversalDeadline = Date.now() + INLINE_REPLIES_TRAVERSAL_BUDGET_MS; + const repliesIterator = traverseCollection(replies, opts)[ + Symbol.asyncIterator + ](); + try { + while (repliesCount < maxReplies) { + let result: Awaited>; + try { + result = await repliesIterator.next(); + } catch (error) { + logger.debug( + "Inline replies traversal for {postIri} failed after " + + "{repliesCount} replies; keeping the parent post.", + { postIri: persistedPost.iri, repliesCount, error }, + ); + break; + } + if (result.done) break; + if (Date.now() >= traversalDeadline) { + logger.debug( + "Inline replies traversal for {postIri} hit the {budgetMs}ms " + + "budget after {repliesCount} replies; stopping early to stay " + + "under the message handler timeout.", + { + postIri: persistedPost.iri, + budgetMs: INLINE_REPLIES_TRAVERSAL_BUDGET_MS, + repliesCount, + }, + ); + break; + } + const reply = result.value; + if (!isPostObject(reply)) continue; + await persistPost(ctx, reply, { + ...options, + actor, + replyTarget: persistedPost, + replies: false, + depth: depth + 1, + signal: overallSignal, + }); + repliesCount++; + } + } finally { + await repliesIterator.return?.(); + } + if (persistedPost.repliesCount < repliesCount) { + await db.update(postTable) + .set({ + repliesCount: + sql`GREATEST(${postTable.repliesCount}, ${repliesCount})`, + }) + .where(eq(postTable.id, persistedPost.id)); + persistedPost.repliesCount = Math.max( + persistedPost.repliesCount, + repliesCount, + ); + } + } else if (deferLargeReplies) { + const lockKey = `reply-backfill/${persistedPost.iri}`; + const [locked] = await ctx.kv.getMany([lockKey]); + if (locked !== "1") { + // Best-effort dedupe lock: avoid spawning multiple backfills for + // the same post during bursty inbox traffic. + await ctx.kv.set(lockKey, "1", REPLIES_BACKFILL_LOCK_TTL_MS); + await queueAfterCommit(ctx, () => { + const backgroundDb = ctx.rootDb ?? ctx.db; + const backgroundCtx: ApplicationContext = { + ...ctx.withDatabase(backgroundDb), + db: backgroundDb, + rootDb: backgroundDb, + afterCommit: undefined, + }; + void (async () => { + // This runs in the background after the handler returns, so it must + // NOT inherit the handler's overall deadline (`opts` is bound to + // `overallSignal`). Give it a loader with only the per-fetch timeout + // so a long backfill is not truncated at the synchronous handler's + // budget; each backfilled reply still gets its own fresh overall + // budget via the `persistPost` call below (which omits `signal`). + const backfillOpts = { + contextLoader: options.contextLoader, + documentLoader: withDocumentLoaderTimeout( + options.documentLoader ?? backgroundCtx.documentLoader, + ), + suppressError: true, + }; + const persistReply = async ( + attempt: number, + ): Promise => { + try { + let count = 0; + for await ( + const reply of traverseCollection(replies, backfillOpts) + ) { + if (count >= maxReplies) break; + if (!isPostObject(reply)) continue; + await persistPost(backgroundCtx, reply, { + ...options, + actor, + replyTarget: persistedPost, + replies: false, + depth: depth + 1, + // Don't inherit a caller's top-level deadline (`...options` + // may carry one); each backfilled reply mints its own fresh + // budget so the background batch isn't cut off by the + // originating handler's deadline. + signal: undefined, + }); + count++; + } + if (persistedPost.repliesCount < count) { + await backgroundDb.update(postTable) + .set({ + repliesCount: + sql`GREATEST(${postTable.repliesCount}, ${count})`, + }) + .where(eq(postTable.id, persistedPost.id)); + persistedPost.repliesCount = Math.max( + persistedPost.repliesCount, + count, + ); + } + } catch (error) { + if (attempt < 1) { + // Single delayed retry to absorb transient federation failures + // without introducing a durable queue. + await new Promise((resolve) => + setTimeout(resolve, REPLIES_BACKFILL_RETRY_DELAY_MS) + ); + await persistReply(attempt + 1); + return; + } + logger.warn( + "Failed to backfill replies for {postIri} after retry: {error}", + { postIri: persistedPost.iri, error }, + ); + } + }; + await persistReply(0); + })().catch((error) => { + logger.warn( + "Replies backfill task failed for {postIri}: {error}", + { + postIri: persistedPost.iri, + error, + }, + ); + }); + }); + } + } + } + let poll: Poll | undefined; + if (post instanceof vocab.Question) { + poll = await persistPoll(db, post, persistedPost.id); + } + if (depth === 0) { + await queueAfterCommit(ctx, () => { + const db = ctx.rootDb ?? ctx.db; + return enqueueEmojiReactionsBackfill( + { + ...ctx.withDatabase(db), + db, + rootDb: db, + afterCommit: undefined, + }, + post, + persistedPost, + { + contextLoader: options.contextLoader, + documentLoader: options.documentLoader, + }, + ); + }); + } + // Only refresh at the top level: recursive reply backfill (depth > 0) would + // re-score the same story once per reply; the periodic sweep covers those. + if (depth === 0) { + await refreshNewsScores(db, [persistedPost.linkId, existingPost?.linkId]); + } + return { + ...persistedPost, + replyTarget: replyTarget ?? null, + quotedPost: quotedPost ?? null, + mentions: mentionList, + poll: poll ?? null, + }; +} + +export async function persistSharedPost( + ctx: ApplicationContext, + announce: vocab.Announce, + options: { + actor?: Actor & { instance: Instance }; + contextLoader?: DocumentLoader; + documentLoader?: DocumentLoader; + /** + * Shared overall deadline for the whole operation. When provided by the + * caller (e.g. an inbox handler that already minted one), getActor(), + * getObject(), and the internal persistPost() all share this signal so + * their aggregate cannot exceed the message-queue handler timeout. When + * omitted, a fresh budget of PERSIST_POST_OVERALL_BUDGET_MS is minted. + */ + signal?: AbortSignal; + } = {}, +): Promise< + Post & { + actor: Actor & { instance: Instance }; + sharedPost: Post & { actor: Actor & { instance: Instance } }; + } | undefined +> { + if (announce.id == null || announce.actorId == null) { + logger.debug( + "Missing required fields (id, actor): {announce}", + { announce }, + ); + return; + } + const announceId = announce.id.href; + const { db } = ctx; + // One deadline for this entire operation. Reuse the caller's signal when + // available so pre-persistPost fetches (getActor, getObject) and the + // persistPost subtree are all capped by the same wall-clock budget. + const overallSignal = options.signal ?? + AbortSignal.timeout(PERSIST_POST_OVERALL_BUDGET_MS); + const boundedOpts = { + contextLoader: options.contextLoader, + documentLoader: withDocumentLoaderTimeout( + options.documentLoader ?? ctx.documentLoader, + REMOTE_FETCH_TIMEOUT_MS, + overallSignal, + ), + suppressError: true, + }; + let actor: Actor & { instance: Instance } | undefined = + options.actor == null || options.actor.iri !== announce.actorId.href + ? await getPersistedActor(db, announce.actorId) + : options.actor; + if (actor != null && isFederationBlocked(actor)) return; + if (actor == null) { + const apActor = await announce.getActor(boundedOpts); + if (apActor == null) return; + actor = await persistActor(ctx, apActor, boundedOpts); + if (actor == null) return; + } + const object = await announce.getObject(boundedOpts); + if (!isPostObject(object)) return; + const post = await persistPost(ctx, object, { + ...options, + replies: true, + signal: overallSignal, + }); + if (post == null) return; + // A censored post cannot be re-amplified via a federated boost, mirroring + // the local sharePost() guard: drop the Announce instead of inserting a + // wrapper that timelines and the share notification would surface. + if (post.censored != null) return; + const to = new Set(announce.toIds.map((u) => u.href)); + const cc = new Set(announce.ccIds.map((u) => u.href)); + const values: Omit = { + iri: announceId, + type: post.type, + visibility: to.has(PUBLIC_COLLECTION.href) + ? "public" + : cc.has(PUBLIC_COLLECTION.href) + ? "unlisted" + : actor.followersUrl != null && + (to.has(actor.followersUrl) || cc.has(actor.followersUrl)) + ? "followers" + : "none", + actorId: actor.id, + sharedPostId: post.id, + name: post.name, + contentHtml: post.contentHtml, + language: post.language, + tags: {}, + emojis: post.emojis, + sensitive: post.sensitive, + url: post.url, + updated: toDate(announce.updated ?? announce.published) ?? undefined, + published: toDate(announce.published) ?? undefined, + }; + return await runInTransaction(db, async (tx) => { + const lockKeys = [ + `announce:${announceId}`, + `share:${actor.id}:${post.id}`, + ].sort(); + for (const lockKey of lockKeys) { + await tx.execute(sql` + SELECT pg_advisory_xact_lock(hashtextextended(${lockKey}, 0)) + `); + } + + const existingByIri = await tx.query.postTable.findFirst({ + where: { iri: announceId }, + }); + if (existingByIri != null && existingByIri.sharedPostId == null) { + logger.warn( + "Dropping Announce {announceId}: its IRI belongs to a non-share post.", + { announceId }, + ); + return undefined; + } + const existingByPair = await tx.query.postTable.findFirst({ + where: { actorId: actor.id, sharedPostId: post.id }, + }); + const affectedNewsLinkIds = new Set(); + if (post.type === "Article" && post.linkId != null) { + affectedNewsLinkIds.add(post.linkId); + } + + if ( + existingByIri != null && existingByPair != null && + existingByIri.id !== existingByPair.id + ) { + logger.warn( + "Keeping existing share {shareId}: Announce {announceId} also belongs to share {iriShareId}.", + { + shareId: existingByPair.id, + announceId, + iriShareId: existingByIri.id, + }, + ); + await refreshNewsScores(tx, [...affectedNewsLinkIds]); + return { ...existingByPair, actor, sharedPost: post }; + } + const existing = existingByIri ?? existingByPair; + + let persistedShare: Post; + if (existing == null) { + const rows = await tx.insert(postTable) + .values({ id: generateUuidV7(), ...values }) + .returning(); + if (rows.length < 1) return undefined; + persistedShare = rows[0]; + await updateSharesCount(tx, post, 1); + } else { + const rows = await tx.update(postTable) + .set(values) + .where(eq(postTable.id, existing.id)) + .returning(); + if (rows.length < 1) return undefined; + persistedShare = rows[0]; + if (existing.sharedPostId !== post.id) { + if (existing.sharedPostId != null) { + const previousPost = await tx.query.postTable.findFirst({ + where: { id: existing.sharedPostId }, + }); + if (previousPost != null) { + await updateSharesCount(tx, previousPost, -1); + if ( + previousPost.type === "Article" && previousPost.linkId != null + ) { + affectedNewsLinkIds.add(previousPost.linkId); + } + } + } + await updateSharesCount(tx, post, 1); + } + } + + await refreshNewsScores(tx, [...affectedNewsLinkIds]); + return { ...persistedShare, actor, sharedPost: post }; + }); +} +async function canPersistIncomingQuote( + db: Database, + quotedPostId: Uuid, + actor: Actor, +): Promise { + const quotedPost = await db.query.postTable.findFirst({ + with: { + actor: { + with: { + followers: { where: { followerId: actor.id } }, + blockees: { where: { blockeeId: actor.id } }, + blockers: { where: { blockerId: actor.id } }, + }, + }, + mentions: { where: { actorId: actor.id } }, + }, + where: { id: quotedPostId }, + }); + // Quote *policy* only; the censored (moderation) gate lives at the caller so + // it also applies to the quote-authorization path, which legitimately + // overrides policy but must never override censorship. + return quotedPost != null && canActorQuotePost(quotedPost, actor); +} + +async function getPersistedQuoteTargetState( + db: Database, + quotePostId: Uuid, + quotedPostId: Uuid, +): Promise { + const pendingRequests = await db.select({ id: quoteRequestTable.id }) + .from(quoteRequestTable) + .where(and( + eq(quoteRequestTable.quotePostId, quotePostId), + eq(quoteRequestTable.quotedPostId, quotedPostId), + isNull(quoteRequestTable.accepted), + isNull(quoteRequestTable.rejected), + )) + .limit(1); + if (pendingRequests.length > 0) return "pending"; + + const deniedRequests = await db.select({ id: quoteRequestTable.id }) + .from(quoteRequestTable) + .where(and( + eq(quoteRequestTable.quotePostId, quotePostId), + eq(quoteRequestTable.quotedPostId, quotedPostId), + isNotNull(quoteRequestTable.rejected), + )) + .limit(1); + if (deniedRequests.length > 0) return "denied"; + + const revokedAuthorizations = await db.select({ + id: quoteAuthorizationTable.id, + }) + .from(quoteAuthorizationTable) + .where(and( + eq(quoteAuthorizationTable.quotePostId, quotePostId), + eq(quoteAuthorizationTable.quotedPostId, quotedPostId), + eq(quoteAuthorizationTable.revoked, true), + )) + .limit(1); + return revokedAuthorizations.length > 0 ? "denied" : null; +} + +async function getOriginalQuoteTarget( + db: Database, + post: PersistedQuoteTarget, +): Promise { + const originalPostId = await getOriginalPostId(db, post); + if (originalPostId == null) return undefined; + if (originalPostId === post.id) return post; + return await db.query.postTable.findFirst({ + with: { + actor: { + with: { instance: true }, + }, + }, + where: { id: originalPostId }, + }); +} +export async function deletePersistedPost( + db: Database, + iri: URL, + actorIri: URL, +): Promise { + const deletedPosts = await db.delete(postTable).where( + and( + eq(postTable.iri, iri.toString()), + inArray( + postTable.actorId, + db.select({ id: actorTable.id }) + .from(actorTable) + .where(eq(actorTable.iri, actorIri.toString())), + ), + isNull(postTable.sharedPostId), + ), + ).returning(); + if (deletedPosts.length < 1) return false; + const [deletedPost] = deletedPosts; + if (deletedPost.replyTargetId != null) { + const replyTarget = await db.query.postTable.findFirst({ + where: { id: deletedPost.replyTargetId }, + }); + if (replyTarget != null) await updateRepliesCount(db, replyTarget, -1); + } + if (deletedPost.quotedPostId != null) { + const quotedPost = await db.query.postTable.findFirst({ + where: { id: deletedPost.quotedPostId }, + }); + if (quotedPost != null) await updateQuotesCount(db, quotedPost, -1); + } + // Re-score the link this post shared and the links of the posts it replied + // to / quoted (their public reply/quote count dropped). + await refreshNewsScoresForPostLinks(db, deletedPost); + return true; +} + +export async function deleteSharedPost( + db: Database, + iri: URL, + actorIri: URL, +): Promise { + const actor = await db.query.actorTable.findFirst({ + where: { iri: actorIri.toString() }, + }); + if (actor == null) return undefined; + const shares = await db.delete(postTable).where( + and( + eq(postTable.iri, iri.toString()), + eq(postTable.actorId, actor.id), + isNotNull(postTable.sharedPostId), + ), + ).returning(); + if (shares.length < 1) return undefined; + const [share] = shares; + if (share.sharedPostId == null) return undefined; + const sharedPost = await db.query.postTable.findFirst({ + where: { id: share.sharedPostId }, + }); + if (sharedPost == null) return { ...share, actor }; + await updateSharesCount(db, sharedPost, -1); + await refreshNewsScores(db, [ + sharedPost.type === "Article" ? sharedPost.linkId : null, + ]); + return { ...share, actor }; +} diff --git a/models/post/sharing.ts b/models/post/sharing.ts new file mode 100644 index 000000000..9016f367b --- /dev/null +++ b/models/post/sharing.ts @@ -0,0 +1,241 @@ +import * as vocab from "@fedify/vocab"; +import { getLogger } from "@logtape/logtape"; +import { and, eq, inArray } from "drizzle-orm"; +import { syncActorFromAccount, toRecipient } from "../actor.ts"; +import type { ApplicationContext } from "../context.ts"; +import type { Database } from "../db.ts"; +import { assertAccountActorNotSuspended } from "../moderation.ts"; +import { refreshNewsScores } from "../news.ts"; +import { + createShareNotification, + deleteShareNotification, +} from "../notification.ts"; +import { + type Account, + type AccountEmail, + type AccountLink, + type Actor, + type Medium, + type Post, + postTable, + type PostVisibility, +} from "../schema.ts"; +import { addPostToTimeline, removeFromTimeline } from "../timeline.ts"; +import { generateUuidV7, type Uuid } from "../uuid.ts"; +import { updateSharesCount } from "./engagement.ts"; + +const logger = getLogger(["hackerspub", "models", "post", "sharing"]); + +async function getOriginalSharedPost( + db: Database, + post: Post & { actor: Actor }, +): Promise { + if (post.sharedPostId == null) return post; + + const visited = new Set([post.id]); + let currentId: Uuid | null = post.sharedPostId; + while (currentId != null) { + if (visited.has(currentId)) return post; + visited.add(currentId); + + const current: Pick | undefined = await db + .query.postTable.findFirst({ + columns: { id: true, sharedPostId: true }, + where: { id: currentId }, + }); + if (current == null) return post; + if (current.sharedPostId == null) { + const original = await db.query.postTable.findFirst({ + with: { actor: true }, + where: { id: current.id }, + }); + return original ?? post; + } + currentId = current.sharedPostId; + } + + return post; +} + +export async function sharePost( + fedCtx: ApplicationContext, + account: Account & { + avatarMedium: Medium | null; + emails: AccountEmail[]; + links: AccountLink[]; + }, + post: Post & { actor: Actor }, + visibility?: PostVisibility, +): Promise { + const { db } = fedCtx; + await assertAccountActorNotSuspended(db, account.id); + const sharedPost = await getOriginalSharedPost(db, post); + // Callers reject censored targets with a proper response; this is a + // backstop so no future caller can boost (and thus re-amplify and + // copy into the wrapper) moderation-hidden content. + if (post.censored != null || sharedPost.censored != null) { + throw new TypeError("A censored post cannot be shared."); + } + const actor = await syncActorFromAccount(fedCtx, account); + const id = generateUuidV7(); + const posts = await db.insert(postTable).values({ + id, + iri: fedCtx.getObjectUri(vocab.Announce, { id }).href, + type: sharedPost.type, + visibility: visibility || account.shareVisibility, + actorId: actor.id, + sharedPostId: sharedPost.id, + name: sharedPost.name, + contentHtml: sharedPost.contentHtml, + language: sharedPost.language, + tags: {}, + emojis: sharedPost.emojis, + sensitive: sharedPost.sensitive, + url: sharedPost.url, + }).onConflictDoNothing().returning(); + if (posts.length < 1) { + const share = await db.query.postTable.findFirst({ + where: { + actorId: actor.id, + sharedPostId: sharedPost.id, + }, + }); + return share!; + } + const share = posts[0]; + sharedPost.sharesCount = await updateSharesCount(db, sharedPost, 1); + share.sharesCount = sharedPost.sharesCount; + await refreshNewsScores(db, [ + sharedPost.type === "Article" ? sharedPost.linkId : null, + ]); + await addPostToTimeline(db, share); + + // Create a share notification for the original post's author + if (sharedPost.actor.accountId != null) { + const notification = await createShareNotification( + db, + sharedPost.actor.accountId, + sharedPost, + actor, + share.published, + ); + logger.debug("Created share notification for {accountId}: {notification}", { + accountId: sharedPost.actor.accountId, + notification, + }); + } + const announce = fedCtx.services.federation.getAnnounce(fedCtx, { + ...share, + sharedPost, + actor: { ...actor, account }, + mentions: [], + }); + await fedCtx.sendActivity( + { identifier: account.id }, + "followers", + announce, + { + orderingKey: share.iri, + preferSharedInbox: true, + excludeBaseUris: [new URL(fedCtx.canonicalOrigin)], + }, + ); + await fedCtx.sendActivity( + { identifier: account.id }, + toRecipient(sharedPost.actor), + announce, + { + orderingKey: share.iri, + excludeBaseUris: [new URL(fedCtx.canonicalOrigin)], + }, + ); + return share; +} + +export async function unsharePost( + fedCtx: ApplicationContext, + account: Account & { + avatarMedium: Medium | null; + emails: AccountEmail[]; + links: AccountLink[]; + }, + sharedPost: Post & { actor: Actor }, +): Promise { + const { db } = fedCtx; + const originalPost = await getOriginalSharedPost(db, sharedPost); + if (originalPost.sharedPostId != null) return; + const actor = await syncActorFromAccount(fedCtx, account); + const unshared = await db.delete(postTable).where( + and( + eq(postTable.actorId, actor.id), + eq(postTable.sharedPostId, originalPost.id), + ), + ).returning(); + if (unshared.length < 1) return undefined; + originalPost.sharesCount = await updateSharesCount(db, originalPost, -1); + await refreshNewsScores(db, [ + originalPost.type === "Article" ? originalPost.linkId : null, + ]); + await removeFromTimeline(db, unshared[0]); + if (originalPost.actor.accountId != null) { + await deleteShareNotification( + db, + originalPost.actor.accountId, + originalPost, + actor, + ); + } + const announce = fedCtx.services.federation.getAnnounce(fedCtx, { + ...unshared[0], + actor: { ...actor, account }, + sharedPost: originalPost, + mentions: [], + }); + const undo = new vocab.Undo({ + actor: fedCtx.getActorUri(account.id), + object: announce, + tos: announce.toIds, + ccs: announce.ccIds, + }); + await fedCtx.sendActivity( + { identifier: account.id }, + "followers", + undo, + { + orderingKey: unshared[0].iri, + preferSharedInbox: true, + excludeBaseUris: [new URL(fedCtx.canonicalOrigin)], + }, + ); + await fedCtx.sendActivity( + { identifier: account.id }, + toRecipient(originalPost.actor), + undo, + { + orderingKey: unshared[0].iri, + excludeBaseUris: [new URL(fedCtx.canonicalOrigin)], + }, + ); + return unshared[0]; +} + +export async function arePostsSharedBy( + db: Database, + postIds: readonly Uuid[], + account: Account & { actor: Actor }, +): Promise> { + if (postIds.length < 1) return new Set(); + const rows = await db.select({ sharedPostId: postTable.sharedPostId }) + .from(postTable) + .where( + and( + eq(postTable.actorId, account.actor.id), + inArray(postTable.sharedPostId, postIds as Uuid[]), + ), + ); + const result = new Set(); + for (const row of rows) { + if (row.sharedPostId != null) result.add(row.sharedPostId); + } + return result; +} diff --git a/models/post/source.ts b/models/post/source.ts new file mode 100644 index 000000000..17f31dd94 --- /dev/null +++ b/models/post/source.ts @@ -0,0 +1,435 @@ +import * as vocab from "@fedify/vocab"; +import { getLogger } from "@logtape/logtape"; +import { eq, sql } from "drizzle-orm"; +import { syncActorFromAccount } from "../actor.ts"; +import { + getArticleSourceMediumUrls, + getOriginalArticleContent, +} from "../article-source.ts"; +import type { ApplicationContext } from "../context.ts"; +import { extractExternalLinks } from "../html.ts"; +import { persistPostLink } from "../link-preview.ts"; +import { getMissingArticleMediumLabel, renderMarkup } from "../markup.ts"; +import { refreshNewsScores } from "../news.ts"; +import { createPoll, type CreatePollInput } from "../poll.ts"; +import { + type Account, + type AccountEmail, + type AccountLink, + type Actor, + type ArticleContent, + type ArticleSource, + type Instance, + type Medium, + type Mention, + mentionTable, + type NewPost, + type NoteSource, + type NoteSourceMedium, + type Poll, + type PollOption, + type Post, + type PostMedium, + postMediumTable, + postTable, + quoteAuthorizationTable, +} from "../schema.ts"; +import { generateUuidV7 } from "../uuid.ts"; +import { updateQuotesCount } from "./engagement.ts"; +import { + createTargetPostUpdatedNotifications, + persistArticleNewsLink, +} from "./persistence.ts"; +import { + canActorQuotePost, + getAllowedQuoteTargetForActor, + normalizeQuotePolicyForVisibility, + type QuotePolicyPost, +} from "./visibility.ts"; + +const logger = getLogger(["hackerspub", "models", "post", "source"]); + +type NoteSourceMediumWithMedium = NoteSourceMedium & { medium: Medium }; + +export async function syncPostFromArticleSource( + fedCtx: ApplicationContext, + articleSource: ArticleSource & { + account: Account & { + avatarMedium: Medium | null; + emails: AccountEmail[]; + links: AccountLink[]; + }; + contents: ArticleContent[]; + }, +): Promise< + Post & { + actor: Actor & { + account: Account & { + avatarMedium: Medium | null; + emails: AccountEmail[]; + links: AccountLink[]; + }; + instance: Instance; + }; + articleSource: ArticleSource & { + account: Account & { + avatarMedium: Medium | null; + emails: AccountEmail[]; + links: AccountLink[]; + }; + contents: ArticleContent[]; + }; + mentions: Mention[]; + } +> { + const { db, kv, storage: disk } = fedCtx; + const actor = await syncActorFromAccount(fedCtx, articleSource.account); + const content = getOriginalArticleContent(articleSource); + if (content == null) { + throw new Error("No content."); + } + const rendered = await renderMarkup(fedCtx, content.content, { + docId: articleSource.id, + kv, + mediumUrls: await getArticleSourceMediumUrls(db, disk, articleSource.id), + missingMediumLabel: getMissingArticleMediumLabel(content.language), + }); + const url = + `${fedCtx.origin}/@${articleSource.account.username}/${articleSource.publishedYear}/${ + encodeURIComponent(articleSource.slug) + }`; + const link = await persistArticleNewsLink(fedCtx, { + url, + iri: fedCtx.getObjectUri(vocab.Article, { id: articleSource.id }).href, + name: content.title, + summary: content.summary, + contentHtml: rendered.html, + }, actor); + const values: Omit = { + iri: fedCtx.getObjectUri(vocab.Article, { id: articleSource.id }).href, + type: "Article", + visibility: "public", + quotePolicy: articleSource.quotePolicy, + actorId: actor.id, + articleSourceId: articleSource.id, + name: content.title, + summary: content.summary, + contentHtml: rendered.html, + language: content.language, + tags: Object.fromEntries( + [...articleSource.tags, ...rendered.hashtags].map((tag) => [ + tag.toLowerCase().replace(/^#/, ""), + `${fedCtx.canonicalOrigin}/tags/${ + encodeURIComponent(tag.replace(/^#/, "")) + }`, + ]), + ), + linkId: link?.id ?? null, + linkUrl: link?.url ?? null, + url, + updated: articleSource.updated, + published: articleSource.published, + }; + const existingPost = await db.query.postTable.findFirst({ + columns: { id: true, name: true, contentHtml: true, linkId: true }, + where: { articleSourceId: articleSource.id }, + }); + const rows = await db.insert(postTable) + .values({ id: generateUuidV7(), ...values }) + .onConflictDoUpdate({ + target: postTable.articleSourceId, + set: values, + setWhere: eq(postTable.articleSourceId, articleSource.id), + }) + .returning(); + const [post] = rows; + await createTargetPostUpdatedNotifications(db, existingPost, post, actor); + await db.delete(mentionTable).where(eq(mentionTable.postId, post.id)); + const mentionList = globalThis.Object.values(rendered.mentions); + const mentions = mentionList.length > 0 + ? await db.insert(mentionTable).values( + mentionList.map((actor) => ({ + postId: post.id, + actorId: actor.id, + })), + ).onConflictDoNothing().returning() + : []; + await refreshNewsScores(db, [post.linkId, existingPost?.linkId]); + return { ...post, actor, mentions, articleSource }; +} + +export async function syncPostFromNoteSource( + fedCtx: ApplicationContext, + noteSource: NoteSource & { + account: Account & { + avatarMedium: Medium | null; + emails: AccountEmail[]; + links: AccountLink[]; + }; + media: NoteSourceMediumWithMedium[]; + }, + relations: { + replyTarget?: Post & { actor: Actor }; + quotedPost?: Post & { actor: Actor }; + question?: { + title: string; + poll: CreatePollInput; + }; + } = {}, +): Promise< + | Post & { + actor: Actor & { + account: Account & { + avatarMedium: Medium | null; + emails: AccountEmail[]; + links: AccountLink[]; + }; + instance: Instance; + }; + noteSource: NoteSource & { + account: Account & { + avatarMedium: Medium | null; + emails: AccountEmail[]; + links: AccountLink[]; + }; + media: NoteSourceMediumWithMedium[]; + }; + replyTarget: Post & { actor: Actor } | null; + quotedPost: Post & { actor: Actor } | null; + quoteRequestRequired: boolean; + quoteRequestTarget: Post & { actor: Actor } | null; + mentions: (Mention & { actor: Actor })[]; + media: PostMedium[]; + poll: (Poll & { options: PollOption[] }) | null; + } + | undefined +> { + const { db, kv, storage: disk } = fedCtx; + const existingPost = await db.query.postTable.findFirst({ + columns: { + id: true, + type: true, + name: true, + contentHtml: true, + quotedPostId: true, + quoteAuthorizationIri: true, + quoteTargetState: true, + linkId: true, + }, + where: { noteSourceId: noteSource.id }, + }); + const type = existingPost?.type ?? + (relations.question == null ? "Note" : "Question"); + if (existingPost != null && existingPost.type !== type) return undefined; + const iri = type === "Question" + ? fedCtx.getObjectUri(vocab.Question, { id: noteSource.id }).href + : fedCtx.getObjectUri(vocab.Note, { id: noteSource.id }).href; + const actor = await syncActorFromAccount(fedCtx, noteSource.account); + const hasQuotedPostRelation = Object.hasOwn(relations, "quotedPost"); + let quotedPost: QuotePolicyPost | undefined; + if (relations.quotedPost != null) { + quotedPost = await getAllowedQuoteTargetForActor( + db, + actor, + relations.quotedPost, + ); + if (quotedPost == null) { + logger.warn("Rejecting local quote creation due to quote policy.", { + noteSourceId: noteSource.id, + actorId: actor.id, + quotedPostId: relations.quotedPost.sharedPostId ?? + relations.quotedPost.id, + }); + return undefined; + } + } + // FIXME: Note should be rendered in a different way + const rendered = await renderMarkup(fedCtx, noteSource.content, { + docId: noteSource.id, + kv, + }); + const externalLinks = extractExternalLinks(rendered.html); + const link = externalLinks.length > 0 + ? await persistPostLink(fedCtx, externalLinks[0]) + : undefined; + const url = new URL( + `/@${noteSource.account.username}/${noteSource.id}`, + fedCtx.canonicalOrigin, + ).href; + const id = existingPost?.id ?? generateUuidV7(); + const quoteTargetId = quotedPost?.id ?? null; + const existingQuoteAuthorizationIri = + existingPost != null && existingPost.quotedPostId === quoteTargetId + ? existingPost.quoteAuthorizationIri + : null; + const quoteRequestRequired = quotedPost != null && + !canActorQuotePost(quotedPost, actor) && + existingQuoteAuthorizationIri == null; + const quotedPostId = !hasQuotedPostRelation && existingPost != null + ? undefined + : quoteRequestRequired + ? null + : quoteTargetId; + const quoteAuthorizationIri = !hasQuotedPostRelation && existingPost != null + ? undefined + : quotedPost == null + ? null + : quoteRequestRequired + ? null + : quotedPost.actor.accountId == null + ? existingQuoteAuthorizationIri + : quotedPost.actorId === actor.id + ? null + : fedCtx.getObjectUri(vocab.QuoteAuthorization, { id }).href; + const quoteTargetState = !hasQuotedPostRelation && existingPost != null + ? undefined + : quoteRequestRequired + ? "pending" + : null; + const values: Omit = { + iri, + type, + visibility: noteSource.visibility, + quotePolicy: normalizeQuotePolicyForVisibility( + noteSource.visibility, + noteSource.quotePolicy, + ), + actorId: actor.id, + noteSourceId: noteSource.id, + replyTargetId: relations.replyTarget?.id, + quotedPostId, + quoteAuthorizationIri, + quoteTargetState, + name: relations.question?.title, + contentHtml: rendered.html, + language: noteSource.language, + tags: Object.fromEntries( + rendered.hashtags.map((tag) => [ + tag.toLowerCase().replace(/^#/, ""), + `${fedCtx.canonicalOrigin}/tags/${ + encodeURIComponent(tag.replace(/^#/, "")) + }`, + ]), + ), + // Use explicit `null` (not `undefined`) so editing a note to remove its + // link actually clears `link_id` on update: drizzle drops `undefined` from + // the update set, which would otherwise leave the old link attached (and + // keep a dropped story in the news feed). Matches `persistPost`. + linkId: link?.id ?? null, + // Keep the exact URL the author shared for navigation. The PostLink URL + // is a fragment-less fetch/aggregation identity and may differ after an + // HTTP redirect; neither should replace the author's query or fragment. + linkUrl: link == null ? null : externalLinks[0].href, + url, + updated: noteSource.updated, + published: noteSource.published, + }; + const rows = await db.insert(postTable) + .values({ id, ...values }) + .onConflictDoUpdate({ + target: postTable.noteSourceId, + set: values, + setWhere: eq(postTable.noteSourceId, noteSource.id), + }) + .returning(); + const post = rows[0]; + await createTargetPostUpdatedNotifications(db, existingPost, post, actor); + if (post.quoteAuthorizationIri != null && quotedPost != null) { + await db.insert(quoteAuthorizationTable).values({ + id, + iri: post.quoteAuthorizationIri, + quotePostIri: post.iri, + quotePostId: post.id, + quotedPostId: quotedPost.sharedPostId ?? quotedPost.id, + attributedActorId: quotedPost.actorId, + }).onConflictDoUpdate({ + target: quoteAuthorizationTable.iri, + set: { + quotePostIri: post.iri, + quotePostId: post.id, + quotedPostId: quotedPost.sharedPostId ?? quotedPost.id, + attributedActorId: quotedPost.actorId, + revoked: false, + updated: sql`CURRENT_TIMESTAMP`, + }, + }); + } + await db.delete(mentionTable).where(eq(mentionTable.postId, post.id)); + const mentionList = globalThis.Object.values(rendered.mentions); + + if (hasQuotedPostRelation && quoteAuthorizationIri == null) { + await db.delete(quoteAuthorizationTable) + .where(eq(quoteAuthorizationTable.quotePostId, post.id)); + } + if ( + hasQuotedPostRelation && + existingPost?.quotedPostId != null && + existingPost.quotedPostId !== quotedPostId + ) { + const previousQuotedPost = await db.query.postTable.findFirst({ + where: { id: existingPost.quotedPostId }, + }); + if (previousQuotedPost != null) { + await updateQuotesCount(db, previousQuotedPost, -1); + } + } + if ( + hasQuotedPostRelation && quotedPost != null && + quotedPostId != null && + existingPost?.quotedPostId !== quotedPostId + ) { + await updateQuotesCount(db, quotedPost, 1); + } + const mentions = mentionList.length > 0 + ? (await db.insert(mentionTable).values( + mentionList.map((actor) => ({ + postId: post.id, + actorId: actor.id, + })), + ).onConflictDoNothing().returning()).map((m) => ({ + ...m, + actor: mentionList.find((a) => a.id === m.actorId)!, + })) + : []; + await db.delete(postMediumTable).where(eq(postMediumTable.postId, post.id)); + const media = noteSource.media.length > 0 + ? await db.insert(postMediumTable).values( + await Promise.all(noteSource.media.map(async (medium) => ({ + postId: post.id, + index: medium.index, + type: medium.medium.type, + url: await disk.getUrl(medium.medium.key), + alt: medium.alt, + width: medium.medium.width, + height: medium.medium.height, + }))), + ).returning() + : []; + const poll = relations.question == null + ? null + : await createPoll(db, post.id, relations.question.poll); + const returnedQuotedPost = hasQuotedPostRelation + ? quoteRequestRequired ? null : quotedPost ?? null + : post.quotedPostId == null + ? null + : await db.query.postTable.findFirst({ + where: { id: post.quotedPostId }, + with: { actor: true }, + }) ?? null; + const quoteRequestTarget = quoteRequestRequired ? quotedPost ?? null : null; + // Score the link this note now shares; also refresh the previous link when + // an edit changed or removed it, so the old story can drop out. + await refreshNewsScores(db, [post.linkId, existingPost?.linkId]); + return { + ...post, + actor, + noteSource, + mentions, + media, + replyTarget: relations.replyTarget ?? null, + quotedPost: returnedQuotedPost, + quoteRequestRequired, + quoteRequestTarget, + poll, + }; +} diff --git a/models/post/visibility.ts b/models/post/visibility.ts new file mode 100644 index 000000000..e2d9dec4a --- /dev/null +++ b/models/post/visibility.ts @@ -0,0 +1,587 @@ +import { PUBLIC_COLLECTION } from "@fedify/vocab"; +import type { Database, RelationsFilter } from "../db.ts"; +import type { + Actor, + Blocking, + Following, + Mention, + Post, + PostVisibility, + QuotePolicy, +} from "../schema.ts"; +import type { Uuid } from "../uuid.ts"; +import type { PostObject } from "./core.ts"; + +export type QuotePolicyPost = Post & { + actor: Actor & { + followers: Following[]; + blockees: Blocking[]; + blockers: Blocking[]; + }; + mentions: Mention[]; +}; + +const maxQuoteShareChainDepth = 16; + +export function normalizeQuotePolicyForVisibility( + visibility: PostVisibility, + quotePolicy: QuotePolicy | null | undefined, +): QuotePolicy { + if (visibility !== "public" && visibility !== "unlisted") return "self"; + return quotePolicy ?? "everyone"; +} + +function quotePolicyFromApprovalUrls( + post: PostObject, + approvalUrls: string[], + authorFollowersUrl: string | null, +): QuotePolicy | undefined { + if (approvalUrls.includes(PUBLIC_COLLECTION.href)) return "everyone"; + if (authorFollowersUrl != null && approvalUrls.includes(authorFollowersUrl)) { + return "followers"; + } + if ( + post.attributionId != null && + approvalUrls.includes(post.attributionId.href) + ) { + return "self"; + } + return undefined; +} + +export function quotePoliciesFromInteractionPolicy( + post: PostObject, + visibility: PostVisibility, + authorFollowersUrl: string | null, +): { + quotePolicy: QuotePolicy; + quoteRequestPolicy: QuotePolicy | null; +} { + if (visibility !== "public" && visibility !== "unlisted") { + return { quotePolicy: "self", quoteRequestPolicy: null }; + } + const policy = post.interactionPolicy?.canQuote; + if (policy == null) { + return { + quotePolicy: normalizeQuotePolicyForVisibility(visibility, undefined), + quoteRequestPolicy: null, + }; + } + const quotePolicy = quotePolicyFromApprovalUrls( + post, + policy.automaticApprovals.map((url) => url.href), + authorFollowersUrl, + ) ?? "self"; + const quoteRequestPolicy = quotePolicyFromApprovalUrls( + post, + policy.manualApprovals.map((url) => url.href), + authorFollowersUrl, + ) ?? null; + return { quotePolicy, quoteRequestPolicy }; +} + +function canActorQuoteByPolicy( + post: Post & { actor: Actor & { followers: Following[] } }, + actor: Actor, + policy: QuotePolicy, +): boolean { + if (post.actorId === actor.id) return true; + if (policy === "everyone") return true; + if (policy === "followers") { + return post.actor.followers.some((follower) => + follower.followerId === actor.id && follower.accepted != null + ); + } + return false; +} + +export function canActorQuotePost( + post: Post & { + actor: Actor & { + followers: Following[]; + blockees: Blocking[]; + blockers: Blocking[]; + }; + mentions: Mention[]; + }, + actor: Actor, +): boolean { + if (post.sharedPostId != null) return false; + if (post.visibility === "direct" || post.visibility === "none") return false; + if (!isPostVisibleTo(post, actor)) return false; + return canActorQuoteByPolicy(post, actor, post.quotePolicy); +} + +export function canActorRequestQuotePost( + post: Post & { + actor: Actor & { + followers: Following[]; + blockees: Blocking[]; + blockers: Blocking[]; + }; + mentions: Mention[]; + }, + actor: Actor, +): boolean { + if (canActorQuotePost(post, actor)) return true; + if (post.sharedPostId != null) return false; + if (post.visibility === "direct" || post.visibility === "none") return false; + if (!isPostVisibleTo(post, actor)) return false; + if (post.quoteRequestPolicy == null) return false; + return canActorQuoteByPolicy(post, actor, post.quoteRequestPolicy); +} + +export async function getAllowedQuoteTargetForActor( + db: Database, + actor: Actor, + post: Post, +): Promise { + const targetPostId = await getOriginalPostId(db, post); + if (targetPostId == null) return undefined; + const quotedPost: QuotePolicyPost | undefined = await db.query.postTable + .findFirst({ + with: { + actor: { + with: { + followers: { where: { followerId: actor.id } }, + blockees: { where: { blockeeId: actor.id } }, + blockers: { where: { blockerId: actor.id } }, + }, + }, + mentions: { where: { actorId: actor.id } }, + }, + where: { id: targetPostId }, + }); + if (quotedPost == null) return undefined; + // A censored post cannot be quoted (by anyone, including its author): + // quoting re-amplifies moderation-hidden content. The submitted row + // is checked too, so a censored share wrapper cannot be used as a + // quote handle either. + if (post.censored != null || quotedPost.censored != null) { + return undefined; + } + const allowed = canActorRequestQuotePost(quotedPost, actor); + return allowed ? quotedPost : undefined; +} + +export async function getOriginalPostId( + db: Database, + post: Pick, +): Promise { + const visited = new Set([post.id]); + let target = post; + let depth = 0; + while (target.sharedPostId != null) { + if (depth >= maxQuoteShareChainDepth) return undefined; + depth++; + if (visited.has(target.sharedPostId)) return undefined; + visited.add(target.sharedPostId); + const next = await db.query.postTable.findFirst({ + columns: { id: true, sharedPostId: true }, + where: { id: target.sharedPostId }, + }); + if (next == null) return undefined; + target = next; + } + return target.id; +} +export function isPostVisibleTo( + post: Post & { + actor: Actor & { + followers: Following[]; + blockees: Blocking[]; + blockers: Blocking[]; + }; + mentions: Mention[]; + }, + actor?: Actor, +): boolean; +export function isPostVisibleTo( + post: Post & { + actor: Actor & { + followers: (Following & { follower: Actor })[]; + blockees: (Blocking & { blockee: Actor })[]; + blockers: (Blocking & { blocker: Actor })[]; + }; + mentions: (Mention & { actor: Actor })[]; + }, + actor?: { iri: string }, +): boolean; +export function isPostVisibleTo( + post: Post & { + actor: Actor & { + followers: (Following & { follower?: Actor })[]; + blockees: (Blocking & { blockee?: Actor })[]; + blockers: (Blocking & { blocker?: Actor })[]; + }; + mentions: (Mention & { actor?: Actor })[]; + sharedPost?: (Post & { actor: Actor }) | null; + }, + actor?: Actor | { iri: string }, +): boolean { + // A share wrapper's visibility depends on the boosted post (its author's + // block and sanction state, which the booster's actor does not carry). + // When the boosted post was not loaded, that cannot be evaluated, so fail + // closed rather than let a wrapper of a hidden post pass on the booster + // alone (e.g. an interaction resolver that has only a wrapper id). + if (post.sharedPostId != null && post.sharedPost == null) return false; + // A share wrapper denormalizes the boosted post's content, so a boost + // of a sanction-hidden actor's post is hidden too (when the relation + // is loaded). Checked before the wrapper-author fast path: only the + // boosted post's author keeps access, not the booster. + if (post.sharedPost?.actor != null) { + const sharedAuthor = post.sharedPost.actor; + const viewerIsSharedAuthor = actor != null && ( + "id" in actor + ? sharedAuthor.id === actor.id + : sharedAuthor.iri === actor.iri + ); + if (!viewerIsSharedAuthor && isActorSanctionHidden(sharedAuthor)) { + return false; + } + } + if (actor != null) { + if ( + "id" in actor && post.actor.id === actor.id || + "iri" in actor && post.actor.iri === actor.iri + ) { + return true; + } + } + if (isActorSanctionHidden(post.actor)) return false; + if (actor != null) { + const blocked = "id" in actor + ? post.actor.blockees.some((b) => b.blockeeId === actor.id) || + post.actor.blockers.some((b) => b.blockerId === actor.id) + : post.actor.blockees.some((b) => b.blockee?.iri === actor.iri) || + post.actor.blockers.some((b) => b.blocker?.iri === actor.iri); + if (blocked) return false; + } + if (post.visibility === "public" || post.visibility === "unlisted") { + return true; + } + if (actor == null) return false; + if (post.visibility === "followers") { + if ("id" in actor) { + return post.actor.followers.some((follower) => + follower.followerId === actor.id && follower.accepted != null + ) || post.mentions.some((mention) => mention.actorId === actor.id); + } else { + return post.actor.followers.some((follower) => + follower.follower?.iri === actor.iri && follower.accepted != null + ) || post.mentions.some((mention) => mention.actor?.iri === actor.iri); + } + } + if (post.visibility === "direct") { + if ("id" in actor) { + return post.mentions.some((mention) => mention.actorId === actor.id); + } else { + return post.mentions.some((mention) => mention.actor?.iri === actor.iri); + } + } + return false; +} + +export interface PostInteractionPolicy { + readonly canReply: boolean; + readonly canQuote: boolean; + readonly canShare: boolean; +} + +const DENY_ALL: PostInteractionPolicy = { + canReply: false, + canQuote: false, + canShare: false, +}; + +export async function getPostInteractionPolicies( + db: Database, + postIds: readonly Uuid[], + viewer: Actor | null, +): Promise> { + const result = new Map(); + for (const id of postIds) result.set(id, DENY_ALL); + if (postIds.length < 1 || viewer == null) return result; + + // Filter each viewer-relevant relation down to the viewer's row only. + // `isPostVisibleTo` just checks `.some(... === viewer.id ...)`, so loading + // the full follower/blockee/blocker/mention sets for popular actors is + // wasteful — at most one row per relation actually matters. + const posts = await db.query.postTable.findMany({ + with: { + actor: { + with: { + followers: { where: { followerId: viewer.id } }, + blockees: { where: { blockeeId: viewer.id } }, + blockers: { where: { blockerId: viewer.id } }, + }, + }, + mentions: { where: { actorId: viewer.id } }, + sharedPost: { + with: { + actor: { + with: { + followers: { where: { followerId: viewer.id } }, + blockees: { where: { blockeeId: viewer.id } }, + blockers: { where: { blockerId: viewer.id } }, + }, + }, + mentions: { where: { actorId: viewer.id } }, + }, + }, + }, + where: { + id: { in: postIds as Uuid[] }, + }, + }); + + for (const post of posts) { + if (!isPostVisibleTo(post, viewer)) continue; + const effective = post.sharedPost ?? post; + // A censored post (or a wrapper of one) cannot be boosted or quoted by + // anyone, including its author or a moderator: both actions re-amplify + // moderation-hidden content, and the share/quote mutations reject them + // outright. Deny the policy too so the UI never offers an affordance + // that is guaranteed to fail. + const censored = post.censored != null || effective.censored != null; + const canAmplify = !censored && effective.sharedPostId == null && + isPostVisibleTo(effective, viewer) && ( + effective.visibility === "public" || + effective.visibility === "unlisted" || + (effective.visibility === "followers" && + effective.actorId === viewer.id) + ); + const canQuote = !censored && effective.sharedPostId == null && + isPostVisibleTo(effective, viewer) && + canActorRequestQuotePost(effective, viewer); + result.set(post.id, { + canReply: true, + canQuote, + canShare: canAmplify, + }); + } + return result; +} + +/** + * Builds a post filter that excludes posts whose author (or whose shared + * original's author) is muted by the given muter. The `sharedPost` clause + * matters for the public timeline, where shares are wrapper posts: it hides a + * muted author's content even when an unmuted account boosts it. (In the + * personal timeline `post_id` always points at the underlying post, so the + * `sharedPost` clause is a harmless no-op there; muted *sharers* are handled + * separately via `timeline_item.last_sharer_id`.) + * + * Unlike {@link getActorContentExclusionFilter} (used for blocking), this is + * intentionally NOT folded into {@link getPostVisibilityFilter}: muting must + * only hide content from feeds, not from the muted actor's own profile or from + * thread views. Apply it explicitly in feed queries. + */ +export function getMutedActorExclusionFilter( + muterActorId: Uuid, +): RelationsFilter<"postTable"> { + return { + actor: { NOT: { muters: { muterId: muterActorId } } }, + NOT: { sharedPost: { actor: { muters: { muterId: muterActorId } } } }, + } satisfies RelationsFilter<"postTable">; +} + +/** + * Builds a post filter that excludes censored posts (and boosts of censored + * posts) from feed-like surfaces: timelines, search, news, and profile post + * lists. Like {@link getMutedActorExclusionFilter}, this is intentionally + * NOT folded into {@link getPostVisibilityFilter}: a censored post's + * permalink must remain reachable so it can show a censorship notice + * instead of disappearing with a 404. Apply it explicitly in list queries. + * + * When `viewerActorId` is given, the viewer's own censored posts stay + * visible to them ("author can still view their own content"). + */ +export function getCensoredPostExclusionFilter( + viewerActorId?: Uuid | null, +): RelationsFilter<"postTable"> { + return { + ...(viewerActorId == null ? { censored: { isNull: true } } : { + OR: [ + { censored: { isNull: true } }, + { actorId: viewerActorId }, + ], + }), + NOT: { sharedPost: { censored: { isNotNull: true } } }, + } satisfies RelationsFilter<"postTable">; +} + +/** + * Matches actors whose content is currently hidden by a moderation + * sanction: banned local actors (permanent suspension) and remote actors + * under an active federation block (temporary or permanent). A + * *temporarily* suspended local actor's content stays visible; only their + * ability to write is restricted. + * + * Sanction activeness is always evaluated by time comparison against the + * given instant, so expired suspensions need no cleanup writes. + * + * This is a *positive* matcher; it is only safe to negate at the relation + * level (`NOT: { sharedPost: { actor: ... } }` compiles to `NOT EXISTS`). + * Negating it directly on an actor row would trip SQL's three-valued + * logic: for unsanctioned actors `suspended` is `NULL`, the comparison + * evaluates to `NULL`, and `NOT NULL` is still `NULL`, filtering the row + * out. Use {@link getSanctionVisibleActorFilter} for the inclusion form. + */ +export function getSanctionHiddenActorFilter( + now: Date = new Date(), +): RelationsFilter<"actorTable"> { + return { + suspended: { lte: now }, + OR: [ + // Remote actor under an active federation block: + { accountId: { isNull: true }, suspendedUntil: { isNull: true } }, + { accountId: { isNull: true }, suspendedUntil: { gt: now } }, + // Banned local actor: + { accountId: { isNotNull: true }, suspendedUntil: { isNull: true } }, + ], + } satisfies RelationsFilter<"actorTable">; +} + +/** + * The TypeScript-side counterpart of {@link getSanctionHiddenActorFilter}: + * whether the actor's content is currently hidden by a moderation sanction + * (banned local actor, or remote actor under an active federation block). + */ +export function isActorSanctionHidden( + actor: Pick, + now: Date = new Date(), +): boolean { + if (actor.suspended == null || actor.suspended > now) return false; + if (actor.suspendedUntil != null && actor.suspendedUntil <= now) { + return false; // Expired. + } + // An active *temporary* suspension of a local actor only restricts + // writing; a permanent one (ban), or any active sanction on a remote + // actor (federation block), hides content. + return actor.accountId == null || actor.suspendedUntil == null; +} + +/** + * The NULL-safe inclusion complement of + * {@link getSanctionHiddenActorFilter}: matches actors whose content is + * NOT hidden by a moderation sanction, including the common case where + * `suspended` is `NULL`. + */ +export function getSanctionVisibleActorFilter( + now: Date = new Date(), +): RelationsFilter<"actorTable"> { + return { + OR: [ + // Not sanctioned at all: + { suspended: { isNull: true } }, + // Sanction not started yet: + { suspended: { gt: now } }, + // Sanction already expired: + { suspendedUntil: { lte: now } }, + // Active temporary suspension of a *local* actor only restricts + // writing; their content stays visible: + { accountId: { isNotNull: true }, suspendedUntil: { gt: now } }, + ], + } satisfies RelationsFilter<"actorTable">; +} + +function getActorContentExclusionFilter( + actorId: Uuid, +): RelationsFilter<"actorTable"> { + return { + AND: [ + { + NOT: { + OR: [ + { blockees: { blockeeId: actorId } }, + { blockers: { blockerId: actorId } }, + ], + }, + }, + getSanctionVisibleActorFilter(), + ], + } satisfies RelationsFilter<"actorTable">; +} + +export function getPostVisibilityFilter( + actor: Actor | null, +): RelationsFilter<"postTable">; +export function getPostVisibilityFilter( + actor: Post, +): RelationsFilter<"actorTable">; + +export function getPostVisibilityFilter( + actorOrPost: Actor | Post | null, +): RelationsFilter<"postTable"> | RelationsFilter<"actorTable"> { + if (actorOrPost == null) { + return { + visibility: { in: ["public", "unlisted"] }, + actor: getSanctionVisibleActorFilter(), + NOT: { sharedPost: { actor: getSanctionHiddenActorFilter() } }, + } satisfies RelationsFilter<"postTable">; + } + if ("accountId" in actorOrPost) { + return { + actor: getActorContentExclusionFilter(actorOrPost.id), + NOT: { sharedPost: { actor: getSanctionHiddenActorFilter() } }, + OR: [ + { actorId: actorOrPost.id }, + { visibility: { in: ["public", "unlisted"] } }, + { mentions: { actorId: actorOrPost.id } }, + { + visibility: "followers", + actor: { + followers: { + followerId: actorOrPost.id, + accepted: { isNotNull: true }, + }, + }, + }, + ], + } satisfies RelationsFilter<"postTable">; + } else { + if ( + actorOrPost.visibility === "public" || + actorOrPost.visibility === "unlisted" + ) { + return getActorContentExclusionFilter(actorOrPost.actorId); + } + return { + AND: [ + getActorContentExclusionFilter(actorOrPost.actorId), + { + OR: [ + { id: actorOrPost.actorId }, + { mentions: { postId: actorOrPost.id } }, + ...(actorOrPost.visibility === "followers" + ? [{ + followees: { + followeeId: actorOrPost.actorId, + accepted: { isNotNull: true }, + } satisfies RelationsFilter<"followingTable">, + }] + : []), + ], + }, + ], + } satisfies RelationsFilter<"actorTable">; + } +} + +export function getPublicTimelineVisibilityFilter( + actor: Actor | null, +): RelationsFilter<"postTable"> { + if (actor == null) { + return { + visibility: "public", + actor: getSanctionVisibleActorFilter(), + NOT: { sharedPost: { actor: getSanctionHiddenActorFilter() } }, + } satisfies RelationsFilter<"postTable">; + } + return { + actor: getActorContentExclusionFilter(actor.id), + NOT: { sharedPost: { actor: getSanctionHiddenActorFilter() } }, + visibility: "public", + } satisfies RelationsFilter<"postTable">; +} diff --git a/models/profile-interactions.ts b/models/profile-interactions.ts index e1edd01ce..b9711666c 100644 --- a/models/profile-interactions.ts +++ b/models/profile-interactions.ts @@ -3,7 +3,7 @@ import type { Database, RelationsFilter } from "./db.ts"; import { getCensoredPostExclusionFilter, getPostVisibilityFilter, -} from "./post.ts"; +} from "./post/visibility.ts"; import { type Account, type Actor, diff --git a/models/question.ts b/models/question.ts index e30374be7..c1e0089f4 100644 --- a/models/question.ts +++ b/models/question.ts @@ -14,11 +14,9 @@ import { QuotePolicyDeniedError, } from "./note.ts"; import { type CreatePollInput, normalizePollInput } from "./poll.ts"; -import { - getAllowedQuoteTargetForActor, - syncPostFromNoteSource, - updateRepliesCount, -} from "./post.ts"; +import { updateRepliesCount } from "./post/engagement.ts"; +import { syncPostFromNoteSource } from "./post/source.ts"; +import { getAllowedQuoteTargetForActor } from "./post/visibility.ts"; import { type Account, type AccountEmail, diff --git a/models/reaction.ts b/models/reaction.ts index cb4aa189a..8737ba514 100644 --- a/models/reaction.ts +++ b/models/reaction.ts @@ -16,7 +16,8 @@ import { createReactNotification, deleteReactNotification, } from "./notification.ts"; -import { getPersistedPost, isPostObject, persistPost } from "./post.ts"; +import { getPersistedPost, isPostObject } from "./post/core.ts"; +import { persistPost } from "./post/remote.ts"; import { type Account, type Actor, diff --git a/models/timeline.ts b/models/timeline.ts index eb3994c06..cc264f17a 100644 --- a/models/timeline.ts +++ b/models/timeline.ts @@ -6,7 +6,7 @@ import { getMutedActorExclusionFilter, getPostVisibilityFilter, getPublicTimelineVisibilityFilter, -} from "./post.ts"; +} from "./post/visibility.ts"; import { type Account, type Actor, diff --git a/web/routes/@[username]/[idOrYear]/[slug]/shares.tsx b/web/routes/@[username]/[idOrYear]/[slug]/shares.tsx index 0ee2192d9..f67184a90 100644 --- a/web/routes/@[username]/[idOrYear]/[slug]/shares.tsx +++ b/web/routes/@[username]/[idOrYear]/[slug]/shares.tsx @@ -60,6 +60,10 @@ export const handler = define.handlers(async (ctx) => { }, }, mentions: true, + // isPostVisibleTo fails closed for a wrapper whose original post was + // not loaded. The query is already constrained to the visible article + // above, so only its author is needed for the wrapper's sanction check. + sharedPost: { with: { actor: true } }, }, where: { AND: [