From c2b9e3cd72475adee3a6d9a57b18774131e57e44 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 6 Oct 2026 00:03:59 +0900 Subject: [PATCH] Accept impolite quotes of public local posts Approve remote quotes of local public or unlisted originals when their policy allows everyone and neither account blocks the other. Mint stable local authorizations and preserve same-target revocation on updates. Exclude local boost wrappers from this automatic approval path. Persist local quote acceptance, counts and new notifications atomically before media processing, so failed fetches and retries retain alerts. Recount QuoteRequest targets and avoid overwriting concurrent revocation. Add federation and API regressions for policy, wire formats, delivery ordering, concurrent moderation, retries and authorization revocation. Codex implemented the change with three Claude Code design reviews. OpenCode with DeepSeek Flash, Codex and Claude Code reviewed the patch; verified Codex and Claude findings were fixed and reviewed again. Validation: mise run check and 920 tests passed, with one skipped. Live Misskey/Mastodon interoperability remains untested. Fixes https://github.com/fedify-dev/hollo/issues/640 Assisted-by: Codex:gpt-6.1-sol Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5-1 --- CHANGES.md | 16 +- src/api/v1/statuses.test.ts | 115 ++++++++++ src/federation/inbox.test.ts | 376 ++++++++++++++++++++++++++++++ src/federation/inbox.ts | 26 ++- src/federation/post.test.ts | 431 +++++++++++++++++++++++++++++++++++ src/federation/post.ts | 209 ++++++++++++++--- 6 files changed, 1126 insertions(+), 47 deletions(-) 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 InboxContext; + await onPostCreated( + ctx, + new Create({ actor: new URL(remoteIri), object: note() }), + ); + const quote = await db.query.posts.findFirst({ + where: { iri: { eq: quoteIri } }, + }); + const quoteResponse = await app.request(`/api/v1/statuses/${quote!.id}`, { + headers: { authorization: bearerAuthorization(authorToken) }, + }); + expect(quoteResponse.status).toBe(200); + expect((await quoteResponse.json()).quote).toMatchObject({ + state: "accepted", + quoted_status: { id: target.id }, + }); + const counted = await app.request(`/api/v1/statuses/${target.id}`); + expect((await counted.json()).quotes_count).toBe(1); + expect( + await db.query.notifications.findMany({ + where: { type: { eq: "quote" }, targetPostId: { eq: quote!.id } }, + }), + ).toHaveLength(1); + const authorizationRequest = () => + new Request(quote!.quoteAuthorizationIri!, { + headers: { Accept: "application/activity+json" }, + }); + expect( + ( + await federation.fetch(authorizationRequest(), { + contextData: undefined, + }) + ).status, + ).toBe(200); + + const fetch = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(null, { status: 202 })); + try { + const revoked = await app.request( + `/api/v1/statuses/${target.id}/quotes/${quote!.id}/revoke`, + { + method: "POST", + headers: { authorization: bearerAuthorization(authorToken) }, + }, + ); + expect(revoked.status).toBe(200); + expect((await revoked.json()).quote.state).toBe("revoked"); + await onPostUpdated( + ctx, + new Update({ actor: new URL(remoteIri), object: note() }), + ); + const edited = await app.request(`/api/v1/statuses/${quote!.id}`); + expect((await edited.json()).quote).toMatchObject({ + state: "revoked", + quoted_status: null, + }); + const countedAgain = await app.request(`/api/v1/statuses/${target.id}`); + expect((await countedAgain.json()).quotes_count).toBe(0); + expect( + ( + await federation.fetch(authorizationRequest(), { + contextData: undefined, + }) + ).status, + ).toBe(404); + } finally { + fetch.mockRestore(); + } + }); + it("allows same-instance quotes regardless of quote policy and emits an authorization IRI", async () => { expect.assertions(7); diff --git a/src/federation/inbox.test.ts b/src/federation/inbox.test.ts index 498443ef..46bc9970 100644 --- a/src/federation/inbox.test.ts +++ b/src/federation/inbox.test.ts @@ -1,6 +1,7 @@ import type { InboxContext } from "@fedify/fedify"; import { Accept, + Create, Delete, type DocumentLoader, Note, @@ -28,6 +29,8 @@ import type { Uuid } from "../uuid"; import { onFollowAccepted, onFollowRejected, + onPostCreated, + onPostUpdated, onQuoteAuthorizationDeleted, onQuoteRequestAccepted, onQuoteRequested, @@ -35,6 +38,7 @@ import { sendQuoteUpdate, } from "./inbox"; import federation from "./index"; +import { persistPost } from "./post"; type SeededFollow = { followerId: Uuid; @@ -1736,6 +1740,378 @@ describe("quote request lifecycle", () => { return { requestCtx, sendActivity }; } + async function seedOrderedQuote() { + const { quotedPostId, quotedPostIri } = await seedLocalQuoteTarget(); + const { requestCtx, sendActivity } = createRequestCtx(); + const json = quoteRequestJson(quotedPostIri); + const create = async () => + onPostCreated( + requestCtx, + new Create({ + actor: new URL(quoterIri), + object: await Note.fromJsonLd({ + "@context": json["@context"], + ...(json.instrument as Record), + }), + }), + ); + const request = async () => + onQuoteRequested(requestCtx, await QuoteRequest.fromJsonLd(json)); + return { quotedPostId, quotedPostIri, sendActivity, create, request }; + } + + async function expectAcceptedQuote( + quotedPostId: Uuid, + quotedPostIri: string, + ) { + const quote = await db.query.posts.findFirst({ + where: { iri: { eq: `${quoterIri}/statuses/1` } }, + }); + expect(quote?.quoteState).toBe("accepted"); + expect(quote?.quoteAuthorizationIri).toBe( + `${quotedPostIri}/quote_authorizations/${quote?.id}`, + ); + expect( + ( + await db.query.posts.findFirst({ + where: { id: { eq: quotedPostId } }, + }) + )?.quotesCount, + ).toBe(1); + return quote!; + } + + it.each(["create-first", "request-first"])( + "counts and notifies once with repeated %s delivery", + async (ordering) => { + const { quotedPostId, quotedPostIri, sendActivity, create, request } = + await seedOrderedQuote(); + if (ordering === "create-first") { + await create(); + await request(); + } else { + await request(); + await create(); + } + const quote = await expectAcceptedQuote(quotedPostId, quotedPostIri); + expect(sendActivity).toHaveBeenCalledOnce(); + const [, , response] = sendActivity.mock.calls[0] as unknown as [ + unknown, + unknown, + Accept, + ]; + expect(response).toBeInstanceOf(Accept); + expect(response.resultId?.href).toBe(quote.quoteAuthorizationIri); + await create(); + await request(); + expect( + await db.query.notifications.findMany({ + where: { type: { eq: "quote" }, targetPostId: { eq: quote.id } }, + }), + ).toHaveLength(1); + await expectAcceptedQuote(quotedPostId, quotedPostIri); + }, + ); + + it("counts concurrent Create and QuoteRequest delivery once", async () => { + const { quotedPostId, quotedPostIri, sendActivity, create, request } = + await seedOrderedQuote(); + await Promise.all([create(), request()]); + const quote = await expectAcceptedQuote(quotedPostId, quotedPostIri); + expect(sendActivity).toHaveBeenCalledOnce(); + const [, , response] = sendActivity.mock.calls[0] as unknown as [ + unknown, + unknown, + Accept, + ]; + expect(response).toBeInstanceOf(Accept); + expect(response.resultId?.href).toBe(quote.quoteAuthorizationIri); + }); + + it("does not answer a QuoteRequest when revocation lands during persistence", async () => { + const { quotedPostId, quotedPostIri } = await seedLocalQuoteTarget(); + const json = quoteRequestJson(quotedPostIri); + const note = await Note.fromJsonLd({ + "@context": json["@context"], + ...(json.instrument as Record), + }); + const quote = await persistPost(db, note, "https://hollo.test", { ...ctx }); + await serveQuoteAuthorization( + quote!.quoteAuthorizationIri!, + "https://hollo.test/@quote-author", + quote!.iri, + quotedPostIri, + ); + let revoked = false; + const loader: DocumentLoader = async (url, options) => { + if (url === quote!.quoteAuthorizationIri) { + revoked = true; + 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, quotedPostId)); + }); + } + return documentLoader(url, options); + }; + const { requestCtx, sendActivity } = createRequestCtx(loader); + await onQuoteRequested( + requestCtx, + new QuoteRequest({ + id: new URL(`${quote!.iri}#quote-request`), + actor: new URL(quoterIri), + object: new URL(quotedPostIri), + instrument: note.clone({ + quoteAuthorization: new URL(quote!.quoteAuthorizationIri!), + }), + }), + ); + expect(revoked).toBe(true); + expect(sendActivity).not.toHaveBeenCalled(); + const persisted = await db.query.posts.findFirst({ + where: { id: { eq: quote!.id } }, + }); + expect(persisted?.quoteState).toBe("revoked"); + expect(persisted?.quoteAuthorizationIri).toBeNull(); + expect( + (await db.query.posts.findFirst({ where: { id: { eq: quotedPostId } } })) + ?.quotesCount, + ).toBe(0); + }); + + it("rolls back quote acceptance if its notification cannot be saved", async () => { + const { quotedPostId, quotedPostIri } = await seedLocalQuoteTarget(); + const { requestCtx } = createRequestCtx(); + const json = quoteRequestJson(quotedPostIri); + async function update() { + return onPostUpdated( + requestCtx, + new Update({ + actor: new URL(quoterIri), + object: await Note.fromJsonLd({ + "@context": json["@context"], + ...(json.instrument as Record), + }), + }), + ); + } + const notificationModule = await import("../notification"); + const notify = vi + .spyOn(notificationModule, "createNotification") + .mockRejectedValueOnce(new Error("Notification storage unavailable")); + try { + await expect(update()).rejects.toThrow( + "Notification storage unavailable", + ); + } finally { + notify.mockRestore(); + } + expect( + await db.query.posts.findFirst({ + where: { iri: { eq: `${quoterIri}/statuses/1` } }, + }), + ).toBeUndefined(); + expect( + ( + await db.query.posts.findFirst({ + where: { id: { eq: quotedPostId } }, + }) + )?.quotesCount, + ).toBe(0); + await update(); + const quote = await expectAcceptedQuote(quotedPostId, quotedPostIri); + expect( + await db.query.notifications.findMany({ + where: { type: { eq: "quote" }, targetPostId: { eq: quote.id } }, + }), + ).toHaveLength(1); + }); + + it("retains the quote notification when an Update retries a failed attachment fetch", async () => { + const { quotedPostId, quotedPostIri } = await seedLocalQuoteTarget(); + const attachmentIri = "https://remote.test/attachments/retry"; + let fail = true; + const loader: DocumentLoader = async (url, options) => { + if (url !== attachmentIri) return documentLoader(url, options); + if (fail) { + fail = false; + throw Object.assign(new Error("HTTP 503"), { + response: new Response(null, { status: 503 }), + }); + } + return { + documentUrl: url, + contextUrl: null, + document: { + "@context": "https://www.w3.org/ns/activitystreams", + id: url, + type: "Document", + }, + }; + }; + const { requestCtx } = createRequestCtx(loader); + const json = quoteRequestJson(quotedPostIri); + async function update() { + return onPostUpdated( + requestCtx, + new Update({ + actor: new URL(quoterIri), + object: await Note.fromJsonLd({ + "@context": json["@context"], + ...(json.instrument as Record), + attachment: attachmentIri, + }), + }), + ); + } + await expect(update()).rejects.toThrow("HTTP 503"); + const quote = await expectAcceptedQuote(quotedPostId, quotedPostIri); + expect( + await db.query.notifications.findMany({ + where: { type: { eq: "quote" }, targetPostId: { eq: quote.id } }, + }), + ).toHaveLength(1); + await update(); + expect( + await db.query.notifications.findMany({ + where: { type: { eq: "quote" }, targetPostId: { eq: quote.id } }, + }), + ).toHaveLength(1); + }); + + it("notifies and recounts a quote first materialized by Update", async () => { + const { quotedPostId, quotedPostIri } = await seedLocalQuoteTarget(); + const { requestCtx } = createRequestCtx(); + const json = quoteRequestJson(quotedPostIri); + async function update(targetIri?: string) { + const object = await Note.fromJsonLd({ + "@context": json["@context"], + ...(json.instrument as Record), + quote: targetIri, + quoteUri: targetIri, + _misskey_quote: targetIri, + }); + await onPostUpdated( + requestCtx, + new Update({ + actor: new URL(quoterIri), + object, + }), + ); + } + await update(quotedPostIri); + const quote = await db.query.posts.findFirst({ + where: { iri: { eq: `${quoterIri}/statuses/1` } }, + }); + expect(quote?.quoteState).toBe("accepted"); + expect( + (await db.query.posts.findFirst({ where: { id: { eq: quotedPostId } } })) + ?.quotesCount, + ).toBe(1); + expect( + await db.query.notifications.findMany({ + where: { type: { eq: "quote" }, targetPostId: { eq: quote!.id } }, + }), + ).toHaveLength(1); + // Dismissed notifications must not reappear on ordinary edits. + const notification = await db.query.notifications.findFirst({ + where: { type: { eq: "quote" }, targetPostId: { eq: quote!.id } }, + }); + const { deleteNotifications } = await import("../notification"); + await deleteNotifications(notification!.accountOwnerId, [notification!.id]); + await update(quotedPostIri); + expect( + await db.query.notifications.findMany({ + where: { type: { eq: "quote" }, targetPostId: { eq: quote!.id } }, + }), + ).toHaveLength(0); + await update(); + expect( + (await db.query.posts.findFirst({ where: { id: { eq: quotedPostId } } })) + ?.quotesCount, + ).toBe(0); + expect( + (await db.query.posts.findFirst({ where: { id: { eq: quote!.id } } })) + ?.quoteAuthorizationIri, + ).toBeNull(); + }); + + it("accepts a previously unauthorized quote on Update and recounts when retargeted", async () => { + const { quotedPostId, quotedPostIri, authorId } = + await seedLocalQuoteTarget("nobody"); + const { requestCtx } = createRequestCtx(); + async function update(iri: string) { + const json = quoteRequestJson(iri); + await onPostUpdated( + requestCtx, + new Update({ + actor: new URL(quoterIri), + object: await Note.fromJsonLd({ + "@context": json["@context"], + ...(json.instrument as Record), + }), + }), + ); + } + await update(quotedPostIri); + expect( + ( + await db.query.posts.findFirst({ + where: { iri: { eq: `${quoterIri}/statuses/1` } }, + }) + )?.quoteState, + ).toBe("unauthorized"); + await db + .update(posts) + .set({ quoteApprovalPolicy: "public" }) + .where(eq(posts.id, quotedPostId)); + await update(quotedPostIri); + const quote = await db.query.posts.findFirst({ + where: { iri: { eq: `${quoterIri}/statuses/1` } }, + }); + expect(quote?.quoteState).toBe("accepted"); + expect( + await db.query.notifications.findMany({ + where: { type: { eq: "quote" }, targetPostId: { eq: quote!.id } }, + }), + ).toHaveLength(1); + const nextId = crypto.randomUUID() as Uuid; + const nextIri = `https://hollo.test/@quote-author/${nextId}`; + await db.insert(posts).values({ + id: nextId, + iri: nextIri, + accountId: authorId, + type: "Note", + visibility: "public", + quoteApprovalPolicy: "public", + published: new Date(), + }); + await update(nextIri); + expect( + (await db.query.posts.findFirst({ where: { id: { eq: quotedPostId } } })) + ?.quotesCount, + ).toBe(0); + expect( + (await db.query.posts.findFirst({ where: { id: { eq: nextId } } })) + ?.quotesCount, + ).toBe(1); + expect( + (await db.query.posts.findFirst({ where: { id: { eq: quote!.id } } })) + ?.quoteAuthorizationIri, + ).toBe(`${nextIri}/quote_authorizations/${quote!.id}`); + // Same-owner retargets use the existing notification deduplication key. + expect( + await db.query.notifications.findMany({ + where: { type: { eq: "quote" }, targetPostId: { eq: quote!.id } }, + }), + ).toHaveLength(1); + }); + it("accepts a Mastodon-shaped QuoteRequest with a helper-built Accept", async () => { const { quotedPostIri } = await seedLocalQuoteTarget(); const { requestCtx, sendActivity } = createRequestCtx(); diff --git a/src/federation/inbox.ts b/src/federation/inbox.ts index c2880760..265e76ec 100644 --- a/src/federation/inbox.ts +++ b/src/federation/inbox.ts @@ -842,8 +842,8 @@ export async function onQuoteRequested( subject, )); const authorizationIri = getQuoteAuthorizationIri(target, persistedQuote); - await db.transaction(async (tx) => { - await tx + const committed = await db.transaction(async (tx) => { + const changed = await tx .update(posts) .set({ quoteTargetId: target.id, @@ -852,22 +852,26 @@ export async function onQuoteRequested( quoteAuthorizationIri: accepted ? authorizationIri : null, updated: new Date(), }) - .where(eq(posts.id, persistedQuote.id)); - if (accepted && !wasAccepted) { - await tx - .update(posts) - .set({ quotesCount: sql`coalesce(${posts.quotesCount}, 0) + 1` }) - .where(eq(posts.id, target.id)); - } else if (accepted) { - await updatePostStats(tx, { id: target.id }); - } + .where( + and( + eq(posts.id, persistedQuote.id), + sql`${posts.quoteState} IS DISTINCT FROM 'revoked'`, + ), + ) + .returning({ id: posts.id }); + if (changed.length < 1) return false; + // Create may have accepted and counted the instrument while this + // request was being verified. Recount instead of incrementing. + await updatePostStats(tx, { id: target.id }); if ( previousAcceptedTargetId != null && previousAcceptedTargetId !== target.id ) { await updatePostStats(tx, { id: previousAcceptedTargetId }); } + return true; }); + if (!committed) return; if (accepted) { await createQuoteNotification( persistedQuote.account, diff --git a/src/federation/post.test.ts b/src/federation/post.test.ts index 1c5e4c46..0626aeda 100644 --- a/src/federation/post.test.ts +++ b/src/federation/post.test.ts @@ -29,6 +29,7 @@ import { createAccount } from "../../tests/helpers/oauth"; import db from "../db"; import { accounts, + blocks, follows, instances, polls, @@ -40,6 +41,7 @@ import { toTemporalInstant } from "./date"; import { onPostShared } from "./inbox"; import federation from "./index"; import { persistPost, persistSharingPost, toObject } from "./post"; +import { getQuoteAuthorizationIri } from "./quote"; async function seedRemoteAccount(username: string) { const id = crypto.randomUUID() as Uuid; @@ -2339,3 +2341,432 @@ describe("Remote poll expiry task dispatch", () => { } }); }); + +describe("persistPost impolite quotes", () => { + beforeEach(async () => { + await cleanDatabase(); + }); + + async function seedTarget( + policy: "public" | "followers" | "nobody" | null = "public", + visibility: "public" | "unlisted" | "private" | "direct" = "public", + ) { + const owner = await createAccount({ username: "impolite-author" }); + const id = crypto.randomUUID() as Uuid; + const iri = `https://hollo.test/@impolite-author/${id}`; + await db.insert(posts).values({ + id, + iri, + type: "Note", + accountId: owner.id as Uuid, + visibility, + quoteApprovalPolicy: policy, + contentHtml: "

Original

", + published: new Date(), + }); + return { id, iri, accountId: owner.id as Uuid }; + } + + function quoteNote( + quoter: Parameters[0], + targetIri: string, + overrides: ConstructorParameters[0] = {}, + ) { + return new Note({ + id: new URL("https://remote.test/objects/impolite-quote"), + attribution: createPerson(quoter), + quoteUrl: new URL(targetIri), + to: PUBLIC_COLLECTION, + content: "

Quote

", + ...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 &&