diff --git a/CHANGES.md b/CHANGES.md index 1598d2d8..815fa5a1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,14 +8,14 @@ To be released. - Upgraded Fedify to 2.4.1. - - Quotes awaiting FEP-044f approval now expose `quoteUrl` for compatibility + - Quotes awaiting [FEP-044f] approval now expose `quoteUrl` for compatibility with older software when the original public or unlisted post explicitly allows automatic approval for everyone. The `quote` field and generated fallback still wait for approval. Rejection or revocation removes the reference through an `Update`; failed rejection updates can be retried without changing the quote state or counts. [[#602]] - - FEP-044f quote authorization now uses Fedify's + - [FEP-044f] quote authorization now uses Fedify's *@fedify/interaction-controls* package to build and verify quote requests and authorizations and to evaluate local quote policies. Verification is stricter in a few cases: [[#635], [#641]] @@ -33,6 +33,14 @@ To be released. - Outgoing `Accept` and `Reject` responses to quote requests now have explicit IDs and address the requester in `to`. + - Remote quotes without [FEP-044f] approval are now accepted when the local + target is public or unlisted, allows automatic quotes by everyone + (including the default policy), and neither account blocks the other. + Accepted quotes have a local authorization and appear in quote counts + and notifications. Cached unauthorized quotes are reevaluated on later + updates; there is no backfill. Revoked quotes retain their state on + updates for the same target, including remote targets. [[#640], [#660]] + - Added WebP (`image/webp`) as an accepted format for profile avatar and banner image uploads. Previously only JPEG, PNG, and GIF were accepted by both the Mastodon-compatible @@ -124,6 +132,7 @@ To be released. 14 adds post-quantum ML-DSA passkey support on runtimes that provide the algorithms. [[GHSA-2g3p-m8c9-hhwh], [GHSA-j3h4-m3m2-7p7j]] +[FEP-044f]: https://w3id.org/fep/044f [FEP-c0e0]: https://w3id.org/fep/c0e0 [Gukhanmun]: https://gukhanmun.org/ [RFC 6749]: https://datatracker.ietf.org/doc/html/rfc6749#section-3.3 @@ -139,12 +148,14 @@ To be released. [#637]: https://github.com/fedify-dev/hollo/issues/637 [#638]: https://github.com/fedify-dev/hollo/issues/638 [#639]: https://github.com/fedify-dev/hollo/issues/639 +[#640]: https://github.com/fedify-dev/hollo/issues/640 [#641]: https://github.com/fedify-dev/hollo/pull/641 [#645]: https://github.com/fedify-dev/hollo/pull/645 [#646]: https://github.com/fedify-dev/hollo/issues/646 [#648]: https://github.com/fedify-dev/hollo/pull/648 [#649]: https://github.com/fedify-dev/hollo/pull/649 [#650]: https://github.com/fedify-dev/hollo/pull/650 +[#660]: https://github.com/fedify-dev/hollo/pull/660 Version 0.9.22 @@ -926,7 +937,6 @@ Released on May 20, 2026. - Added Traditional Chinese (繁體中文; `zh-TW`) documentation. [Split-domain WebFinger guide]: https://docs.hollo.social/install/split-domain/ -[FEP-044f]: https://w3id.org/fep/044f [logfmt]: https://brandur.org/logfmt [@hollo@hollo.social]: https://hollo.social/@hollo [#67]: https://github.com/fedify-dev/hollo/issues/67 diff --git a/src/api/v1/statuses.test.ts b/src/api/v1/statuses.test.ts index 4447c261..ae49f598 100644 --- a/src/api/v1/statuses.test.ts +++ b/src/api/v1/statuses.test.ts @@ -1,3 +1,5 @@ +import type { InboxContext } from "@fedify/fedify"; +import { Create, Note, Person, PUBLIC_COLLECTION, Update } from "@fedify/vocab"; import { eq } from "drizzle-orm"; import { afterAll, @@ -20,6 +22,7 @@ import { } from "../../../tests/helpers/oauth"; import db from "../../db"; import { federation } from "../../federation"; +import { onPostCreated, onPostUpdated } from "../../federation/inbox"; import app from "../../index"; import { accountOwners, @@ -936,6 +939,118 @@ describe("/api/v1/statuses quotes", { concurrent: false }, () => { }); } + it("exposes an impolite quote and preserves API revocation through a remote Update", async () => { + const targetResponse = await createStatus(authorToken, { + status: "Quote this", + quote_approval_policy: "public", + }); + const target = await targetResponse.json(); + const remoteId = uuidv7(); + const remoteIri = "https://remote.test/users/impolite-quoter"; + const quoteIri = "https://remote.test/notes/impolite"; + await db + .insert(instances) + .values({ host: "remote.test" }) + .onConflictDoNothing(); + await db.insert(accounts).values({ + id: remoteId, + iri: remoteIri, + instanceHost: "remote.test", + type: "Person", + name: "Impolite quoter", + handle: "@impolite-quoter@remote.test", + bioHtml: "", + protected: false, + inboxUrl: `${remoteIri}/inbox`, + published: new Date(), + }); + function note() { + return new Note({ + id: new URL(quoteIri), + attribution: new Person({ + id: new URL(remoteIri), + preferredUsername: "impolite-quoter", + inbox: new URL(`${remoteIri}/inbox`), + }), + quoteUrl: new URL(target.uri), + to: PUBLIC_COLLECTION, + content: "
Remote quote
", + }); + } + const ctx = federation.createContext( + new URL("https://hollo.test"), + undefined, + ) as InboxContextOriginal
", + published: new Date(), + }); + return { id, iri, accountId: owner.id as Uuid }; + } + + function quoteNote( + quoter: ParametersQuote
", + ...overrides, + }); + } + + it.each([ + ["public", "public"], + ["public", "unlisted"], + [null, "public"], + [null, "unlisted"], + ] as const)( + "accepts %s policy on %s local targets", + async (policy, visibility) => { + const target = await seedTarget(policy, visibility); + const quoter = await seedRemoteAccount("impolite-quoter"); + const quote = await persistPost( + db, + quoteNote(quoter, target.iri), + "https://hollo.test", + ); + expect(quote?.quoteState).toBe("accepted"); + expect(quote?.quoteTargetId).toBe(target.id); + expect(quote?.quoteAuthorizationIri).toBe( + getQuoteAuthorizationIri(target, quote!), + ); + expect( + (await db.query.posts.findFirst({ where: { id: { eq: target.id } } })) + ?.quotesCount, + ).toBe(1); + + const edited = await persistPost( + db, + quoteNote(quoter, target.iri, { content: "Edited
" }), + "https://hollo.test", + ); + expect(edited?.id).toBe(quote?.id); + expect(edited?.quoteAuthorizationIri).toBe(quote?.quoteAuthorizationIri); + }, + ); + + it.each([ + ["followers", "public"], + ["nobody", "public"], + ["public", "private"], + ["public", "direct"], + [null, "private"], + [null, "direct"], + ] as const)( + "does not accept %s policy on %s targets without approval", + async (policy, visibility) => { + const target = await seedTarget(policy, visibility); + const quoter = await seedRemoteAccount("impolite-quoter"); + await db.insert(follows).values({ + iri: `${quoter.iri}#follow`, + followingId: target.accountId, + followerId: quoter.id, + approved: new Date(), + }); + const quote = await persistPost( + db, + quoteNote(quoter, target.iri), + "https://hollo.test", + ); + expect(quote?.quoteState).toBe("unauthorized"); + expect(quote?.quoteAuthorizationIri).toBeNull(); + expect( + (await db.query.posts.findFirst({ where: { id: { eq: target.id } } })) + ?.quotesCount, + ).toBe(0); + }, + ); + + it.each([null, "public"] as const)( + "does not approve a local boost wrapper with %s policy", + async (policy) => { + const target = await seedTarget(policy); + const originalAuthor = await seedRemoteAccount("original-author"); + const originalId = crypto.randomUUID() as Uuid; + await db.insert(posts).values({ + id: originalId, + iri: "https://remote.test/objects/boosted-original", + type: "Note", + accountId: originalAuthor.id, + visibility: "public", + quoteApprovalPolicy: policy, + contentHtml: "Remote original
", + published: new Date(), + }); + await db + .update(posts) + .set({ sharingId: originalId }) + .where(eq(posts.id, target.id)); + const quoter = await seedRemoteAccount("impolite-quoter"); + const quote = await persistPost( + db, + quoteNote(quoter, target.iri), + "https://hollo.test", + ); + expect(quote?.quoteState).toBe("unauthorized"); + expect(quote?.quoteAuthorizationIri).toBeNull(); + expect( + (await db.query.posts.findFirst({ where: { id: { eq: target.id } } })) + ?.quotesCount, + ).toBe(0); + expect(await db.query.notifications.findMany()).toHaveLength(0); + }, + ); + + it.each(["author", "quoter"])( + "does not accept when the %s blocks the other account", + async (blocker) => { + const target = await seedTarget(); + const quoter = await seedRemoteAccount("impolite-quoter"); + await db.insert(blocks).values({ + accountId: blocker === "author" ? target.accountId : quoter.id, + blockedAccountId: blocker === "author" ? quoter.id : target.accountId, + }); + const quote = await persistPost( + db, + quoteNote(quoter, target.iri), + "https://hollo.test", + ); + expect(quote?.quoteState).toBe("unauthorized"); + expect(quote?.quoteAuthorizationIri).toBeNull(); + }, + ); + + it("does not grant an authorization to a quote hosted on another actor's origin", async () => { + const target = await seedTarget(); + const quoter = await seedRemoteAccount("impolite-quoter"); + const quote = await persistPost( + db, + quoteNote(quoter, target.iri, { id: new URL("https://evil.test/quote") }), + "https://hollo.test", + ); + expect(quote?.quoteState).toBe("unauthorized"); + expect(quote?.quoteAuthorizationIri).toBeNull(); + }); + + // Wire shapes modeled on Misskey's ApRendererService and contexts.ts, + // rather than captured live traffic. Test each fallback independently. + it.each(["_misskey_quote", "quoteUrl", "quote", "tag"])( + "accepts the %s wire representation", + async (field) => { + const target = await seedTarget(); + const quoter = await seedRemoteAccount("impolite-quoter"); + const note = await Note.fromJsonLd({ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + _misskey_quote: "https://misskey-hub.net/ns#_misskey_quote", + quoteUrl: "as:quoteUrl", + quote: { "@id": "https://w3id.org/fep/044f#quote", "@type": "@id" }, + }, + ], + id: "https://remote.test/objects/impolite-quote", + type: "Note", + attributedTo: await createPerson(quoter).toJsonLd(), + to: PUBLIC_COLLECTION.href, + content: "Quote
", + [field]: + field === "tag" + ? [ + { + type: "Link", + mediaType: "application/activity+json", + href: target.iri, + }, + ] + : target.iri, + }); + const quote = await persistPost(db, note, "https://hollo.test"); + expect(quote?.quoteState).toBe("accepted"); + expect(quote?.quoteTargetId).toBe(target.id); + }, + ); + + it("uses the actual row id for authorizations after concurrent first persists", async () => { + const target = await seedTarget(); + const quoter = await seedRemoteAccount("impolite-quoter"); + const results = await Promise.all([ + persistPost(db, quoteNote(quoter, target.iri), "https://hollo.test"), + persistPost(db, quoteNote(quoter, target.iri), "https://hollo.test"), + ]); + const quote = await db.query.posts.findFirst({ + where: { iri: { eq: "https://remote.test/objects/impolite-quote" } }, + }); + expect(quote?.quoteState).toBe("accepted"); + expect(results[0]?.id).toBe(results[1]?.id); + expect(quote?.quoteAuthorizationIri).toBe( + getQuoteAuthorizationIri(target, quote!), + ); + const response = await federation.fetch( + new Request(quote!.quoteAuthorizationIri!, { + headers: { Accept: "application/activity+json" }, + }), + { contextData: undefined }, + ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + type: "QuoteAuthorization", + interactingObject: quote?.iri, + interactionTarget: target.iri, + attributedTo: "https://hollo.test/@impolite-author", + }); + }); + + it.each([false, true])( + "preserves revocation during an update with a stamp: %s", + async (withStamp) => { + const target = await seedTarget(); + const quoter = await seedRemoteAccount("impolite-quoter"); + const quote = await persistPost( + db, + quoteNote(quoter, target.iri), + "https://hollo.test", + ); + await db + .update(posts) + .set({ quoteState: "revoked", quoteAuthorizationIri: null }) + .where(eq(posts.id, quote!.id)); + await db + .update(posts) + .set({ quotesCount: 0 }) + .where(eq(posts.id, target.id)); + const loader = vi.fn(async () => { + throw new Error("Revoked stamps must not be fetched"); + }); + const updated = await persistPost( + db, + quoteNote(quoter, target.iri, { + content: "Edited after revocation
", + quoteAuthorization: withStamp + ? new URL(quote!.quoteAuthorizationIri!) + : null, + }), + "https://hollo.test", + { documentLoader: loader }, + ); + expect(updated?.quoteState).toBe("revoked"); + expect(updated?.quoteAuthorizationIri).toBeNull(); + expect(updated?.contentHtml).toBe("Edited after revocation
"); + expect(loader).not.toHaveBeenCalled(); + expect( + (await db.query.posts.findFirst({ where: { id: { eq: target.id } } })) + ?.quotesCount, + ).toBe(0); + }, + ); + + it("does not carry revocation to a different quote target", async () => { + const target = await seedTarget(); + const quoter = await seedRemoteAccount("impolite-quoter"); + const quote = await persistPost( + db, + quoteNote(quoter, target.iri), + "https://hollo.test", + ); + await db + .update(posts) + .set({ quoteState: "revoked", quoteAuthorizationIri: null }) + .where(eq(posts.id, quote!.id)); + const nextId = crypto.randomUUID() as Uuid; + const nextIri = `https://hollo.test/@impolite-author/${nextId}`; + await db.insert(posts).values({ + id: nextId, + iri: nextIri, + accountId: target.accountId, + type: "Note", + visibility: "unlisted", + quoteApprovalPolicy: "public", + published: new Date(), + }); + const updated = await persistPost( + db, + quoteNote(quoter, nextIri), + "https://hollo.test", + ); + expect(updated?.quoteState).toBe("accepted"); + expect(updated?.quoteAuthorizationIri).toBe( + getQuoteAuthorizationIri({ iri: nextIri }, quote!), + ); + expect( + (await db.query.posts.findFirst({ where: { id: { eq: target.id } } })) + ?.quotesCount, + ).toBe(0); + expect( + (await db.query.posts.findFirst({ where: { id: { eq: nextId } } })) + ?.quotesCount, + ).toBe(1); + }); + + it("counts an impolite quote discovered through an Announce", async () => { + const target = await seedTarget(); + const quoter = await seedRemoteAccount("impolite-quoter"); + const booster = await seedRemoteAccount("impolite-booster"); + const boost = await persistSharingPost( + db, + createAnnounce( + "https://remote.test/announces/impolite", + createPerson(booster), + quoteNote(quoter, target.iri), + ), + quoteNote(quoter, target.iri), + "https://hollo.test", + ); + expect(boost?.sharingId).not.toBeNull(); + expect( + (await db.query.posts.findFirst({ where: { id: { eq: target.id } } })) + ?.quotesCount, + ).toBe(1); + }); + + it("preserves revoked remote quotes without applying their public policy", async () => { + const author = await seedRemoteAccount("remote-author"); + const quoter = await seedRemoteAccount("impolite-quoter"); + const targetId = crypto.randomUUID() as Uuid; + const targetIri = "https://remote.test/objects/remote-target"; + await db.insert(posts).values({ + id: targetId, + iri: targetIri, + accountId: author.id, + type: "Note", + visibility: "public", + quoteApprovalPolicy: "public", + published: new Date(), + }); + const quote = await persistPost( + db, + quoteNote(quoter, targetIri), + "https://hollo.test", + ); + expect(quote?.quoteState).toBe("unauthorized"); + await db + .update(posts) + .set({ quoteState: "revoked" }) + .where(eq(posts.id, quote!.id)); + const updated = await persistPost( + db, + quoteNote(quoter, targetIri), + "https://hollo.test", + ); + expect(updated?.quoteState).toBe("revoked"); + expect(updated?.quoteAuthorizationIri).toBeNull(); + }); + + it("keeps a revocation committed while a stamp is being verified", async () => { + const target = await seedTarget(); + const quoter = await seedRemoteAccount("impolite-quoter"); + const quote = await persistPost( + db, + quoteNote(quoter, target.iri), + "https://hollo.test", + ); + const authorization = new QuoteAuthorization({ + id: new URL(quote!.quoteAuthorizationIri!), + attribution: new URL("https://hollo.test/@impolite-author"), + interactingObject: new URL(quote!.iri), + interactionTarget: new URL(target.iri), + }); + const loader: DocumentLoader = async (url) => { + await db.transaction(async (tx) => { + await tx + .update(posts) + .set({ quoteState: "revoked", quoteAuthorizationIri: null }) + .where(eq(posts.id, quote!.id)); + await tx + .update(posts) + .set({ quotesCount: 0 }) + .where(eq(posts.id, target.id)); + }); + return { + documentUrl: url, + contextUrl: null, + document: await authorization.toJsonLd(), + }; + }; + const updated = await persistPost( + db, + quoteNote(quoter, target.iri, { + quoteAuthorization: new URL(quote!.quoteAuthorizationIri!), + }), + "https://hollo.test", + { documentLoader: loader }, + ); + expect(updated?.quoteState).toBe("revoked"); + expect(updated?.quoteAuthorizationIri).toBeNull(); + expect( + (await db.query.posts.findFirst({ where: { id: { eq: target.id } } })) + ?.quotesCount, + ).toBe(0); + }); +}); diff --git a/src/federation/post.ts b/src/federation/post.ts index 8612ed9f..dfdcea65 100644 --- a/src/federation/post.ts +++ b/src/federation/post.ts @@ -1,4 +1,5 @@ import type { Context } from "@fedify/fedify"; +import { quoteInteraction } from "@fedify/interaction-controls"; import * as vocab from "@fedify/vocab"; import { type Announce, @@ -36,6 +37,7 @@ import { extractPreviewLink } from "../html"; import { makeVideoScreenshot, type Thumbnail, uploadThumbnail } from "../media"; import { orderMedia } from "../media-order"; import { REMOTE_MEDIA_THUMBNAILS } from "../media-proxy"; +import { createNotification } from "../notification"; import { enqueuePollNotification } from "../poll-notification-tasks"; import { fetchPreviewCard } from "../previewcard"; import { @@ -68,7 +70,11 @@ import { import { toDate, toTemporalInstant } from "./date"; import { toEmoji } from "./emoji"; import { federation } from "./federation"; -import { verifyQuoteAuthorization } from "./quote"; +import { + createQuoteTargetSubject, + getQuoteAuthorizationIri, + verifyQuoteAuthorization, +} from "./quote"; import { persistRemoteEmojiReactions } from "./reactions"; import { enqueueRemoteReplyScrape } from "./replies"; import { appendPostToTimelines } from "./timeline"; @@ -356,6 +362,9 @@ export async function persistPost( let quoteTargetIri: string | null = null; let quoteTargetAccountId: Uuid | null = null; let quoteTargetAccountIri: string | null = null; + let localQuoteTarget: + | (Post & { account: Account & { owner: AccountOwner | null } }) + | null = null; if (objectLink == null && object.quoteId != null) { objectLink = object.quoteId; } @@ -366,12 +375,15 @@ export async function persistPost( quoteTargetIri = objectLink.href; const found = await db.query.posts.findFirst({ where: { iri: { eq: objectLink.href } }, - with: { account: true }, + with: { account: { with: { owner: true } } }, }); if (found != null) { quoteTargetId = found.id; quoteTargetAccountId = found.accountId; quoteTargetAccountIri = found.account.iri; + if (found.account.owner != null && found.sharingId == null) { + localQuoteTarget = found; + } logger.debug("The quote target is already persisted: {quoteTargetId}", { quoteTargetId, }); @@ -406,22 +418,76 @@ export async function persistPost( ); const previewCard = previewLink == null ? null : await fetchPreviewCard(previewLink); - const quoteAuthorizationIri = await getVerifiedQuoteAuthorizationIri( - object, - baseUrl, - quoteTargetIri, - quoteTargetAccountIri, - options, - ); + const sameQuoteTarget = + existingPost != null && + ((quoteTargetId != null && existingPost.quoteTargetId === quoteTargetId) || + (quoteTargetIri != null && + existingPost.quoteTargetIri === quoteTargetIri)); + const preserveRevokedQuote = + sameQuoteTarget && existingPost?.quoteState === "revoked"; + const quoteAuthorizationIri = preserveRevokedQuote + ? null + : await getVerifiedQuoteAuthorizationIri( + object, + baseUrl, + quoteTargetIri, + quoteTargetAccountIri, + options, + ); const preserveAcceptedQuote = - quoteTargetIri != null && - existingPost?.quoteState === "accepted" && - existingPost.quoteTargetIri === quoteTargetIri; - const preservedQuoteAuthorizationIri = - quoteAuthorizationIri ?? - (preserveAcceptedQuote ? existingPost.quoteAuthorizationIri : null); + sameQuoteTarget && existingPost?.quoteState === "accepted"; + let acceptImpoliteQuote = false; + if ( + !preserveRevokedQuote && + !preserveAcceptedQuote && + quoteAuthorizationIri == null && + account.owner == null && + object.id.origin === new URL(account.iri).origin && + localQuoteTarget != null && + (localQuoteTarget.quoteApprovalPolicy ?? "public") === "public" && + (localQuoteTarget.visibility === "public" || + localQuoteTarget.visibility === "unlisted") + ) { + const block = await db.query.blocks.findFirst({ + where: { + RAW: (blocks, { and, eq, or }) => + or( + and( + eq(blocks.accountId, localQuoteTarget.accountId), + eq(blocks.blockedAccountId, account.id), + ), + and( + eq(blocks.accountId, account.id), + eq(blocks.blockedAccountId, localQuoteTarget.accountId), + ), + )!, + }, + }); + if (block == null) { + const ctx = federation.createContext(new URL(baseUrl), undefined); + const decision = await quoteInteraction.evaluatePolicy(ctx, { + subject: createQuoteTargetSubject( + localQuoteTarget, + getCanQuoteRule(localQuoteTarget, ctx), + ), + requester: new URL(account.iri), + }); + acceptImpoliteQuote = + decision.result === "automatic" && decision.reason.type === "public"; + } + } const published = publishedRaw; const updated = updatedRaw ?? published ?? new Date(); + const postId = + existingPost?.id ?? uuidv7(Math.max(0, +(published ?? updated))); + const preservedQuoteAuthorizationIri = preserveRevokedQuote + ? null + : (quoteAuthorizationIri ?? + (preserveAcceptedQuote + ? existingPost.quoteAuthorizationIri + : acceptImpoliteQuote && localQuoteTarget != null + ? getQuoteAuthorizationIri(localQuoteTarget, { id: postId }) + : null)); // Only Articles carry a title; other types may use `name` for unrelated // purposes (e.g., poll options), so it is ignored for them: const name = @@ -439,12 +505,14 @@ export async function persistPost( sharingId: null, quoteTargetId, quoteTargetIri, - quoteState: - quoteTargetId == null + quoteState: preserveRevokedQuote + ? "revoked" + : quoteTargetId == null ? null : quoteTargetAccountId === account.id || quoteAuthorizationIri != null || - preserveAcceptedQuote + preserveAcceptedQuote || + acceptImpoliteQuote ? "accepted" : "unauthorized", quoteAuthorizationIri: preservedQuoteAuthorizationIri, @@ -470,22 +538,97 @@ export async function persistPost( published, updated, } as const; - await db - .insert(posts) - .values({ - ...values, - repliesCount: existingPost?.repliesCount ?? 0, - id: uuidv7(Math.max(0, +(published ?? updated))), - iri: object.id.href, - }) - .onConflictDoUpdate({ - target: [posts.iri], - set: values, - setWhere: eq(posts.iri, object.id.href), + // Read moderation state from the conflicting row, not just the snapshot + // above: an approval or revocation may arrive during remote fetches. + const sameStoredTarget = sql`( + (${quoteTargetId}::uuid IS NOT NULL AND ${posts.quoteTargetId} = ${quoteTargetId}::uuid) + OR (${quoteTargetIri}::text IS NOT NULL AND ${posts.quoteTargetIri} = ${quoteTargetIri}::text) + )`; + const storedRevoked = sql`${sameStoredTarget} AND ${posts.quoteState} = 'revoked'`; + const storedAccepted = sql`${sameStoredTarget} AND ${posts.quoteState} = 'accepted'`; + const impoliteAuthorization = + acceptImpoliteQuote && localQuoteTarget != null + ? sql`${localQuoteTarget.iri + "/quote_authorizations/"}::text || ${posts.id}::text` + : sql`NULL::text`; + + const postIri = object.id.href; + const persistValues = async (database: DatabaseLike) => { + // Keep the acceptance transition and its notification in one short + // transaction. Network/media work remains outside it, so a retry after + // a failed attachment fetch cannot lose the notification. + const previous = + localQuoteTarget == null + ? existingPost + : ( + await database + .select({ + quoteTargetId: posts.quoteTargetId, + quoteState: posts.quoteState, + }) + .from(posts) + .where(eq(posts.iri, postIri)) + .for("update") + )[0]; + await database + .insert(posts) + .values({ + ...values, + repliesCount: existingPost?.repliesCount ?? 0, + id: postId, + iri: postIri, + }) + .onConflictDoUpdate({ + target: [posts.iri], + set: { + ...values, + quoteState: sql`CASE + WHEN ${storedRevoked} THEN 'revoked'::quote_state + WHEN ${quoteTargetId}::uuid IS NULL THEN NULL + WHEN ${storedAccepted} THEN 'accepted'::quote_state + ELSE ${values.quoteState}::quote_state END`, + quoteAuthorizationIri: sql`CASE + WHEN ${storedRevoked} THEN NULL + WHEN ${quoteAuthorizationIri}::text IS NOT NULL THEN ${quoteAuthorizationIri}::text + WHEN ${storedAccepted} THEN ${posts.quoteAuthorizationIri} + ELSE ${impoliteAuthorization} END`, + }, + setWhere: eq(posts.iri, postIri), + }); + const post = await database.query.posts.findFirst({ + where: { iri: { eq: postIri } }, }); - let post = await db.query.posts.findFirst({ - where: { iri: { eq: object.id.href } }, - }); + + if (post == null) return undefined; + for (const targetId of new Set([ + previous?.quoteTargetId, + post.quoteTargetId, + ])) { + if (targetId != null) await updatePostStats(database, { id: targetId }); + } + if ( + localQuoteTarget?.account.owner != null && + post.quoteTargetId === localQuoteTarget.id && + post.quoteState === "accepted" && + account.id !== localQuoteTarget.accountId && + (previous?.quoteTargetId !== post.quoteTargetId || + (previous.quoteState != null && previous.quoteState !== "accepted")) + ) { + await createNotification( + { + accountOwnerId: localQuoteTarget.account.owner.id, + type: "quote", + actorAccountId: account.id, + targetPostId: post.id, + }, + database, + ); + } + return post; + }; + let post = + localQuoteTarget == null + ? await persistValues(db) + : await db.transaction(persistValues); if (post == null) return null; if ( options.fetchEmojiReactions !== false &&