From 016b9f4d38260e6e68cbc5c69d8be9f0f2f31227 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 14 Jul 2026 01:48:36 +0900 Subject: [PATCH 01/15] Split post modules by feature boundaries Separate post model behavior and GraphQL schema registration into focused modules while preserving the existing compatibility facades and generated schema. Move actor and account post fields to the composition root, and centralize shared persistence helpers so local and remote synchronization use the same implementation. Closes https://github.com/hackers-pub/hackerspub/issues/340 Assisted-by: Codex:gpt-5.6-sol --- ARCHITECTURE.md | 38 + federation/collections.ts | 2 +- federation/inbox/quote.ts | 9 +- federation/inbox/subscribe.ts | 6 +- federation/inbox/update.ts | 2 +- federation/objects.ts | 2 +- graphql/account.ts | 19 - graphql/actor.ts | 354 +- graphql/builder.ts | 2 +- graphql/lookup.ts | 3 +- graphql/mod.ts | 2 + graphql/moderation.ts | 2 +- graphql/news.ts | 2 +- graphql/poll.ts | 4 +- graphql/post.facade.test.ts | 29 + graphql/post.ts | 5892 +------------------------------- graphql/post/actor-fields.ts | 361 ++ graphql/post/article-fields.ts | 21 + graphql/post/article.ts | 586 ++++ graphql/post/core.ts | 2369 +++++++++++++ graphql/post/mutations.ts | 2908 ++++++++++++++++ graphql/post/note.ts | 85 + graphql/reactable.ts | 2 +- graphql/refresh.ts | 3 +- graphql/search.ts | 6 +- models/actor.ts | 3 +- models/article-source.ts | 74 + models/article.ts | 79 +- models/deno.json | 7 + models/link-preview.ts | 396 ++- models/note.ts | 8 +- models/poll.ts | 4 +- models/post.delete.test.ts | 3 +- models/post.facade.test.ts | 28 + models/post.lifecycle.test.ts | 3 +- models/post.remote.test.ts | 6 +- models/post.share.test.ts | 2 +- models/post.sync.test.ts | 5 +- models/post.test.ts | 10 +- models/post.ts | 3826 +-------------------- models/post/core.ts | 269 ++ models/post/engagement.ts | 300 ++ models/post/lifecycle.ts | 195 ++ models/post/persistence.ts | 172 + models/post/remote-fetch.ts | 72 + models/post/remote.ts | 1230 +++++++ models/post/sharing.ts | 241 ++ models/post/source.ts | 436 +++ models/post/visibility.ts | 587 ++++ models/profile-interactions.ts | 2 +- models/question.ts | 8 +- models/reaction.ts | 3 +- models/timeline.ts | 2 +- 53 files changed, 10535 insertions(+), 10145 deletions(-) create mode 100644 graphql/post.facade.test.ts create mode 100644 graphql/post/actor-fields.ts create mode 100644 graphql/post/article-fields.ts create mode 100644 graphql/post/article.ts create mode 100644 graphql/post/core.ts create mode 100644 graphql/post/mutations.ts create mode 100644 graphql/post/note.ts create mode 100644 models/article-source.ts create mode 100644 models/post.facade.test.ts create mode 100644 models/post/core.ts create mode 100644 models/post/engagement.ts create mode 100644 models/post/lifecycle.ts create mode 100644 models/post/persistence.ts create mode 100644 models/post/remote-fetch.ts create mode 100644 models/post/remote.ts create mode 100644 models/post/sharing.ts create mode 100644 models/post/source.ts create mode 100644 models/post/visibility.ts 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..e7917c83c 100644 --- a/federation/inbox/subscribe.ts +++ b/federation/inbox/subscribe.ts @@ -31,14 +31,14 @@ import { 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 { isPostObject } from "@hackerspub/models/post/core"; +import { updateRepliesCount } from "@hackerspub/models/post/engagement"; import { persistPollVoteResult } from "@hackerspub/models/poll"; import { deleteReaction, 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.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..de31357ce --- /dev/null +++ b/graphql/post/actor-fields.ts @@ -0,0 +1,361 @@ +import { + getCensoredPostExclusionFilter, + getPostVisibilityFilter, +} from "@hackerspub/models/post/visibility"; +import { + formatTimelineCursor, + getProfileInteractions, +} from "@hackerspub/models/profile-interactions"; +import { validateUuid } from "@hackerspub/models/uuid"; +import { resolveOffsetConnection } from "@pothos/plugin-relay"; +import { + Actor, + authenticationRequired, + conflictingCursors, + getActorById, + getConnectionWindow, + loadActorProfilePostPage, + parseRequiredTimelineCursor, + pinConnectionHelpers, +} from "../actor.ts"; +import { resolveActingAccountForGlobalIdArg } from "../acting-account.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..6611a26b8 --- /dev/null +++ b/graphql/post/article.ts @@ -0,0 +1,586 @@ +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 }), + 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"), + }), +}); + +/** + * 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..f25c3db44 --- /dev/null +++ b/graphql/post/core.ts @@ -0,0 +1,2369 @@ +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 { + 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 DataLoader from "dataloader"; +import { + DESCENDANT_TREE_MAX_DEPTH, + getAncestorChain, + getDescendantPage, +} from "@hackerspub/models/thread"; +import type { Uuid } from "@hackerspub/models/uuid"; +import { isMediumOwner, isMediumUploadWindowActive } from "../medium-upload.ts"; +import { createGraphQLError } from "graphql-yoga"; +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 { 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, + 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, + }), + }), +}); + +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..cea2073c9 --- /dev/null +++ b/graphql/post/mutations.ts @@ -0,0 +1,2908 @@ +// Mutation and query registration for posts. +import { assertNever } from "@std/assert/unstable-never"; +import { and, eq } from "drizzle-orm"; +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 { + canActorRequestQuotePost, + getPostVisibilityFilter, + isPostVisibleTo, + normalizeQuotePolicyForVisibility, +} from "@hackerspub/models/post/visibility"; +import { deletePost } from "@hackerspub/models/post/lifecycle"; +import { revokeQuote as revokeQuoteModel } from "@hackerspub/models/post/engagement"; +import { sharePost, unsharePost } from "@hackerspub/models/post/sharing"; +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 { + 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 { getActorById } from "../actor.ts"; +import { builder } from "../builder.ts"; +import { + ActorSuspendedError, + InvalidInputError, + NotAuthorizedError, +} from "../error.ts"; +import { lookupPostByUrl, parseHttpUrl } from "../lookup.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 } }, + }, + 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, + }), + }), + }, +); 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/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.ts b/models/link-preview.ts index a57d03c8c..9384cb03d 100644 --- a/models/link-preview.ts +++ b/models/link-preview.ts @@ -1,10 +1,32 @@ +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 { + type NewPostLink, + type PostLink, + postLinkTable, + postTable, +} from "./schema.ts"; +import { + getRemoteFetchSignal, + readResponseBytesAtMost, +} from "./post/remote-fetch.ts"; import { generateUuidV7, type Uuid } from "./uuid.ts"; +const logger = getLogger(["hackerspub", "models", "link-preview"]); +const SCRAPE_IMAGE_METADATA_BYTES_LIMIT = 128 * 1024; + export interface RepairBrokenLinkPreviewsResult { readonly brokenLinks: number; readonly repairedPosts: number; @@ -164,3 +186,375 @@ 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 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]; +} 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..eaac1c50d 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({ 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..14a3be08f --- /dev/null +++ b/models/post/lifecycle.ts @@ -0,0 +1,195 @@ +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 }); + } + // 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)], + }, + ); +} 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..d6aef0d03 --- /dev/null +++ b/models/post/remote.ts @@ -0,0 +1,1230 @@ +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 + ](); + 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 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..40c2d728d --- /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 { assertAccountActorNotSuspended } from "../moderation.ts"; +import { syncActorFromAccount, toRecipient } from "../actor.ts"; +import type { ApplicationContext } from "../context.ts"; +import type { Database } from "../db.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..18626bc60 --- /dev/null +++ b/models/post/source.ts @@ -0,0 +1,436 @@ +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 { 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 { persistPostLink } from "../link-preview.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..b15792e53 --- /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 === 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">; +} 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, From 5e0e1f0814f3d9a270ddaf882fa79c360e718c26 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 14 Jul 2026 02:35:24 +0900 Subject: [PATCH 02/15] Harden link preview fetching Validate every preview URL in a manually followed redirect chain before issuing the next request. Use portable response body APIs and reuse the redirect destination recorded by posts that share the same authored URL. https://github.com/hackers-pub/hackerspub/pull/357#discussion_r3572560989 https://github.com/hackers-pub/hackerspub/pull/357#discussion_r3572561040 https://github.com/hackers-pub/hackerspub/pull/357#discussion_r3572621474 https://github.com/hackers-pub/hackerspub/pull/357#discussion_r3572621488 Assisted-by: Codex:gpt-5.6-sol --- models/link-preview.test.ts | 34 ++++++++++++- models/link-preview.ts | 98 ++++++++++++++++++++++++++++++------- models/post.test.ts | 84 +++++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 20 deletions(-) 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 9384cb03d..29081fa21 100644 --- a/models/link-preview.ts +++ b/models/link-preview.ts @@ -26,6 +26,60 @@ 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; @@ -201,14 +255,13 @@ export async function scrapePostLink( } let response: Response; try { - response = await fetch(url, { + response = await fetchWithSafeRedirects(url, { headers: { "User-Agent": getUserAgent({ software: "HackersPub", url: new URL(fedCtx.canonicalOrigin), }), }, - redirect: "follow", signal: getRemoteFetchSignal(options.signal), }); } catch (error) { @@ -238,7 +291,7 @@ export async function scrapePostLink( contentType === "application/pdf" || contentType === "application/x-pdf" ) { try { - const pdf = await PDFDocument.load(await response.bytes(), { + const pdf = await PDFDocument.load(await response.arrayBuffer(), { updateMetadata: false, }); return { @@ -275,7 +328,7 @@ export async function scrapePostLink( let charset = contentTypeParams.charset?.toLowerCase(); let bytes: Uint8Array; try { - bytes = await response.bytes(); + bytes = new Uint8Array(await response.arrayBuffer()); } catch (error) { lg.warn("Failed to read body from {url}: {error}", { url: responseUrl, @@ -379,11 +432,7 @@ export async function scrapePostLink( 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}`, - ); - } + assertSafeRemoteUrl(imageUrl); image.imageUrl = imageUrl.href; } catch (error) { lg.warn("Ignoring invalid preview image URL for {url}: {error}", { @@ -391,11 +440,7 @@ export async function scrapePostLink( imageUrl: image.imageUrl, error, }); - image.imageUrl = undefined; - image.imageAlt = undefined; - image.imageType = undefined; - image.imageWidth = undefined; - image.imageHeight = undefined; + clearPreviewImage(image); } } if ( @@ -403,7 +448,7 @@ export async function scrapePostLink( (image.imageWidth == null || image.imageHeight == null) ) { try { - const response = await fetch(image.imageUrl, { + const response = await fetchWithSafeRedirects(new URL(image.imageUrl), { headers: { "User-Agent": getUserAgent({ software: "HackersPub", @@ -413,7 +458,6 @@ export async function scrapePostLink( "Range": `bytes=0-${SCRAPE_IMAGE_METADATA_BYTES_LIMIT - 1}`, "Referer": responseUrl, }, - redirect: "follow", signal: getRemoteFetchSignal(options.signal), }); logger.debug("Fetched image {url}: {status} {statusText}", { @@ -451,8 +495,12 @@ export async function scrapePostLink( "Failed to fetch image {url}: {error}", { url: image.imageUrl, error }, ); - image.imageWidth = undefined; - image.imageHeight = undefined; + if (error instanceof UnsafeRemoteUrlError) { + clearPreviewImage(image); + } else { + image.imageWidth = undefined; + image.imageHeight = undefined; + } } } const creatorHandle = result.customMetaTags?.fediverseCreator == null @@ -501,9 +549,21 @@ export async function persistPostLink( const scrapeUrl = new URL(url); scrapeUrl.hash = ""; const { db } = ctx; - const link = await db.query.postLinkTable.findFirst({ + 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 ( diff --git a/models/post.test.ts b/models/post.test.ts index eaac1c50d..545359358 100644 --- a/models/post.test.ts +++ b/models/post.test.ts @@ -137,6 +137,90 @@ test("scrapePostLink() keeps image URL when metadata probing fails", async () => resetGlobalFetch(); }); +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", { From 12974978210cbae0cdb969f6d860a78ca9a29808 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 14 Jul 2026 02:35:59 +0900 Subject: [PATCH 03/15] Fail closed on missing shared posts Treat both unloaded and missing shared-post relations as insufficient to evaluate a boost wrapper. This prevents a dangling relation from bypassing the wrapper visibility guard. https://github.com/hackers-pub/hackerspub/pull/357#discussion_r3572561047 Assisted-by: Codex:gpt-5.6-sol --- models/post.test.ts | 12 ++++++++++++ models/post/visibility.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/models/post.test.ts b/models/post.test.ts index 545359358..a71a486c1 100644 --- a/models/post.test.ts +++ b/models/post.test.ts @@ -510,4 +510,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/visibility.ts b/models/post/visibility.ts index b15792e53..e2d9dec4a 100644 --- a/models/post/visibility.ts +++ b/models/post/visibility.ts @@ -224,7 +224,7 @@ export function isPostVisibleTo( // 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; + 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 From 3dc3bee0c9678b828891227818f6fa10f1d0d560 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 14 Jul 2026 02:36:15 +0900 Subject: [PATCH 04/15] Remove unreachable quote count loop Delete the query and update loop that ran after both the quoted post and its quoting rows had already been removed. The subsequent interaction processing already handles the affected news score inputs. https://github.com/hackers-pub/hackerspub/pull/357#discussion_r3572561056 Assisted-by: Codex:gpt-5.6-sol --- models/post/lifecycle.ts | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/models/post/lifecycle.ts b/models/post/lifecycle.ts index 14a3be08f..260dde0ff 100644 --- a/models/post/lifecycle.ts +++ b/models/post/lifecycle.ts @@ -35,13 +35,6 @@ export async function deletePost( 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), @@ -83,19 +76,6 @@ export async function deletePost( } } - // 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 From d8862e8d06ff41b91335dd4e7add0e8f7215d36e Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 14 Jul 2026 02:53:27 +0900 Subject: [PATCH 05/15] Handle quoted link preview charsets Strip matching quotes from Content-Type charset values before decoding so valid quoted UTF-8 declarations follow the normal HTML parsing path. Cover the case through the existing Open Graph scraping test. https://github.com/hackers-pub/hackerspub/pull/357#discussion_r3572844046 Assisted-by: Codex:gpt-5.6-sol --- models/link-preview.ts | 4 +++- models/post.test.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/models/link-preview.ts b/models/link-preview.ts index 29081fa21..bc9843832 100644 --- a/models/link-preview.ts +++ b/models/link-preview.ts @@ -325,7 +325,9 @@ export async function scrapePostLink( pair as [string, string] ), ); - let charset = contentTypeParams.charset?.toLowerCase(); + let charset = contentTypeParams.charset + ?.replace(/^(["'])(.*)\1$/, "$2") + .toLowerCase(); let bytes: Uint8Array; try { bytes = new Uint8Array(await response.arrayBuffer()); diff --git a/models/post.test.ts b/models/post.test.ts index a71a486c1..5b63c7e3c 100644 --- a/models/post.test.ts +++ b/models/post.test.ts @@ -42,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: ` From 1c5fe59762491c6e18788f888ba2cdc28fd499f8 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 14 Jul 2026 02:53:37 +0900 Subject: [PATCH 06/15] Close inline reply iterators Always return the manually consumed collection iterator when inline reply traversal stops. This releases underlying collection resources on reply limits, time budgets, and traversal failures. https://github.com/hackers-pub/hackerspub/pull/357#discussion_r3572844079 Assisted-by: Codex:gpt-5.6-sol --- models/post/remote.ts | 76 +++++++++++++++++++++++-------------------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/models/post/remote.ts b/models/post/remote.ts index d6aef0d03..8f8cf1c4a 100644 --- a/models/post/remote.ts +++ b/models/post/remote.ts @@ -725,43 +725,47 @@ export async function persistPost( 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; + 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++; } - 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) From 9e3953cb8211cdc2f6a751015001111d7bebf3da Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 14 Jul 2026 02:53:49 +0900 Subject: [PATCH 07/15] Hydrate shared reply targets Load the original post and its viewer-specific relations before checking a share wrapper as a reply target. This keeps visibility checks fail-closed without rejecting valid replies to public shares in either mutation. https://github.com/hackers-pub/hackerspub/pull/357#discussion_r3572878062 Assisted-by: Codex:gpt-5.6-sol --- graphql/post.more.test.ts | 84 +++++++++++++++++++++++++++++++++++++++ graphql/post/mutations.ts | 40 +++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/graphql/post.more.test.ts b/graphql/post.more.test.ts index 50d526bd0..5ad134db5 100644 --- a/graphql/post.more.test.ts +++ b/graphql/post.more.test.ts @@ -2675,6 +2675,90 @@ 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 rejects invisible reply and quote targets", async () => { await withRollback(async (tx) => { const author = await insertAccountWithActor(tx, { diff --git a/graphql/post/mutations.ts b/graphql/post/mutations.ts index cea2073c9..3ba2f2520 100644 --- a/graphql/post/mutations.ts +++ b/graphql/post/mutations.ts @@ -256,6 +256,26 @@ builder.relayMutationField( }, }, 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 }, }); @@ -559,6 +579,26 @@ builder.relayMutationField( }, }, 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 }, }); From 08849c8a67b0d73b76cc30f716042b86ed886ee7 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 14 Jul 2026 09:21:57 +0900 Subject: [PATCH 08/15] Document post schema fields Explain locale filtering, translation progress, visibility, and quote state semantics in the public GraphQL contract. https://github.com/hackers-pub/hackerspub/pull/357#discussion_r3572963812 https://github.com/hackers-pub/hackerspub/pull/357#discussion_r3572963817 Assisted-by: Codex:gpt-5.6-sol --- graphql/post/article.ts | 11 +++++++- graphql/post/core.ts | 12 +++++++++ graphql/schema.graphql | 56 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/graphql/post/article.ts b/graphql/post/article.ts index 6611a26b8..418b28a17 100644 --- a/graphql/post/article.ts +++ b/graphql/post/article.ts @@ -129,11 +129,20 @@ export const Article = builder.drizzleNode("postTable", { "a moderation sanction, and the viewer is neither " + "its author nor a moderator.", args: { - language: t.arg({ type: "Locale", required: false }), + 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) => ({ diff --git a/graphql/post/core.ts b/graphql/post/core.ts index f25c3db44..5d0b446da 100644 --- a/graphql/post/core.ts +++ b/graphql/post/core.ts @@ -485,6 +485,10 @@ export const Post = builder.drizzleInterface("postTable", { }), 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 }, }, @@ -494,6 +498,10 @@ export const Post = builder.drizzleInterface("postTable", { }), 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 }, }, @@ -504,6 +512,10 @@ export const Post = builder.drizzleInterface("postTable", { 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 }, }, 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 """ From d39ead0919f1601efcc72f7a01660d4f1b33cb8e Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 14 Jul 2026 09:22:07 +0900 Subject: [PATCH 09/15] Sort post sharing imports Keep internal relative imports ordered by module path so the extracted sharing module follows the repository import convention. https://github.com/hackers-pub/hackerspub/pull/357#discussion_r3572963823 Assisted-by: Codex:gpt-5.6-sol --- models/post/sharing.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models/post/sharing.ts b/models/post/sharing.ts index 40c2d728d..9016f367b 100644 --- a/models/post/sharing.ts +++ b/models/post/sharing.ts @@ -1,10 +1,10 @@ import * as vocab from "@fedify/vocab"; import { getLogger } from "@logtape/logtape"; import { and, eq, inArray } from "drizzle-orm"; -import { assertAccountActorNotSuspended } from "../moderation.ts"; 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, From f5a18e131051c1136bf0eb3c0fd0ca2878398b03 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 14 Jul 2026 11:55:28 +0900 Subject: [PATCH 10/15] Close failed image response bodies Cancel unsuccessful image metadata response streams so failed probes do not leave network resources open. Cover the cleanup path with a stream whose cancellation is observable. https://github.com/hackers-pub/hackerspub/pull/357#discussion_r3575216583 Assisted-by: Codex:gpt-5.6-sol --- models/link-preview.ts | 2 ++ models/post.test.ts | 44 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/models/link-preview.ts b/models/link-preview.ts index bc9843832..a3a8ba73b 100644 --- a/models/link-preview.ts +++ b/models/link-preview.ts @@ -491,6 +491,8 @@ export async function scrapePostLink( image.imageWidth = undefined; image.imageHeight = undefined; } + } else { + await response.body?.cancel().catch(() => {}); } } catch (error) { logger.debug( diff --git a/models/post.test.ts b/models/post.test.ts index 5b63c7e3c..c62850212 100644 --- a/models/post.test.ts +++ b/models/post.test.ts @@ -137,6 +137,50 @@ 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[] = []; From ca07af9f19d099810c3b93c89ca3a53512f404f9 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 14 Jul 2026 12:12:46 +0900 Subject: [PATCH 11/15] Validate shared reply targets Check both a share wrapper and its effective original before accepting it as a reply target. This prevents public wrappers from exposing replies to followers-only or direct posts while retaining replies to public shares. Add one regression test that covers both note and question creation paths. https://github.com/hackers-pub/hackerspub/pull/357#discussion_r3575756429 Assisted-by: Codex:gpt-5.6-sol --- graphql/post.more.test.ts | 84 +++++++++++++++++++++++++++++++++++++++ graphql/post/mutations.ts | 14 ++++++- 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/graphql/post.more.test.ts b/graphql/post.more.test.ts index 5ad134db5..f9eb77b1b 100644 --- a/graphql/post.more.test.ts +++ b/graphql/post.more.test.ts @@ -2759,6 +2759,90 @@ test("createNote and createQuestion allow replies to public shares", async () => }); }); +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/mutations.ts b/graphql/post/mutations.ts index 3ba2f2520..3367ec3bf 100644 --- a/graphql/post/mutations.ts +++ b/graphql/post/mutations.ts @@ -279,8 +279,13 @@ builder.relayMutationField( }, where: { id: replyTargetId.id }, }); + if (post == null) { + throw new InvalidInputError("replyTargetId"); + } + const effectivePost = post.sharedPost ?? post; if ( - post == null || !isPostVisibleTo(post, actingAccount.account.actor) + !isPostVisibleTo(post, actingAccount.account.actor) || + !isPostVisibleTo(effectivePost, actingAccount.account.actor) ) { throw new InvalidInputError("replyTargetId"); } @@ -602,8 +607,13 @@ builder.relayMutationField( }, where: { id: replyTargetId.id }, }); + if (post == null) { + throw new InvalidInputError("replyTargetId"); + } + const effectivePost = post.sharedPost ?? post; if ( - post == null || !isPostVisibleTo(post, actingAccount.account.actor) + !isPostVisibleTo(post, actingAccount.account.actor) || + !isPostVisibleTo(effectivePost, actingAccount.account.actor) ) { throw new InvalidInputError("replyTargetId"); } From 4838bac3d6bb6e46af7424cc8298709f37e5aaad Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 14 Jul 2026 12:13:16 +0900 Subject: [PATCH 12/15] Group post resolver imports Keep third-party dependencies ahead of workspace and relative imports in the split post resolver modules. Also order the moved imports within their local groups so each module follows the repository convention. https://github.com/hackers-pub/hackerspub/pull/357#discussion_r3575732308 https://github.com/hackers-pub/hackerspub/pull/357#discussion_r3575732311 https://github.com/hackers-pub/hackerspub/pull/357#discussion_r3575732316 Assisted-by: Codex:gpt-5.6-sol --- graphql/post/actor-fields.ts | 4 ++-- graphql/post/core.ts | 8 ++++---- graphql/post/mutations.ts | 26 +++++++++++++------------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/graphql/post/actor-fields.ts b/graphql/post/actor-fields.ts index de31357ce..c1e56747b 100644 --- a/graphql/post/actor-fields.ts +++ b/graphql/post/actor-fields.ts @@ -1,3 +1,4 @@ +import { resolveOffsetConnection } from "@pothos/plugin-relay"; import { getCensoredPostExclusionFilter, getPostVisibilityFilter, @@ -7,7 +8,7 @@ import { getProfileInteractions, } from "@hackerspub/models/profile-interactions"; import { validateUuid } from "@hackerspub/models/uuid"; -import { resolveOffsetConnection } from "@pothos/plugin-relay"; +import { resolveActingAccountForGlobalIdArg } from "../acting-account.ts"; import { Actor, authenticationRequired, @@ -18,7 +19,6 @@ import { parseRequiredTimelineCursor, pinConnectionHelpers, } from "../actor.ts"; -import { resolveActingAccountForGlobalIdArg } from "../acting-account.ts"; import { builder } from "../builder.ts"; import { actingAccountIdArgDescription, diff --git a/graphql/post/core.ts b/graphql/post/core.ts index 5d0b446da..34172c322 100644 --- a/graphql/post/core.ts +++ b/graphql/post/core.ts @@ -1,5 +1,4 @@ import * as vocab from "@fedify/vocab"; -import { generateAltText } from "@hackerspub/ai/alttext"; import { getLogger } from "@logtape/logtape"; import { drizzleConnectionHelpers } from "@pothos/plugin-drizzle"; import { @@ -8,7 +7,10 @@ import { } 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, @@ -35,15 +37,12 @@ import { } from "@hackerspub/models/post/visibility"; import { actorTable, 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 type { Uuid } from "@hackerspub/models/uuid"; -import { isMediumOwner, isMediumUploadWindowActive } from "../medium-upload.ts"; -import { createGraphQLError } from "graphql-yoga"; import { Account } from "../account.ts"; import { resolveActingAccountForMutation } from "../acting-account.ts"; import { @@ -55,6 +54,7 @@ import { } 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, diff --git a/graphql/post/mutations.ts b/graphql/post/mutations.ts index 3367ec3bf..4f2b845bd 100644 --- a/graphql/post/mutations.ts +++ b/graphql/post/mutations.ts @@ -1,6 +1,7 @@ // 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, @@ -34,15 +35,15 @@ 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 { deletePost } from "@hackerspub/models/post/lifecycle"; -import { revokeQuote as revokeQuoteModel } from "@hackerspub/models/post/engagement"; -import { sharePost, unsharePost } from "@hackerspub/models/post/sharing"; import { InvalidPollInputError } from "@hackerspub/models/poll"; import { createQuestion } from "@hackerspub/models/question"; import { react, undoReaction } from "@hackerspub/models/reaction"; @@ -58,16 +59,6 @@ import { 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, @@ -81,6 +72,15 @@ import { 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"; From d81425dd997d72623edbfb5719daabf9dea6b7b6 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 14 Jul 2026 12:51:00 +0900 Subject: [PATCH 13/15] Sort internal model imports Place nested post imports alongside the other internal modules in lexical order. This keeps the extracted model files aligned with the repository's import grouping convention without changing runtime behavior. https://github.com/hackers-pub/hackerspub/pull/357#discussion_r3575799899 https://github.com/hackers-pub/hackerspub/pull/357#discussion_r3575799901 Assisted-by: Codex:gpt-5.6-sol --- models/link-preview.ts | 8 ++++---- models/post/source.ts | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/models/link-preview.ts b/models/link-preview.ts index a3a8ba73b..a77868b1a 100644 --- a/models/link-preview.ts +++ b/models/link-preview.ts @@ -12,16 +12,16 @@ import type { ApplicationContext } from "./context.ts"; import type { Database } from "./db.ts"; import { extractExternalLinks } from "./html.ts"; import { recomputeNewsScores } from "./news.ts"; +import { + getRemoteFetchSignal, + readResponseBytesAtMost, +} from "./post/remote-fetch.ts"; import { type NewPostLink, type PostLink, postLinkTable, postTable, } from "./schema.ts"; -import { - getRemoteFetchSignal, - readResponseBytesAtMost, -} from "./post/remote-fetch.ts"; import { generateUuidV7, type Uuid } from "./uuid.ts"; const logger = getLogger(["hackerspub", "models", "link-preview"]); diff --git a/models/post/source.ts b/models/post/source.ts index 18626bc60..17f31dd94 100644 --- a/models/post/source.ts +++ b/models/post/source.ts @@ -8,6 +8,7 @@ import { } 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"; @@ -34,8 +35,6 @@ import { quoteAuthorizationTable, } from "../schema.ts"; import { generateUuidV7 } from "../uuid.ts"; - -import { persistPostLink } from "../link-preview.ts"; import { updateQuotesCount } from "./engagement.ts"; import { createTargetPostUpdatedNotifications, From 2d9b5c4b5104f96dba48364cd0943bf53ccada9f Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 14 Jul 2026 13:15:16 +0900 Subject: [PATCH 14/15] Hydrate article share targets Load each boost's original post author before applying the shared visibility guard. The article itself is already constrained and visible, while the relation lets the guard evaluate its sanction state instead of failing closed. https://github.com/hackers-pub/hackerspub/pull/357#discussion_r3575925453 Assisted-by: Codex:gpt-5.6-sol --- web/routes/@[username]/[idOrYear]/[slug]/shares.tsx | 4 ++++ 1 file changed, 4 insertions(+) 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: [ From 491cb13ae3e4ca5a5509888d0f42ef6f840d05e3 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 14 Jul 2026 13:15:52 +0900 Subject: [PATCH 15/15] Sort subscribe handler imports Group third-party, workspace, and relative dependencies in lexical order. This makes the extracted post module imports follow the repository style without changing inbox behavior. https://github.com/hackers-pub/hackerspub/pull/357#discussion_r3575914015 Assisted-by: Codex:gpt-5.6-sol --- federation/inbox/subscribe.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/federation/inbox/subscribe.ts b/federation/inbox/subscribe.ts index e7917c83c..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,6 +29,8 @@ import { createShareNotification, deleteShareNotification, } from "@hackerspub/models/notification"; +import { isPostObject } from "@hackerspub/models/post/core"; +import { updateRepliesCount } from "@hackerspub/models/post/engagement"; import { deletePersistedPost, deleteSharedPost, @@ -37,8 +40,6 @@ import { REMOTE_FETCH_TIMEOUT_MS, withDocumentLoaderTimeout, } from "@hackerspub/models/post/remote"; -import { isPostObject } from "@hackerspub/models/post/core"; -import { updateRepliesCount } from "@hackerspub/models/post/engagement"; 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"]);