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 &&